This commit is contained in:
COMPUTER\EDY 2026-10-10 11:08:23 +08:00
commit 9bf06c7630
18 changed files with 300 additions and 121 deletions

View File

@ -1,13 +0,0 @@
{
"ver": "1.1.3",
"uuid": "d682dfb5-ffb3-4397-98b7-bc758e1a1fd0",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -3,6 +3,7 @@ import Art from './BattlePassArt';
import NumberToImage from './NumberToImage'; import NumberToImage from './NumberToImage';
import { settlementRewards, settlementSteps } from './module/Config/BattlePassSettlementModel'; import { settlementRewards, settlementSteps } from './module/Config/BattlePassSettlementModel';
import { MiniGameSdk } from './Sdk/MiniGameSdk'; import { MiniGameSdk } from './Sdk/MiniGameSdk';
import BattlePassSettlementCheck from './module/Config/BattlePassSettlementCheck';
const { ccclass } = cc._decorator; const { ccclass } = cc._decorator;
@ccclass @ccclass
@ -20,6 +21,10 @@ export default class BattlePassSettlement extends cc.Component {
private finished: () => void; private finished: () => void;
private iosOrder = ''; private iosOrder = '';
private recoveredRewards: any[] = []; private recoveredRewards: any[] = [];
private check: BattlePassSettlementCheck;
private readVersion = 0;
private checkedThrough = 0;
private retryDelay = 5;
private back = (e: cc.Event.EventKeyboard) => { private back = (e: cc.Event.EventKeyboard) => {
if (e.keyCode === cc.macro.KEY.escape || e.keyCode === 6) this.close(); if (e.keyCode === cc.macro.KEY.escape || e.keyCode === 6) this.close();
}; };
@ -31,29 +36,48 @@ export default class BattlePassSettlement extends cc.Component {
// Resolve paid entitlements before selecting frozen rewards. Other login // Resolve paid entitlements before selecting frozen rewards. Other login
// orders remain in the home queue until this entire sequence is closed. // orders remain in the home queue until this entire sequence is closed.
const gm = cc.fx.GameConfig.GM_INFO; const gm = cc.fx.GameConfig.GM_INFO;
this.check = BattlePassSettlementCheck.forPlayer(gm);
// Usually observed by the existing homepage read. Also cover failed
// or locked entry reads so the login cache never lacks a deadline.
if (!this.check.hasSchedule()) {
const period: any = await new Promise((resolve, reject) => Utils.POSTJSON('passCheck', {
uid: gm.uid || Utils.uid, action: 'read'
}, res => res.code === 1 ? resolve(res) : reject(res)));
this.check.observeStart(Number(period.data.time));
if (!this.check.hasSchedule()) throw { msg: '战令周期时间不可用' };
}
if (!this.isCurrentRun()) return;
for (const order of (gm.allOutTradeNo || []).slice()) { for (const order of (gm.allOutTradeNo || []).slice()) {
if (!/^battlepass(?:_30|_12)?$/.test(order.itemid)) continue; if (!/^battlepass(?:_30|_12)?$/.test(order.itemid)) continue;
if (this.check.reviewOrders.has(order.outTradeNo)) continue;
try { try {
const paid = await Utils.completePassOrder(order.outTradeNo); const paid = await Utils.completePassOrder(order.outTradeNo);
if (!this.isCurrentRun()) return;
if (!paid.data.alreadyGranted) { if (!paid.data.alreadyGranted) {
if (paid.data.postSeason) this.recoveredRewards.push(...(paid.data.rewards || [])); if (paid.data.postSeason) this.recoveredRewards.push(...(paid.data.rewards || []));
else if (paid.data.bonus) this.recoveredRewards.push({ type: 'coin', count: 3000 }, { type: 'pass_key', count: 20 }); else if (paid.data.bonus) this.recoveredRewards.push({ type: 'coin', count: 3000 }, { type: 'pass_key', count: 20 });
} }
gm.allOutTradeNo = gm.allOutTradeNo.filter(o => o.outTradeNo !== order.outTradeNo); gm.allOutTradeNo = gm.allOutTradeNo.filter(o => o.outTradeNo !== order.outTradeNo);
} catch (e) { } catch (e) {
// if (e.code !== 410) throw e; if (e.code !== 410) throw e;
// Paid duplicates/obsolete orders remain available for manual review. // Paid duplicates/obsolete orders remain available for manual review.
this.check.reviewOrders.add(order.outTradeNo);
cc.warn('战令订单需要人工处理', order.outTradeNo, e.msg); cc.warn('战令订单需要人工处理', order.outTradeNo, e.msg);
} }
} }
this.readVersion = this.check.version;
const readStarted = Date.now();
const result = await Utils.settlementRequest('read'); const result = await Utils.settlementRequest('read');
if (!cc.isValid(this.node)) return; if (!this.isCurrentRun()) return;
this.serverTime = result.data.serverTime; this.serverTime = result.data.serverTime;
this.check.syncClock(this.serverTime);
// A request spanning the cutoff may contain a pre-cutoff snapshot.
this.checkedThrough = this.serverTime - Math.max(0, Date.now() - readStarted);
this.latest = Math.max(0, ...result.data.rows.map(r => r.end)); this.latest = Math.max(0, ...result.data.rows.map(r => r.end));
this.rows = settlementSteps(result.data.rows, this.latest, this.serverTime); this.rows = settlementSteps(result.data.rows, this.latest, this.serverTime);
if (!this.rows.length && !this.recoveredRewards.length) { done(); this.destroy(); return; } if (!this.rows.length && !this.recoveredRewards.length) { this.finish(); return; }
await Art.load(); await Art.load();
if (!cc.isValid(this.node)) return; if (!this.isCurrentRun()) return;
this.root = cc.instantiate(Art.settlementPrefab); this.root.active = false; this.root.parent = this.node; this.root.zIndex = 1000; this.root = cc.instantiate(Art.settlementPrefab); this.root.active = false; this.root.parent = this.node; this.root.zIndex = 1000;
this.panel = this.root.getChildByName('panel'); this.panel = this.root.getChildByName('panel');
const fit = Math.min(this.node.width / this.root.width, this.node.height / this.root.height); const fit = Math.min(this.node.width / this.root.width, this.node.height / this.root.height);
@ -68,15 +92,30 @@ export default class BattlePassSettlement extends cc.Component {
if (typeof wx !== 'undefined') wx.onShow(this.resume); if (typeof wx !== 'undefined') wx.onShow(this.resume);
this.next(); this.next();
} catch (e) { } catch (e) {
if (!this.isCurrentRun()) return;
this.error(e); this.error(e);
if (this.root && cc.isValid(this.root)) this.root.destroy(); if (this.root && cc.isValid(this.root)) this.root.destroy();
cc.systemEvent.off(cc.SystemEvent.EventType.KEY_DOWN, this.back, this); cc.systemEvent.off(cc.SystemEvent.EventType.KEY_DOWN, this.back, this);
// @ts-ignore // @ts-ignore
if (typeof wx !== 'undefined') wx.offShow(this.resume); if (typeof wx !== 'undefined') wx.offShow(this.resume);
// Keep the queue reserved until the settlement check can complete. // Keep the queue reserved until the settlement check can complete.
this.scheduleOnce(() => this.run(done), 5); this.scheduleOnce(() => this.run(done), this.retryDelay);
this.retryDelay = Math.min(this.retryDelay * 2, 60);
} }
} }
private isCurrentRun(): boolean {
return cc.isValid(this, true) && cc.isValid(this.node, true) &&
this.check === BattlePassSettlementCheck.forPlayer(cc.fx.GameConfig.GM_INFO);
}
private finish() {
// Cache only after every popup and reward animation has completed. A
// destroyed scene or failed request must leave the login check pending.
if (!this.isCurrentRun()) return;
this.check.complete(this.checkedThrough, this.readVersion);
if (this.root && cc.isValid(this.root)) this.root.destroy();
this.destroy();
this.finished();
}
private error(e: any) { private error(e: any) {
cc.log(e.msg || e.message || '网络异常,请重试'); cc.log(e.msg || e.message || '网络异常,请重试');
// MiniGameSdk.API.showToast(e.msg || e.message || '网络异常,请重试'); // MiniGameSdk.API.showToast(e.msg || e.message || '网络异常,请重试');
@ -89,7 +128,7 @@ export default class BattlePassSettlement extends cc.Component {
this.playRewards(rewards, () => this.next()); this.playRewards(rewards, () => this.next());
return; return;
} }
this.root.destroy(); this.finished(); this.destroy(); return; this.finish(); return;
} }
if (!this.row.claimed && !this.row.emptySeen) { if (!this.row.claimed && !this.row.emptySeen) {
const rewards = settlementRewards(this.row, cc.fx.GameConfig.PASS_CHECK); const rewards = settlementRewards(this.row, cc.fx.GameConfig.PASS_CHECK);

View File

@ -6,6 +6,7 @@
// - https://docs.cocos.com/creator/manual/en/scripting/life-cycle-callbacks.html // - https://docs.cocos.com/creator/manual/en/scripting/life-cycle-callbacks.html
import Utils from "./module/Pay/Utils"; import Utils from "./module/Pay/Utils";
import BattlePassSettlementCheck from './module/Config/BattlePassSettlementCheck';
import CloudRiseService from "./module/Config/CloudRiseService"; import CloudRiseService from "./module/Config/CloudRiseService";
import ActivityScheduleService from "./module/Config/ActivityScheduleService"; import ActivityScheduleService from "./module/Config/ActivityScheduleService";
import GoldMinerService from './coin_madness/GoldMinerService'; import GoldMinerService from './coin_madness/GoldMinerService';
@ -339,6 +340,7 @@ export default class GameManager extends cc.Component {
if (data.data.token) { if (data.data.token) {
cc.fx.GameConfig.GM_INFO.token = data.data.token; cc.fx.GameConfig.GM_INFO.token = data.data.token;
} }
BattlePassSettlementCheck.resetLogin();
const wucaiType = Number(data.data.wucai_type); const wucaiType = Number(data.data.wucai_type);
cc.fx.GameConfig.GM_INFO.wucai_type = Number.isInteger(wucaiType) ? wucaiType : -1; cc.fx.GameConfig.GM_INFO.wucai_type = Number.isInteger(wucaiType) ? wucaiType : -1;
cc.fx.GameConfig.GM_INFO.wucai_preview = data.data.wucai_preview || null; cc.fx.GameConfig.GM_INFO.wucai_preview = data.data.wucai_preview || null;

View File

@ -12,6 +12,7 @@ import MapConroler from "./Map";
import { DRAWABLE_CAT_IDS, getOwnedDrawableCats, getCatDrawCost } from "./module/Tool/CatDrawRules"; import { DRAWABLE_CAT_IDS, getOwnedDrawableCats, getCatDrawCost } from "./module/Tool/CatDrawRules";
import HomePopupQueue from "../home_popup_queue/HomePopupQueue"; import HomePopupQueue from "../home_popup_queue/HomePopupQueue";
import BattlePassSettlement from './BattlePassSettlement'; import BattlePassSettlement from './BattlePassSettlement';
import BattlePassSettlementCheck from './module/Config/BattlePassSettlementCheck';
import CloudRiseView from "./CloudRiseView"; import CloudRiseView from "./CloudRiseView";
import HomeActivitySchedule from "./HomeActivitySchedule"; import HomeActivitySchedule from "./HomeActivitySchedule";
import CoinMadnessHost from './coin_madness/CoinMadnessHost'; import CoinMadnessHost from './coin_madness/CoinMadnessHost';
@ -871,6 +872,7 @@ export default class JiaZai extends cc.Component {
onDestroy() { onDestroy() {
cc.game.off(cc.game.EVENT_SHOW, this.checkBattlePassSettlement, this); cc.game.off(cc.game.EVENT_SHOW, this.checkBattlePassSettlement, this);
this.unschedule(this.checkBattlePassSettlement);
MiniProgramBenefitsBridge.instance.detach(this.node); MiniProgramBenefitsBridge.instance.detach(this.node);
this.homeAutomaticTasks = []; this.homeAutomaticTasks = [];
this.homePopupOpenRequest++; this.homePopupOpenRequest++;
@ -986,8 +988,9 @@ export default class JiaZai extends cc.Component {
// 9. 检查月卡过期状态 // 9. 检查月卡过期状态
// ============================================ // ============================================
start() { start() {
// 与黄金矿工一致:每次进入首页及游戏回到前台时重新检查。 // 沿用首页/回前台入口;每秒只检查本地截止时间,不轮询接口。
this.checkBattlePassSettlement(); this.checkBattlePassSettlement();
this.schedule(this.checkBattlePassSettlement, 1);
cc.game.on(cc.game.EVENT_SHOW, this.checkBattlePassSettlement, this); cc.game.on(cc.game.EVENT_SHOW, this.checkBattlePassSettlement, this);
if (!this.node.getComponent(HomeActivitySchedule)) this.node.addComponent(HomeActivitySchedule); if (!this.node.getComponent(HomeActivitySchedule)) this.node.addComponent(HomeActivitySchedule);
CloudRiseView.mount(this.node, () => this.startGame()); CloudRiseView.mount(this.node, () => this.startGame());
@ -4806,8 +4809,12 @@ export default class JiaZai extends cc.Component {
/** 与黄金矿工使用相同显示时机,仍优先处理旧赛季结算。 */ /** 与黄金矿工使用相同显示时机,仍优先处理旧赛季结算。 */
private checkBattlePassSettlement(): void { private checkBattlePassSettlement(): void {
if (!this.isHomeRuntimeAlive() || !this.node.activeInHierarchy || cc.fx.GameConfig.isGameplayEntryPreparing()) return; if (!this.isHomeRuntimeAlive() || !this.node.activeInHierarchy || cc.fx.GameConfig.isGameplayEntryPreparing()) return;
const gm = cc.fx.GameConfig.GM_INFO;
if (!BattlePassSettlementCheck.forPlayer(gm).needsCheck(gm.allOutTradeNo || [])) return;
HomePopupQueue.get(this.node).enqueue('battlePassSettlement', -100, done => { HomePopupQueue.get(this.node).enqueue('battlePassSettlement', -100, done => {
if (!this.isHomeRuntimeAlive() || !this.node.activeInHierarchy || cc.fx.GameConfig.isGameplayEntryPreparing()) { done(); return; } if (!this.isHomeRuntimeAlive() || !this.node.activeInHierarchy || cc.fx.GameConfig.isGameplayEntryPreparing()) { done(); return; }
const info = cc.fx.GameConfig.GM_INFO;
if (!BattlePassSettlementCheck.forPlayer(info).needsCheck(info.allOutTradeNo || [])) { done(); return; }
const settlement = this.node.getComponent(BattlePassSettlement); const settlement = this.node.getComponent(BattlePassSettlement);
(cc.isValid(settlement, true) ? settlement : this.node.addComponent(BattlePassSettlement)).run(() => { (cc.isValid(settlement, true) ? settlement : this.node.addComponent(BattlePassSettlement)).run(() => {
if (this.isHomeRuntimeAlive() && this.node.activeInHierarchy && !cc.fx.GameConfig.isGameplayEntryPreparing()) { if (this.isHomeRuntimeAlive() && this.node.activeInHierarchy && !cc.fx.GameConfig.isGameplayEntryPreparing()) {

View File

@ -0,0 +1,54 @@
import { PASS_PERIOD } from './BattlePassModel';
/** Memory-only login cache; scene destruction must not reset a completed check. */
export default class BattlePassSettlementCheck {
private static player: any;
private static identity = '';
private static current: BattlePassSettlementCheck;
static forPlayer(player: any): BattlePassSettlementCheck {
const identity = String(player.uid || '') + ':' + String(player.token || '');
if (this.player !== player || this.identity !== identity || !this.current) {
this.player = player;
this.identity = identity;
this.current = new BattlePassSettlementCheck();
}
return this.current;
}
static resetLogin() { this.current = null; }
private periodEnd = 0;
private checkedAt = 0;
private checkedVersion = -1;
private offset = 0;
version = 0;
readonly reviewOrders = new Set<string>();
hasSchedule(): boolean { return this.periodEnd > 0; }
observeStart(start: number) {
if (Number.isFinite(start) && start > 0) this.periodEnd = start + PASS_PERIOD;
}
syncClock(serverTime: number) {
if (Number.isFinite(serverTime) && serverTime > 0) this.offset = serverTime - Date.now();
}
invalidate() { this.version++; }
needsCheck(orders: any[] = []): boolean {
if (orders.some(order => /^battlepass(?:_30|_12)?$/.test(order.itemid) && !this.reviewOrders.has(order.outTradeNo))) return true;
if (!this.checkedAt || this.checkedVersion !== this.version) return true;
if (!this.periodEnd) return false;
// Derive the first boundary after the last read, even if another API has
// already advanced the global season while we were in GameScene.
const nextEnd = this.periodEnd + (Math.floor((this.checkedAt - this.periodEnd) / PASS_PERIOD) + 1) * PASS_PERIOD;
return Date.now() + this.offset >= nextEnd;
}
complete(serverTime: number, version: number) {
this.checkedAt = serverTime;
this.checkedVersion = version;
}
}

View File

@ -0,0 +1,10 @@
{
"ver": "1.1.0",
"uuid": "d98a4d7a-9c08-45b2-b8c8-e44b23896e55",
"importer": "typescript",
"isPlugin": false,
"loadPluginInWeb": true,
"loadPluginInNative": true,
"loadPluginInEditor": false,
"subMetas": {}
}

View File

@ -1,6 +1,7 @@
import { MiniGameSdk } from "../../Sdk/MiniGameSdk"; import { MiniGameSdk } from "../../Sdk/MiniGameSdk";
import { normalizePass, mergePassInfo, legacyPassInfo, legacyExperience, passTier, PASS_PERIOD } from '../Config/BattlePassModel'; import { normalizePass, mergePassInfo, legacyPassInfo, legacyExperience, passTier, PASS_PERIOD } from '../Config/BattlePassModel';
import { settlementRewards, settlementClaimFlags } from '../Config/BattlePassSettlementModel'; import { settlementRewards, settlementClaimFlags } from '../Config/BattlePassSettlementModel';
import BattlePassSettlementCheck from '../Config/BattlePassSettlementCheck';
//#region 测试 //#region 测试
/** 测试 */ /** 测试 */
export default class Utils { export default class Utils {
@ -1514,8 +1515,11 @@ export default class Utils {
if (callBack) callBack({ code: 0, msg: '战令配置尚未加载,请稍后重试' }); if (callBack) callBack({ code: 0, msg: '战令配置尚未加载,请稍后重试' });
return; return;
} }
const settlementCheck = BattlePassSettlementCheck.forPlayer(cc.fx.GameConfig.GM_INFO);
Utils.POST("passCheck", { uid: cc.fx.GameConfig.GM_INFO.uid || Utils.uid, action: 'read' }, res => { Utils.POST("passCheck", { uid: cc.fx.GameConfig.GM_INFO.uid || Utils.uid, action: 'read' }, res => {
if (res.code !== 1) { if (callBack) callBack(res); return; } if (res.code !== 1) { if (callBack) callBack(res); return; }
// time is the idcount period origin, not the user's archive end or server now.
settlementCheck.observeStart(Number(res.data.time));
Utils.passRequest('read').then(extra => { Utils.passRequest('read').then(extra => {
const raw = typeof extra.data.legacy === 'string' ? JSON.parse(extra.data.legacy) : extra.data.legacy; const raw = typeof extra.data.legacy === 'string' ? JSON.parse(extra.data.legacy) : extra.data.legacy;
let pass = mergePassInfo(raw, extra.data.extra, cc.fx.GameConfig.PASS_CHECK); let pass = mergePassInfo(raw, extra.data.extra, cc.fx.GameConfig.PASS_CHECK);
@ -1604,6 +1608,11 @@ export default class Utils {
}); });
} }
static async completePassOrder(outTradeNo: string): Promise<any> { static async completePassOrder(outTradeNo: string): Promise<any> {
const check = BattlePassSettlementCheck.forPlayer(cc.fx.GameConfig.GM_INFO);
try { return await Utils.deliverPassOrder(outTradeNo); }
finally { check.invalidate(); }
}
private static async deliverPassOrder(outTradeNo: string): Promise<any> {
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
const order = await Utils.passRequest('order', { outTradeNo }); const order = await Utils.passRequest('order', { outTradeNo });
if (order.data.postSeason) { if (order.data.postSeason) {

View File

@ -1,13 +0,0 @@
{
"ver": "1.1.3",
"uuid": "fa6df961-bba5-49c6-b2a1-6228f3ca160e",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -1,13 +0,0 @@
{
"ver": "1.1.3",
"uuid": "3863fc2a-22f0-4ff5-bca0-ab32cd195ca1",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -1,13 +0,0 @@
{
"ver": "1.1.3",
"uuid": "c5c018c1-b95c-4bf8-8bcc-2f017b07719d",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -1,13 +0,0 @@
{
"ver": "1.1.3",
"uuid": "162b023e-0bbd-4aca-a325-55598baf604d",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -1,13 +0,0 @@
{
"ver": "1.1.3",
"uuid": "63fea74b-4a7d-432d-922a-1577658321ab",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -1,13 +0,0 @@
{
"ver": "1.1.3",
"uuid": "ca7daeb9-1930-47d6-989f-6dbdc501cb3a",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -1,13 +0,0 @@
{
"ver": "1.1.3",
"uuid": "1381ff15-3157-44af-a14e-daddf0b2eaf3",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -25,7 +25,15 @@ BattlePassSettlement:旧赛季结算队列、图1/2/3/4交互和上期18元购
BattlePassArt:图片文案、万圣节数字/南瓜/时钟、图6购买成功展示。 BattlePassArt:图片文案、万圣节数字/南瓜/时钟、图6购买成功展示。
BattlePassSettlementModel:使用当前最新 PASS_CHECK 表计算奖励并合并同类项。 BattlePassSettlementModel:使用当前最新 PASS_CHECK 表计算奖励并合并同类项。
HomePopupQueue:结算优先级-100,完整结算/奖励动画结束后才继续原有弹窗。 HomePopupQueue:结算优先级-100,完整结算/奖励动画结束后才继续原有弹窗。
触发时机与黄金矿工一致:首页 start 检查一次,cc.game.EVENT_SHOW 回前台重新检查,销毁首页时移除监听;不新增 onShow,不再由战令等级刷新重复触发。检查仍使用服务端结算状态,同一期已处理的弹窗不重复展示。 触发入口沿用黄金矿工:首页 start、cc.game.EVENT_SHOW;不新增 onShow。
每次登录首次进入首页读取结算;成功完成整条弹窗/奖励动画流程后缓存结果。
缓存仅保留在本次登录内,跨 HomeScene/GameScene 保留,账号/token变化或重新登录重置。
首页每秒仅检查本地时间和缓存,不轮询接口;周期依据 passCheck/read.time(idcount 周期起点)+30天,使用结算返回的 serverTime 校准本地时间。
到期时请求一次;在战斗/后台跨过到期点则回首页/前台补查。其他接口先推进到新周期也不会漏掉旧周期检查。
缺少周期信息时补读一次 passCheck/read,读取失败不写成功缓存。
待恢复战令订单、支付完成/失败使缓存失效;网络失败按5/10/20/40/60秒重试,最多间隔60秒,成功后停止。
410人工处理订单在本次登录内不反复重试,保留订单记录;重新登录可再次检查。
销毁首页移除监听和本地计时;未完成弹窗流程不缓存,结算优先级仍为-100。
登录支付恢复先补齐战令权益再读取快照,其他订单和奖励弹窗延后。 登录支付恢复先补齐战令权益再读取快照,其他订单和奖励弹窗延后。
规则 规则
@ -46,6 +54,7 @@ tools/import-battle-pass-art.cjs 可重建新增资源/meta及预制体图片引
主界面背景有hash保护,避免覆盖后续美术调整。 主界面背景有hash保护,避免覆盖后续美术调整。
测试命令(客户端目录) 测试命令(客户端目录)
node --test tools/test-battle-pass-settlement-check.cjs
node --test tools/test-battle-pass-settlement.cjs tools/test-battle-pass.cjs node --test tools/test-battle-pass-settlement.cjs tools/test-battle-pass.cjs
node --test tools/test-battle-pass-ui.cjs node --test tools/test-battle-pass-ui.cjs
新增模块使用 creator.d.ts 的定向TypeScript检查通过。 新增模块使用 creator.d.ts 的定向TypeScript检查通过。

View File

@ -0,0 +1,139 @@
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('node:fs'), vm = require('node:vm'), ts = require('typescript');
function load(file, deps = {}, globals = {}) {
const exports = {};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: 99, module: 1, experimentalDecorators: true }
}).outputText, { exports, require: name => deps[name], Date, console, ...globals });
return exports;
}
const model = load('assets/Script/module/Config/BattlePassModel.ts');
function fixture() {
let now = 1800000000000;
class Clock extends Date { static now() { return now; } }
const Check = load('assets/Script/module/Config/BattlePassSettlementCheck.ts', { './BattlePassModel': model }, { Date: Clock }).default;
const gm = { uid: 'u', token: 't', allOutTradeNo: [] };
const cache = Check.forPlayer(gm), end = now + 60000;
cache.observeStart(end - model.PASS_PERIOD);
return { Check, cache, gm, end, Clock, now: () => now, advance: value => now += value };
}
test('login catch-up is mandatory even with 30 days remaining; same-period result is cached', () => {
const f = fixture();
assert.equal(f.cache.needsCheck(), true);
f.cache.complete(f.now(), f.cache.version);
for (let i = 0; i < 100; i++) assert.equal(f.Check.forPlayer(f.gm).needsCheck(), false);
f.advance(59999); assert.equal(f.cache.needsCheck(), false);
f.advance(1); assert.equal(f.cache.needsCheck(), true);
f.cache.complete(f.now(), f.cache.version);
assert.equal(f.cache.needsCheck(), false);
});
test('new-season reads before returning home cannot hide a missed cutoff', () => {
const f = fixture(); f.cache.complete(f.now(), 0);
f.advance(60001 + model.PASS_PERIOD * 2);
f.cache.observeStart(f.end + model.PASS_PERIOD * 2);
assert.equal(f.cache.needsCheck(), true);
f.cache.complete(f.now(), 0);
assert.equal(f.cache.needsCheck(), false);
});
test('deadline uses server-clock offset, not an inaccurate device clock', () => {
const f = fixture(), serverNow = f.now();
f.advance(3600000); f.cache.syncClock(serverNow); f.cache.complete(serverNow, 0);
assert.equal(f.cache.needsCheck(), false);
f.advance(60000); assert.equal(f.cache.needsCheck(), true);
});
test('account/token changes and a new login with the same token reset the cache', () => {
const f = fixture(); f.cache.complete(f.now(), 0);
f.gm.token = 'new'; assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true);
f.Check.forPlayer(f.gm).complete(f.now(), 0);
f.gm.uid = 'other'; assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true);
f.Check.forPlayer(f.gm).complete(f.now(), 0);
f.Check.resetLogin(); assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true);
f.cache.complete(f.now(), 0); assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true, 'old async completion cannot cache a new login');
});
test('payment invalidation during a read survives its later completion; pending orders bypass cache', () => {
const f = fixture(); f.cache.invalidate(); f.cache.complete(f.now(), 0);
assert.equal(f.cache.needsCheck(), true);
f.cache.complete(f.now(), 1);
assert.equal(f.cache.needsCheck([{ itemid: 'other', outTradeNo: 'a' }]), false);
const order = { itemid: 'battlepass_12', outTradeNo: 'b' };
assert.equal(f.cache.needsCheck([order]), true);
f.cache.reviewOrders.add('b'); assert.equal(f.cache.needsCheck([order]), false);
});
function runFixture() {
const f = fixture(), calls = [], retries = [];
let error, paidError, readResponse, pendingRead;
const cc = { _decorator: { ccclass: T => T }, Component: class {
scheduleOnce(fn, delay) { retries.push({ fn, delay }); }
destroy() { this.destroyed = true; }
}, isValid: value => !!value && !value.destroyed, log() {}, warn() {},
systemEvent: { off() {} }, SystemEvent: { EventType: { KEY_DOWN: 'key' } },
fx: { GameConfig: { GM_INFO: f.gm } } };
const utils = {
POSTJSON(url, body, callback) { calls.push(url); callback({ code: 1, data: { time: f.end - model.PASS_PERIOD } }); },
async completePassOrder() { calls.push('payment'); if (paidError) throw paidError; f.cache.invalidate(); return { data: { alreadyGranted: true } }; },
async settlementRequest() { calls.push('read'); if (error) throw error; if (pendingRead) return pendingRead; return readResponse || { data: { serverTime: f.now(), rows: [] } }; }
};
const C = load('assets/Script/BattlePassSettlement.ts', {
'./module/Pay/Utils': { default: utils },
'./module/Config/BattlePassSettlementCheck': { default: f.Check },
'./module/Config/BattlePassSettlementModel': { settlementSteps: rows => rows },
'./BattlePassArt': { default: { load: async () => { throw Error('art failed'); } } }
}, { cc, Date: f.Clock }).default;
const view = new C(); view.node = {}; let done = 0;
return { ...f, view, calls, retries, utils, run: () => view.run(() => done++), done: () => done,
fail: value => error = value, paidFail: value => paidError = value,
rows: rows => readResponse = { data: { serverTime: f.now(), rows } },
defer: () => new Promise(resolve => { pendingRead = new Promise(r => resolve(r)); }) };
}
test('successful empty read caches, and an unavailable period is fetched before caching', async () => {
const f = runFixture(); f.Check.resetLogin(); await f.run();
assert.deepEqual(f.calls, ['passCheck', 'read']); assert.equal(f.done(), 1);
assert.equal(f.Check.forPlayer(f.gm).needsCheck(), false);
});
test('network failure is not cached; retry backoff caps at 60 seconds and stops after success', async () => {
const f = runFixture(); f.fail({ code: 0 }); await f.run();
for (let i = 0; i < 5; i++) await f.retries[i].fn();
assert.deepEqual(f.retries.map(r => r.delay), [5, 10, 20, 40, 60, 60]);
assert.equal(f.done(), 0); assert.equal(f.cache.needsCheck(), true);
f.fail(null); await f.retries[5].fn();
assert.equal(f.done(), 1); assert.equal(f.cache.needsCheck(), false); assert.equal(f.retries.length, 6);
});
test('transient paid-order failure retries; 410 is reviewed once per login without deleting the order', async () => {
const f = runFixture(); f.gm.allOutTradeNo = [{ itemid: 'battlepass', outTradeNo: 'paid' }];
f.paidFail({ code: 500 }); await f.run(); assert.deepEqual(f.calls, ['payment']);
assert.equal(f.cache.needsCheck(), true);
f.paidFail({ code: 410 }); await f.retries[0].fn();
assert.equal(f.cache.needsCheck(f.gm.allOutTradeNo), false); assert.equal(f.gm.allOutTradeNo.length, 1);
f.view.destroyed = false; await f.run();
assert.equal(f.calls.filter(v => v === 'payment').length, 2);
});
test('late read after scene destruction and failed popup construction never cache completion', async () => {
const f = runFixture(), resolve = await f.defer(), run = f.run();
f.view.node.destroyed = true; resolve({ data: { serverTime: f.now(), rows: [] } }); await run;
assert.equal(f.done(), 0); assert.equal(f.cache.needsCheck(), true);
const g = runFixture(); g.rows([{ end: g.end - model.PASS_PERIOD }]); await g.run();
assert.equal(g.done(), 0); assert.equal(g.cache.needsCheck(), true); assert.equal(g.retries.length, 1);
});
test('a read crossing the deadline triggers one follow-up instead of caching the pre-expiry snapshot', async () => {
const f = runFixture(), resolve = await f.defer(), run = f.run();
f.advance(60001); resolve({ data: { serverTime: f.now(), rows: [] } }); await run;
assert.equal(f.cache.needsCheck(), true);
});
test('a relogin discards the previous session response without caching or releasing its UI', async () => {
const f = runFixture(), resolve = await f.defer(), run = f.run();
f.Check.resetLogin(); resolve({ data: { serverTime: f.now(), rows: [] } }); await run;
assert.equal(f.done(), 0); assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true);
});
test('payment wrapper invalidates on both success and failure', async () => {
const f = fixture(), source = ts.createSourceFile('Utils.ts', fs.readFileSync('assets/Script/module/Pay/Utils.ts', 'utf8'), 99, true);
const method = source.statements.find(ts.isClassDeclaration).members.find(m => m.name?.getText(source) === 'completePassOrder').getText(source);
const context = { cc: { fx: { GameConfig: { GM_INFO: f.gm } } }, BattlePassSettlementCheck: f.Check };
vm.runInNewContext(ts.transpileModule('class Utils {' + method + '} globalThis.Utils=Utils;', { compilerOptions: { target: 99 } }).outputText, context);
for (const failed of [false, true]) {
f.cache.complete(f.now(), f.cache.version);
context.Utils.deliverPassOrder = async () => { if (failed) throw Error('lost response'); return 'ok'; };
if (failed) await assert.rejects(context.Utils.completePassOrder('paid')); else await context.Utils.completePassOrder('paid');
assert.equal(f.cache.needsCheck(), true);
}
});

View File

@ -138,6 +138,7 @@ function prefabTree(file) {
} }
function homeSettlementFixture() { function homeSettlementFixture() {
const Check=load('assets/Script/module/Config/BattlePassSettlementCheck.ts',{'./BattlePassModel':model}).default;
const source=ts.createSourceFile('JiaZai.ts',fs.readFileSync('assets/Script/JiaZai.ts','utf8'),99,true); const source=ts.createSourceFile('JiaZai.ts',fs.readFileSync('assets/Script/JiaZai.ts','utf8'),99,true);
const members=source.statements.find(ts.isClassDeclaration).members; const members=source.statements.find(ts.isClassDeclaration).members;
const method=name=>members.find(n=>n.name?.getText(source)===name); const method=name=>members.find(n=>n.name?.getText(source)===name);
@ -147,14 +148,15 @@ function homeSettlementFixture() {
game:{EVENT_SHOW:'show',on(event,fn,owner){listeners.push({event,fn,owner});}, game:{EVENT_SHOW:'show',on(event,fn,owner){listeners.push({event,fn,owner});},
off(event,fn,owner){const i=listeners.findIndex(l=>l.event===event&&l.fn===fn&&l.owner===owner);if(i>=0)listeners.splice(i,1);}}}; off(event,fn,owner){const i=listeners.findIndex(l=>l.event===event&&l.fn===fn&&l.owner===owner);if(i>=0)listeners.splice(i,1);}}};
const Queue=load('assets/home_popup_queue/HomePopupQueue.ts',{}, {cc}).default; const Queue=load('assets/home_popup_queue/HomePopupQueue.ts',{}, {cc}).default;
class Settlement { run(done){runs.push({view:this,done});} } class Settlement { run(done){const cache=Check.forPlayer(cc.fx.GameConfig.GM_INFO);const version=cache.version;runs.push({view:this,done:()=>{cache.complete(Date.now(),version);done();}});} }
// Exercise the actual lifecycle statements, without unrelated homepage UI setup. // Exercise the actual lifecycle statements, without unrelated homepage UI setup.
const lifecycle=name=>name+'(){'+method(name).body.statements.filter(s=>s.getText(source).includes('this.checkBattlePassSettlement')).map(s=>s.getText(source)).join('\n')+'}'; const lifecycle=name=>name+'(){'+method(name).body.statements.filter(s=>s.getText(source).includes('this.checkBattlePassSettlement')).map(s=>s.getText(source)).join('\n')+'}';
const exports={};vm.runInNewContext(ts.transpileModule('export class Home {'+method('checkBattlePassSettlement').getText(source)+method('checkAndSetPlayerPassLevel').getText(source)+lifecycle('start')+lifecycle('onDestroy')+'}',{compilerOptions:{target:99,module:1}}).outputText, const exports={};vm.runInNewContext(ts.transpileModule('export class Home {'+method('checkBattlePassSettlement').getText(source)+method('checkAndSetPlayerPassLevel').getText(source)+lifecycle('start')+lifecycle('onDestroy')+'}',{compilerOptions:{target:99,module:1}}).outputText,
{exports,cc,HomePopupQueue:Queue,BattlePassSettlement:Settlement,canClaimPass:model.canClaimPass, {exports,cc,HomePopupQueue:Queue,BattlePassSettlement:Settlement,BattlePassSettlementCheck:Check,canClaimPass:model.canClaimPass,
Utils:{getPassCheckInfo:callback=>passReads.push(callback)}}); Utils:{getPassCheckInfo:callback=>passReads.push(callback)}});
const create=()=>{ const create=()=>{
const home=new exports.Home(),components=[]; const home=new exports.Home(),components=[];
home.schedule=(fn,seconds)=>{assert.equal(seconds,1);home.tick=()=>fn.call(home);};home.unschedule=()=>{home.tick=null;};
home.isHomeRuntimeAlive=()=>!home.destroyed; home.isHomeRuntimeAlive=()=>!home.destroyed;
const red={active:true},button={active:false,getChildByName:name=>name==='red'?red:null}; const red={active:true},button={active:false,getChildByName:name=>name==='red'?red:null};
const top={getChildByName:name=>name==='passBtn'?button:null},load={getChildByName:name=>name==='Top'?top:null}; const top={getChildByName:name=>name==='passBtn'?button:null},load={getChildByName:name=>name==='Top'?top:null};
@ -162,7 +164,7 @@ function homeSettlementFixture() {
home.start();const queue=Queue.get(home.node);queue.hasVisiblePopup=()=>false; home.start();const queue=Queue.get(home.node);queue.hasVisiblePopup=()=>false;
return {home,queue,button,red}; return {home,queue,button,red};
}; };
return {create,runs,listeners,passReads,cc,source,method,show:()=>listeners.slice().forEach(l=>l.fn.call(l.owner))}; return {create,runs,listeners,passReads,cc,source,method,Check,show:()=>listeners.slice().forEach(l=>l.fn.call(l.owner))};
} }
test('finishing old-season settlement refreshes the hidden home entry and current-season progress without reentering',()=>{ test('finishing old-season settlement refreshes the hidden home entry and current-season progress without reentering',()=>{
@ -198,18 +200,20 @@ test('battle-pass checks use the same start and foreground hooks as gold miner,
assert.equal(f.method('onShow'),undefined); assert.equal(f.method('onShow'),undefined);
for(const name of ['onLoad','checkAndSetPlayerPassLevel'])assert.doesNotMatch(f.method(name).getText(f.source),/battlePassSettlement|checkBattlePassSettlement/); for(const name of ['onLoad','checkAndSetPlayerPassLevel'])assert.doesNotMatch(f.method(name).getText(f.source),/battlePassSettlement|checkBattlePassSettlement/);
for(let visit=0;visit<3;visit++) { for(let visit=0;visit<3;visit++) {
const {home,queue}=f.create();queue.update();assert.equal(f.runs.length,visit+1); const {home,queue}=f.create();queue.update();assert.equal(f.runs.length,1);
f.runs.at(-1).done();home.onDestroy();home.destroyed=true; if(visit===0)f.runs.at(-1).done();home.onDestroy();home.destroyed=true;
assert.equal(f.listeners.length,0); assert.equal(f.listeners.length,0);
} }
f.show();assert.equal(f.runs.length,3); f.show();assert.equal(f.runs.length,1);
}); });
test('foreground checks deduplicate queued/running settlement and recheck after completion',()=>{ test('foreground checks deduplicate queued/running settlement and bypass completed cache only when invalidated',()=>{
const f=homeSettlementFixture(),{queue}=f.create(); const f=homeSettlementFixture(),{queue}=f.create();
f.show();f.show();queue.update();assert.equal(f.runs.length,1); f.show();f.show();queue.update();assert.equal(f.runs.length,1);
f.show();queue.update();assert.equal(f.runs.length,1); f.show();queue.update();assert.equal(f.runs.length,1);
// Cocos destruction is deferred; a completed component must not be reused. // Cocos destruction is deferred; a completed component must not be reused.
f.runs[0].view.destroyed=true;f.runs[0].done(); f.runs[0].view.destroyed=true;f.runs[0].done();
f.show();queue.update();assert.equal(f.runs.length,1);
f.Check.forPlayer(f.cc.fx.GameConfig.GM_INFO).invalidate();
f.show();queue.update();assert.equal(f.runs.length,2); f.show();queue.update();assert.equal(f.runs.length,2);
assert.notEqual(f.runs[0].view,f.runs[1].view); assert.notEqual(f.runs[0].view,f.runs[1].view);
}); });
@ -222,6 +226,15 @@ test('home settlement check skips inactive/leaving scenes, including queued task
f.cc.fx.GameConfig.isGameplayEntryPreparing=()=>false; f.cc.fx.GameConfig.isGameplayEntryPreparing=()=>false;
f.show();home.destroyed=true;queue.update();assert.equal(f.runs.length,0); f.show();home.destroyed=true;queue.update();assert.equal(f.runs.length,0);
}); });
test('home local timer triggers the cutoff once and stops on scene destruction',()=>{
const f=homeSettlementFixture(),{home,queue}=f.create();queue.update();f.runs[0].done();
for(let i=0;i<60;i++){home.tick();queue.update();}assert.equal(f.runs.length,1);
const cache=f.Check.forPlayer(f.cc.fx.GameConfig.GM_INFO);
cache.observeStart(Date.now()-model.PASS_PERIOD-1000);cache.complete(Date.now()-2000,cache.version);
home.tick();f.show();queue.update();assert.equal(f.runs.length,2);
f.runs[1].done();home.tick();queue.update();assert.equal(f.runs.length,2);
home.onDestroy();assert.equal(home.tick,null);assert.equal(f.listeners.length,0);
});
const find=(path,root)=>path.split('/').reduce((n,p)=>n.getChildByName(p),root); const find=(path,root)=>path.split('/').reduce((n,p)=>n.getChildByName(p),root);
test('figures 1/2/3 match figure 5 title size and the offer uses the glossy green button',()=>{ test('figures 1/2/3 match figure 5 title size and the offer uses the glossy green button',()=>{
const panel=prefabTree('assets/passCheck/prefab/PassSettlement.prefab').getChildByName('panel'); const panel=prefabTree('assets/passCheck/prefab/PassSettlement.prefab').getChildByName('panel');

View File

@ -47,9 +47,10 @@ function fixture(owned = 18) {
const old = body => legacy({ body: { uid: 'u', token: 't', ...body } }); const old = body => legacy({ body: { uid: 'u', token: 't', ...body } });
const cc = { fx: { GameConfig: { PASS_CHECK: rows, GM_INFO: { uid: 'u', coin: 100 } }, const cc = { fx: { GameConfig: { PASS_CHECK: rows, GM_INFO: { uid: 'u', coin: 100 } },
StorageMessage: { setStorage() {}, getStorage() {} } } }; StorageMessage: { setStorage() {}, getStorage() {} } } };
const context = { cc, ...model, Date, console, setTimeout, clearTimeout }; const BattlePassSettlementCheck = load('assets/Script/module/Config/BattlePassSettlementCheck.ts', { './BattlePassModel': model }).default;
const context = { cc, ...model, BattlePassSettlementCheck, Date, console, setTimeout, clearTimeout };
const source = ts.createSourceFile('Utils.ts', fs.readFileSync('assets/Script/module/Pay/Utils.ts', 'utf8'), 99, true); const source = ts.createSourceFile('Utils.ts', fs.readFileSync('assets/Script/module/Pay/Utils.ts', 'utf8'), 99, true);
const names = ['passGiftPending', 'applyPassGift', 'getPassCheckInfo', 'setPassCheckInfo', 'passRequest', 'passUpload', 'addPassProgress', 'completePassOrder']; const names = ['passGiftPending', 'applyPassGift', 'getPassCheckInfo', 'setPassCheckInfo', 'passRequest', 'passUpload', 'addPassProgress', 'completePassOrder', 'deliverPassOrder'];
const methods = source.statements.find(ts.isClassDeclaration).members.filter(n => names.includes(n.name?.getText(source))).map(n => n.getText(source)).join('\n'); const methods = source.statements.find(ts.isClassDeclaration).members.filter(n => names.includes(n.name?.getText(source))).map(n => n.getText(source)).join('\n');
vm.runInNewContext(ts.transpileModule('class Utils {' + methods + '} globalThis.Utils=Utils;', { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText, context); vm.runInNewContext(ts.transpileModule('class Utils {' + methods + '} globalThis.Utils=Utils;', { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText, context);
context.Utils.POST = (url, body, callback) => (url === 'passCheck' ? old(body) : call(body)).then(callback); context.Utils.POST = (url, body, callback) => (url === 'passCheck' ? old(body) : call(body)).then(callback);