fix: keep starter pack rewards client-side

This commit is contained in:
COMPUTER\EDY 2026-09-30 21:49:05 +08:00
parent 60f8fc4047
commit b8e02e08d0
9 changed files with 60 additions and 52 deletions

View File

@ -2692,7 +2692,7 @@ export default class JiaZai extends cc.Component {
coinTemp = 1000;
}
else if (/^starter_pack(?:_[2-5])?$/.test(order.itemid)) {
coinTemp = starterPackReward(order.starterPackOffer || res.starterPackOffer).coin;
coinTemp = starterPackReward(order.itemid).coin;
} else if (order.itemid == "battlepass") {
// 通行证订单
this.reissuePaaCheck();

View File

@ -1,7 +1,14 @@
/** Use the order's offer for delivery; missing snapshots retain legacy rewards. */
export function starterPackReward(offer?: any) {
if (offer?.reward) return { ...offer.reward };
return { coin: 3000, hammer: 3, freeze: 3, magic_wand: 3, infinite_health: 30 * 60 };
const STARTER_PACK_REWARDS = {
starter_pack: { coin: 3000, hammer: 1, freeze: 1, magic_wand: 1, infinite_health: 30 * 60 },
starter_pack_2: { coin: 3000, hammer: 1, freeze: 1, magic_wand: 1, infinite_health: 30 * 60 },
starter_pack_3: { coin: 4500, hammer: 3, freeze: 3, magic_wand: 3, infinite_health: 45 * 60 },
starter_pack_4: { coin: 5000, hammer: 5, freeze: 5, magic_wand: 5, infinite_health: 60 * 60 },
starter_pack_5: { coin: 8000, hammer: 8, freeze: 8, magic_wand: 8, infinite_health: 180 * 60 },
};
/** Rewards are fixed by the client SKU; the server only selects the offer tier. */
export function starterPackReward(productId = "starter_pack") {
return { ...(STARTER_PACK_REWARDS[productId] || STARTER_PACK_REWARDS.starter_pack) };
}
export function starterPackNow(): number {

View File

@ -1003,7 +1003,6 @@ var GameTool = {
//商城购买
shopBuy(productId, compensate, orderContext: any = null) {
if (/^starter_pack_[2-5]$/.test(productId) && !orderContext?.starterPackOffer?.reward) return false;
const newProduct = getNewShopProduct(productId);
const newOrderKey = newProduct && orderContext?.outTradeNo
? "ShopBGranted_" + cc.fx.GameConfig.GM_INFO.uid + "_" + orderContext.outTradeNo : "";
@ -1304,7 +1303,7 @@ var GameTool = {
case "starter_pack_3":
case "starter_pack_4":
case "starter_pack_5": {
const reward = starterPackReward(orderContext?.starterPackOffer);
const reward = starterPackReward(productId);
coin = reward.coin;
price = orderContext?.starterPackOffer?.priceFen || 300;
cc.fx.GameTool.changeCoin(coin);

View File

@ -44,7 +44,7 @@ export default class NewbieGift extends cc.Component {
private refreshOffer() {
const price = Number(this.offer?.priceFen) || 300;
for (const value of [3, 6, 12, 18, 30]) cc.find("btnContainer/" + value, this.node).active = value * 100 === price;
const reward = starterPackReward(this.offer);
const reward = starterPackReward(this.offer?.productId);
const bundle = cc.assetManager.getBundle("action_bundle");
bundle.load("img/newbieGift/newbieGiftFont", cc.SpriteAtlas, (error, atlas: cc.SpriteAtlas) => {
if (error || !cc.isValid(this)) return;
@ -110,13 +110,6 @@ export default class NewbieGift extends cc.Component {
server: version === "开发版" || version === "体验版" ? Utils.testHttpip : Utils.httpip,
});
const done = (data) => {
if (/^starter_pack_[2-5]$/.test(data?.data?.itemid || data?.data?.payment_name || "") && !data.data.starterPackOffer?.reward) {
this.polling = false;
this.paying = false;
this.openConfirmBox();
this.refreshCountdown();
return;
}
if (data?.data && /^starter_pack(?:_[2-5])?$/.test(data.data.itemid || data.data.payment_name || "")) {
this.orderContext = data.data;
}

View File

@ -70,11 +70,11 @@ const DAY = 24 * 60 * 60 * 1000;
const BEIJING_OFFSET = 8 * 60 * 60 * 1000;
const STARTER_REWARD_VERSION = PAYMENT_PROFILE_RULE.starterPackRewardVersion;
const STARTER_TIERS = [
{ productId: 'starter_pack', priceFen: 300, coin: 3000, props: 1, minutes: 30 },
{ productId: 'starter_pack_2', priceFen: 600, coin: 3000, props: 1, minutes: 30 },
{ productId: 'starter_pack_3', priceFen: 1200, coin: 4500, props: 3, minutes: 45 },
{ productId: 'starter_pack_4', priceFen: 1800, coin: 5000, props: 5, minutes: 60 },
{ productId: 'starter_pack_5', priceFen: 3000, coin: 8000, props: 8, minutes: 180 },
{ productId: 'starter_pack', priceFen: 300 },
{ productId: 'starter_pack_2', priceFen: 600 },
{ productId: 'starter_pack_3', priceFen: 1200 },
{ productId: 'starter_pack_4', priceFen: 1800 },
{ productId: 'starter_pack_5', priceFen: 3000 },
];
// A live offer is immutable, including when payment identity or daily factors change.
@ -82,12 +82,11 @@ export function starterPackPatch(user: any, now: number, trigger = false, option
const expiry = Number(user.starter_pack) || 0;
if (expiry > now) {
if (user.starterPackOffer) return null;
// Preserve an existing legacy offer's original deadline and promised rewards.
// Preserve an existing legacy offer's original deadline and tier.
return {
starterPackVersion: 3, starterPackRound: starterPackRound(user),
starterPackOffer: {
version: 3, legacy: true, round: starterPackRound(user), productId: 'starter_pack', priceFen: 300,
reward: { coin: 3000, hammer: 3, freeze: 3, magic_wand: 3, infinite_health: 1800 },
startsAt: expiry - ((Number(user.starterPackVersion) || 1) < 2 ? DAY : 2 * DAY), expiresAt: expiry,
rewardVersion: 'starter_pack_legacy', selectionBasis: 'legacy',
},
@ -137,7 +136,6 @@ export function starterPackPatch(user: any, now: number, trigger = false, option
starter_pack: expiresAt, starter_packState: 0, starterPackVersion: 3, starterPackRound: round,
starterPackOffer: {
version: 3, round, productId: tier.productId, priceFen: tier.priceFen,
reward: { coin: tier.coin, hammer: tier.props, freeze: tier.props, magic_wand: tier.props, infinite_health: tier.minutes * 60 },
startsAt: now, expiresAt, rewardVersion: STARTER_REWARD_VERSION, selectionBasis,
},
};

View File

@ -22,17 +22,17 @@
- 等级至少 15。金币小于 500、普通体力为 0 且无限体力无效、三个道具各不超过 3、商城/局内购买意向、真实购买行为、当前关卡连续失败至少 4 次,任一满足即可。失败次数仍由客户端按账号和关卡计数,后端校验关卡和整数次数。
- 新轮只在满足触发条件且可安全展示的界面开始计时。普通读取不启动。购买意向恢复入口,不强制弹窗;资源提醒每轮最多自动展示一次,玩法、支付及领奖过程延后处理。
- 本轮 `starterPackOffer` 固定商品、价格、奖励、起止时间和轮号;期间身份或画像改变不会重选。
- 本轮 `starterPackOffer` 固定商品、价格、起止时间和轮号;期间身份或画像改变不会重选。奖励由客户端按商品 ID 固定配置。
- 下轮最早为原截止时间所属北京时间日期加 2 天的零点:9 月 23 日 02:00 到期,9 月 25 日 00:00 才能再触发。提前购买不改变这个截止时间。购买状态按轮次重置。
- 旧有效礼包保留原截止时间和原奖励(3000 金币、三个道具各 3、无限体力 1800 秒);旧已购用户无可信截止时间时可在下一次合格触发进入新轮。
- 旧有效礼包保留原截止时间并按 `starter_pack` 使用客户端固定的 3 元奖励;旧已购用户无可信截止时间时可在下一次合格触发进入新轮。
`limitedTimeEvent` 保留 read/save/reactivate/shown 动作;save/reactivate 需 reason。返回新增 `starterPackOffer` 和 `starterPackAutoShownRound`。shown 传本轮 expiry 和 automatic;支持客户端表单的字符串编码。`starterPackLastShownAt` 保留作为记录,不再决定冷却。
## 支付及兼容
现有下单接口仅对上述五个 SKU 校验当前有效轮次、未购买状态与商品匹配,并固定服务端报价和数量 1。订单保存 `starterPackOffer`、`starterPackBuyRound`、`starterPackExpiresAt`、`activityType`、`rewardVersion`,旧 iOS 转单继续携带这些字段。
现有下单接口仅对上述五个 SKU 校验当前有效轮次、未购买状态与商品匹配,并固定服务端报价和数量 1。订单保存不含奖励内容的 `starterPackOffer`、`starterPackBuyRound`、`starterPackExpiresAt`、`activityType`、`rewardVersion`,旧 iOS 转单继续携带这些字段。
新订单到期后不可创建;到期前的有效订单可继续原支付流程。回调只关闭所属轮次,不关闭后开的礼包。查单携带订单快照;`getOrderReward` 仍返回 `data="ok"`,礼包快照位于响应顶层。前端从该订单的快照发奖,缺快照旧单走原奖励,不使用当前轮报价替换旧单。登录和账号迁移保留轮次及报价。
新订单到期后不可创建;到期前的有效订单可继续原支付流程。回调只关闭所属轮次,不关闭后开的礼包。查单携带商品和轮次快照;`getOrderReward` 仍返回 `data="ok"`,礼包快照位于响应顶层。前端只按订单商品 ID 读取本地固定奖励,不读取后端奖励字段。登录和账号迁移保留轮次及报价。
保留原跨设备并发下单、客户端发奖及旧 iOS 响应中断的边界,未新增订单占位锁或服务端发奖事务。礼包待支付订单不再按原通用 3 小时期限清理,以免删除仍可能支付的旧单;关闭但未成功的礼包订单也会保留,不因此发奖。退款不在本次范围内。

View File

@ -79,15 +79,15 @@ function order(user, extra = {}) {
const ctx = body => ({ body, headers: {}, socket: { remoteAddress: '127.0.0.1' } });
const notification = () => ctx({ MiniGame: { Payload: JSON.stringify({ OutTradeNo: 'wcx_test' }) } });
function setOffer(user, productId, priceFen, reward = { coin: 3000, hammer: 1, freeze: 1, magic_wand: 1, infinite_health: 1800 }) {
function setOffer(user, productId, priceFen) {
user.starterPackVersion = 3;
user.pay_user = productId !== 'starter_pack';
user.starterPackOffer = { version: 3, round: user.starterPackRound, productId, priceFen, reward,
user.starterPackOffer = { version: 3, round: user.starterPackRound, productId, priceFen,
startsAt: NOW - 10000, expiresAt: user.starter_pack, rewardVersion: 'starter_pack_v3_20260930', selectionBasis: 'profile' };
return user.starterPackOffer;
}
test('each lucky tier uses the server offer price, quantity and reward snapshot on native payment', async () => {
test('each lucky tier uses the server offer price, quantity and tier snapshot on native payment', async () => {
for (const create of [androidOrder, nativeIosOrder]) {
for (const [productId, priceFen] of [['starter_pack', 300], ['starter_pack_2', 600], ['starter_pack_3', 1200], ['starter_pack_4', 1800], ['starter_pack_5', 3000]]) {
const user = reset(), offer = setOffer(user, productId, priceFen);
@ -96,6 +96,7 @@ test('each lucky tier uses the server offer price, quantity and reward snapshot
const saved = tables.order[0];
assert.equal(saved.goodsPrice, priceFen); assert.equal(saved.itemCount, 1);
assert.deepEqual(saved.starterPackOffer, offer);
assert.equal(saved.starterPackOffer.reward, undefined);
assert.equal(saved.activityType, 'lucky'); assert.equal(saved.rewardVersion, offer.rewardVersion);
const signed = JSON.parse(result.data.signData);
assert.equal(signed.productId, productId); assert.equal(signed.goodsPrice, priceFen); assert.equal(signed.buyQuantity, 1);

View File

@ -41,6 +41,7 @@ registerHooks({
});
const { default: activity, starterPackPatch, starterPackOrderSnapshot } = await import('../functions/limitedTimeEvent.ts');
const { PAYMENT_PROFILE_RULE } = await import('../functions/paymentProfile.ts');
const { starterPackReward } = await import('../../../assets/Script/module/Config/StarterPack.ts');
function reset(patch = {}) {
now = NOW; beforeUpdate = null; failProfile = false;
for (const name of Object.keys(tables)) tables[name] = [];
@ -68,10 +69,11 @@ test('read or save without a reason cannot start; first unpaid offer lasts 12 ho
assert.equal(user.starter_pack, NOW + DAY / 2);
assert.equal(user.starterPackRound, 1);
assert.equal(user.starterPackOffer.productId, 'starter_pack');
assert.deepEqual(user.starterPackOffer.reward, { coin: 3000, hammer: 1, freeze: 1, magic_wand: 1, infinite_health: 1800 });
assert.equal(user.starterPackOffer.reward, undefined);
assert.deepEqual(starterPackReward('starter_pack'), { coin: 3000, hammer: 1, freeze: 1, magic_wand: 1, infinite_health: 1800 });
});
test('formula thresholds select four paid products and exact rewards for six hours', async () => {
test('formula thresholds select four paid products while the client fixes rewards by SKU', async () => {
for (const [daily, id, price, coin, props, minutes] of [
[0, 'starter_pack_2', 600, 3000, 1, 30], [1199, 'starter_pack_2', 600, 3000, 1, 30],
[1200, 'starter_pack_3', 1200, 4500, 3, 45], [1800, 'starter_pack_4', 1800, 5000, 5, 60],
@ -82,7 +84,8 @@ test('formula thresholds select four paid products and exact rewards for six hou
assert.equal(user.starter_pack, NOW + DAY / 4);
assert.equal(user.starterPackOffer.productId, id);
assert.equal(user.starterPackOffer.priceFen, price);
assert.deepEqual(user.starterPackOffer.reward, { coin, hammer: props, freeze: props, magic_wand: props, infinite_health: minutes * 60 });
assert.equal(user.starterPackOffer.reward, undefined);
assert.deepEqual(starterPackReward(id), { coin, hammer: props, freeze: props, magic_wand: props, infinite_health: minutes * 60 });
}
});
@ -134,15 +137,16 @@ test('a displayed offer stays fixed when payment identity and daily factors chan
assert.equal(starterPackOrderSnapshot(user, 'starter_pack').starterPackOffer.priceFen, 300);
});
test('legacy active offers retain their original deadline and old promised reward', async () => {
test('legacy active offers retain their original deadline and use the client 3 yuan reward', async () => {
for (const version of [undefined, 1, 2]) {
const user = reset({ starter_pack: NOW + DAY, starterPackVersion: version });
await Promise.all([read(), read()]);
assert.equal(user.starter_pack, NOW + DAY);
assert.equal(user.starterPackRound, 1);
assert.equal(user.starterPackOffer.legacy, true);
assert.equal(user.starterPackOffer.reward.hammer, 3);
assert.equal(user.starterPackOffer.reward.infinite_health, 1800);
assert.equal(user.starterPackOffer.reward, undefined);
assert.deepEqual(starterPackReward(user.starterPackOffer.productId),
{ coin: 3000, hammer: 1, freeze: 1, magic_wand: 1, infinite_health: 1800 });
const locked = copy(user); await read(); assert.deepEqual(user, locked);
}
});
@ -227,11 +231,11 @@ test('form-encoded failure counts and automatic flags match the client transport
}
});
test('order snapshots bind a valid current SKU, price, reward and cycle', async () => {
test('order snapshots bind a valid current SKU, price and cycle without reward data', async () => {
const user = reset({ pay_user: true }); profile(user, 3000); await trigger();
const snapshot = starterPackOrderSnapshot(user, 'starter_pack_5');
assert.equal(snapshot.starterPackOffer.priceFen, 3000);
assert.equal(snapshot.starterPackOffer.reward.coin, 8000);
assert.equal(snapshot.starterPackOffer.reward, undefined);
assert.equal(snapshot.activityType, 'lucky');
assert.equal(snapshot.rewardVersion, PAYMENT_PROFILE_RULE.starterPackRewardVersion);
assert.throws(() => starterPackOrderSnapshot(user, 'starter_pack'), /档位/);

View File

@ -185,7 +185,7 @@ test('pending order still completes after expiry while a fresh order is forbidde
let grantedOrder; f.cc.fx.GameTool.shopBuy = (id, comp, data) => { grantedOrder = data.outTradeNo; };
f.gift.onShow(); assert.equal(grantedOrder, 'late'); assert.equal(f.gift.node.active, false);
});
for (const remaining of [0, -60, 600]) test(`starter pack grants 3000/3 and 30 minutes for purchases and compensation, with ${remaining}s existing health`, () => {
for (const remaining of [0, -60, 600]) test(`starter pack grants 3000/1 and 30 minutes for purchases and compensation, with ${remaining}s existing health`, () => {
const f = fixture();
const ast = source('assets/Script/module/Tool/GameTool.ts');
const obj = ast.statements.find(n => ts.isVariableStatement(n) && n.declarationList.declarations.some(d => d.name.getText(ast) === 'GameTool'));
@ -203,7 +203,7 @@ for (const remaining of [0, -60, 600]) test(`starter pack grants 3000/3 and 30 m
tool.shopBuy('starter_pack', true, { outTradeNo: 'new', starterPackVersion: 2 });
tool.shopBuy('starter_pack', false, { outTradeNo: 'new', starterPackVersion: 2 });
assert.deepEqual(coins, [3000,3000]);
assert.deepEqual(props.map(p => [p.hammer,p.freeze,p.magic_wand]), [[3,3,3],[3,3,3]]);
assert.deepEqual(props.map(p => [p.hammer,p.freeze,p.magic_wand]), [[1,1,1],[1,1,1]]);
const base = nowSeconds + Math.max(0, remaining);
assert.deepEqual(synced, [base + 1800, base + 3600]);
assert.equal(f.storage.get('userPowerTime'), base + 3600);
@ -680,9 +680,9 @@ const tiers = [
['starter_pack_3', 1200, 4500, 3, 2700], ['starter_pack_4', 1800, 5000, 5, 3600],
['starter_pack_5', 3000, 8000, 8, 10800],
];
for (const [productId, priceFen, coin, props, seconds] of tiers) test(`${productId} uses order snapshot for UI, payment and legacy grant flow`, () => {
for (const [productId, priceFen, coin, props, seconds] of tiers) test(`${productId} uses server tier and client-fixed rewards`, () => {
const f=fixture(), coins=[], grants=[], health=[], displayed=[];
const offer={version:3,round:2,productId,priceFen,reward:{coin,hammer:props,freeze:props,magic_wand:props,infinite_health:seconds}};
const offer={version:3,round:2,productId,priceFen};
Object.assign(f.info,{starterPackRound:2,starterPackOffer:offer});
f.gift.init(true);
for(const value of [3,6,12,18,30]) assert.equal(f.cc.find('btnContainer/'+value,f.gift.node).active,value*100===priceFen);
@ -717,14 +717,14 @@ test('new round resets purchased status but stale round and same-round factor ch
assert.equal(f.info.starterPackRound,2); assert.equal(f.info.starterPackPurchased,false);
});
test('late old order gives its original reward and leaves the current round purchasable', () => {
const f=fixture(); f.info.starterPackRound=4; f.info.starterPackOffer={round:4,reward:{coin:8000}};
test('late old order uses its SKU reward and leaves the current round purchasable', () => {
const f=fixture(); f.info.starterPackRound=4; f.info.starterPackOffer={round:4,productId:'starter_pack_5',priceFen:3000};
const coins=[], props=[]; f.globals.MapConroler={_instance:null};
const tool=gameToolMethods(f,['shopBuy']);
f.cc.fx.GameTool.changeCoin=n=>coins.push(n); tool.getShopProp=data=>props.push(data); tool.setUserPowerTime=()=>{};
tool.shopBuy('starter_pack',true,{outTradeNo:'legacy'});
assert.equal(coins[0],3000); assert.equal(props[0].hammer,3); assert.equal(f.info.starterPackPurchased,false);
tool.shopBuy('starter_pack_3',true,{outTradeNo:'old3',starterPackBuyRound:3,starterPackOffer:{priceFen:1200,reward:{coin:4500,freeze:3,hammer:3,magic_wand:3,infinite_health:2700}}});
assert.equal(coins[0],3000); assert.equal(props[0].hammer,1); assert.equal(f.info.starterPackPurchased,false);
tool.shopBuy('starter_pack_3',true,{outTradeNo:'old3',starterPackBuyRound:3,starterPackOffer:{priceFen:1200}});
assert.equal(coins[1],4500); assert.equal(f.info.starterPackPurchased,false);
});
@ -740,13 +740,19 @@ test('level purchase waits for a safe screen and queued purchase intent never fo
});
test('new SKU without its order snapshot is not acknowledged or granted legacy rewards', () => {
const f=fixture(); const tool=gameToolMethods(f,['shopBuy']);
assert.equal(tool.shopBuy('starter_pack_5',true,{outTradeNo:'missing'}),false);
test('new SKU is acknowledged and uses client rewards without a server reward snapshot', () => {
const f=fixture(), coins=[], props=[], health=[];
f.globals.MapConroler={_instance:null};
const tool=gameToolMethods(f,['shopBuy']);
f.cc.fx.GameTool.changeCoin=n=>coins.push(n); tool.getShopProp=data=>props.push(data); tool.setUserPowerTime=n=>health.push(n);
tool.shopBuy('starter_pack_5',true,{outTradeNo:'fixed-reward'});
assert.deepEqual(coins,[8000]); assert.equal(props[0].hammer,8); assert.deepEqual(health,[10800]);
f.gift.pendingOrder='missing';
f.api.getPayInfo=done=>done({code:1,data:{pay_state:2,itemid:'starter_pack_5'}});
f.api.setPayInfo=()=>assert.fail('missing snapshot acknowledged');
f.cc.fx.GameTool.shopBuy=()=>assert.fail('missing snapshot rewarded');
let acknowledged=false, granted='';
f.api.setPayInfo=done=>{acknowledged=true;done({code:1,data:'ok'});};
f.cc.fx.GameTool.shopBuy=id=>{granted=id;};
f.gift.onShow();
assert.equal(f.gift.pendingOrder,'missing'); assert.equal(f.gift.polling,false); assert.equal(f.gift.paying,false);
assert.equal(acknowledged,true); assert.equal(granted,'starter_pack_5');
assert.equal(f.gift.pendingOrder,''); assert.equal(f.gift.polling,false); assert.equal(f.gift.paying,false);
});