diff --git a/assets/GameplayIntro/compressed.meta b/assets/GameplayIntro/compressed.meta deleted file mode 100644 index aa05cdb..0000000 --- a/assets/GameplayIntro/compressed.meta +++ /dev/null @@ -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": {} -} \ No newline at end of file diff --git a/assets/Script/BattlePassSettlement.ts b/assets/Script/BattlePassSettlement.ts index f359680..8866e6b 100644 --- a/assets/Script/BattlePassSettlement.ts +++ b/assets/Script/BattlePassSettlement.ts @@ -3,6 +3,7 @@ import Art from './BattlePassArt'; import NumberToImage from './NumberToImage'; import { settlementRewards, settlementSteps } from './module/Config/BattlePassSettlementModel'; import { MiniGameSdk } from './Sdk/MiniGameSdk'; +import BattlePassSettlementCheck from './module/Config/BattlePassSettlementCheck'; const { ccclass } = cc._decorator; @ccclass @@ -20,6 +21,10 @@ export default class BattlePassSettlement extends cc.Component { private finished: () => void; private iosOrder = ''; private recoveredRewards: any[] = []; + private check: BattlePassSettlementCheck; + private readVersion = 0; + private checkedThrough = 0; + private retryDelay = 5; private back = (e: cc.Event.EventKeyboard) => { 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 // orders remain in the home queue until this entire sequence is closed. 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()) { if (!/^battlepass(?:_30|_12)?$/.test(order.itemid)) continue; + if (this.check.reviewOrders.has(order.outTradeNo)) continue; try { const paid = await Utils.completePassOrder(order.outTradeNo); + if (!this.isCurrentRun()) return; if (!paid.data.alreadyGranted) { 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 }); } gm.allOutTradeNo = gm.allOutTradeNo.filter(o => o.outTradeNo !== order.outTradeNo); } catch (e) { - // if (e.code !== 410) throw e; + if (e.code !== 410) throw e; // Paid duplicates/obsolete orders remain available for manual review. + this.check.reviewOrders.add(order.outTradeNo); cc.warn('战令订单需要人工处理', order.outTradeNo, e.msg); } } + this.readVersion = this.check.version; + const readStarted = Date.now(); const result = await Utils.settlementRequest('read'); - if (!cc.isValid(this.node)) return; + if (!this.isCurrentRun()) return; 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.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(); - 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.panel = this.root.getChildByName('panel'); 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); this.next(); } catch (e) { + if (!this.isCurrentRun()) return; this.error(e); if (this.root && cc.isValid(this.root)) this.root.destroy(); cc.systemEvent.off(cc.SystemEvent.EventType.KEY_DOWN, this.back, this); // @ts-ignore if (typeof wx !== 'undefined') wx.offShow(this.resume); // 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) { cc.log(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()); return; } - this.root.destroy(); this.finished(); this.destroy(); return; + this.finish(); return; } if (!this.row.claimed && !this.row.emptySeen) { const rewards = settlementRewards(this.row, cc.fx.GameConfig.PASS_CHECK); diff --git a/assets/Script/GameManager.ts b/assets/Script/GameManager.ts index 0a38a8f..2c1fe38 100644 --- a/assets/Script/GameManager.ts +++ b/assets/Script/GameManager.ts @@ -6,6 +6,7 @@ // - https://docs.cocos.com/creator/manual/en/scripting/life-cycle-callbacks.html import Utils from "./module/Pay/Utils"; +import BattlePassSettlementCheck from './module/Config/BattlePassSettlementCheck'; import CloudRiseService from "./module/Config/CloudRiseService"; import ActivityScheduleService from "./module/Config/ActivityScheduleService"; import GoldMinerService from './coin_madness/GoldMinerService'; @@ -339,6 +340,7 @@ export default class GameManager extends cc.Component { if (data.data.token) { cc.fx.GameConfig.GM_INFO.token = data.data.token; } + BattlePassSettlementCheck.resetLogin(); 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_preview = data.data.wucai_preview || null; diff --git a/assets/Script/JiaZai.ts b/assets/Script/JiaZai.ts index 132d5c2..eacd460 100644 --- a/assets/Script/JiaZai.ts +++ b/assets/Script/JiaZai.ts @@ -12,6 +12,7 @@ import MapConroler from "./Map"; import { DRAWABLE_CAT_IDS, getOwnedDrawableCats, getCatDrawCost } from "./module/Tool/CatDrawRules"; import HomePopupQueue from "../home_popup_queue/HomePopupQueue"; import BattlePassSettlement from './BattlePassSettlement'; +import BattlePassSettlementCheck from './module/Config/BattlePassSettlementCheck'; import CloudRiseView from "./CloudRiseView"; import HomeActivitySchedule from "./HomeActivitySchedule"; import CoinMadnessHost from './coin_madness/CoinMadnessHost'; @@ -871,6 +872,7 @@ export default class JiaZai extends cc.Component { onDestroy() { cc.game.off(cc.game.EVENT_SHOW, this.checkBattlePassSettlement, this); + this.unschedule(this.checkBattlePassSettlement); MiniProgramBenefitsBridge.instance.detach(this.node); this.homeAutomaticTasks = []; this.homePopupOpenRequest++; @@ -986,8 +988,9 @@ export default class JiaZai extends cc.Component { // 9. 检查月卡过期状态 // ============================================ start() { - // 与黄金矿工一致:每次进入首页及游戏回到前台时重新检查。 + // 沿用首页/回前台入口;每秒只检查本地截止时间,不轮询接口。 this.checkBattlePassSettlement(); + this.schedule(this.checkBattlePassSettlement, 1); cc.game.on(cc.game.EVENT_SHOW, this.checkBattlePassSettlement, this); if (!this.node.getComponent(HomeActivitySchedule)) this.node.addComponent(HomeActivitySchedule); CloudRiseView.mount(this.node, () => this.startGame()); @@ -4806,8 +4809,12 @@ export default class JiaZai extends cc.Component { /** 与黄金矿工使用相同显示时机,仍优先处理旧赛季结算。 */ private checkBattlePassSettlement(): void { 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 => { 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); (cc.isValid(settlement, true) ? settlement : this.node.addComponent(BattlePassSettlement)).run(() => { if (this.isHomeRuntimeAlive() && this.node.activeInHierarchy && !cc.fx.GameConfig.isGameplayEntryPreparing()) { diff --git a/assets/Script/module/Config/BattlePassSettlementCheck.ts b/assets/Script/module/Config/BattlePassSettlementCheck.ts new file mode 100644 index 0000000..5b9137c --- /dev/null +++ b/assets/Script/module/Config/BattlePassSettlementCheck.ts @@ -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(); + + 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; + } +} diff --git a/assets/Script/module/Config/BattlePassSettlementCheck.ts.meta b/assets/Script/module/Config/BattlePassSettlementCheck.ts.meta new file mode 100644 index 0000000..c952964 --- /dev/null +++ b/assets/Script/module/Config/BattlePassSettlementCheck.ts.meta @@ -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": {} +} \ No newline at end of file diff --git a/assets/Script/module/Pay/Utils.ts b/assets/Script/module/Pay/Utils.ts index 882545e..c954215 100644 --- a/assets/Script/module/Pay/Utils.ts +++ b/assets/Script/module/Pay/Utils.ts @@ -1,6 +1,7 @@ import { MiniGameSdk } from "../../Sdk/MiniGameSdk"; import { normalizePass, mergePassInfo, legacyPassInfo, legacyExperience, passTier, PASS_PERIOD } from '../Config/BattlePassModel'; import { settlementRewards, settlementClaimFlags } from '../Config/BattlePassSettlementModel'; +import BattlePassSettlementCheck from '../Config/BattlePassSettlementCheck'; //#region 测试 /** 测试 */ export default class Utils { @@ -1514,8 +1515,11 @@ export default class Utils { if (callBack) callBack({ code: 0, msg: '战令配置尚未加载,请稍后重试' }); 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 => { 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 => { 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); @@ -1604,6 +1608,11 @@ export default class Utils { }); } static async completePassOrder(outTradeNo: string): Promise { + 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 { for (let attempt = 0; attempt < 3; attempt++) { const order = await Utils.passRequest('order', { outTradeNo }); if (order.data.postSeason) { diff --git a/assets/gacha_bundle/compressed.meta b/assets/gacha_bundle/compressed.meta deleted file mode 100644 index f5fe91a..0000000 --- a/assets/gacha_bundle/compressed.meta +++ /dev/null @@ -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": {} -} \ No newline at end of file diff --git a/assets/gacha_bundle/img/cat.meta b/assets/gacha_bundle/img/cat.meta deleted file mode 100644 index 862e6fa..0000000 --- a/assets/gacha_bundle/img/cat.meta +++ /dev/null @@ -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": {} -} \ No newline at end of file diff --git a/assets/libs/dn-sdk-minigame.meta b/assets/libs/dn-sdk-minigame.meta deleted file mode 100644 index b1484e6..0000000 --- a/assets/libs/dn-sdk-minigame.meta +++ /dev/null @@ -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": {} -} \ No newline at end of file diff --git a/assets/passCheck/script.meta b/assets/passCheck/script.meta deleted file mode 100644 index 263dad4..0000000 --- a/assets/passCheck/script.meta +++ /dev/null @@ -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": {} -} \ No newline at end of file diff --git a/assets/propWindow/compressed.meta b/assets/propWindow/compressed.meta deleted file mode 100644 index 6d25665..0000000 --- a/assets/propWindow/compressed.meta +++ /dev/null @@ -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": {} -} \ No newline at end of file diff --git a/assets/res/shop.meta b/assets/res/shop.meta deleted file mode 100644 index 9b05c9e..0000000 --- a/assets/res/shop.meta +++ /dev/null @@ -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": {} -} \ No newline at end of file diff --git a/assets/win/compressed.meta b/assets/win/compressed.meta deleted file mode 100644 index bb8b7c7..0000000 --- a/assets/win/compressed.meta +++ /dev/null @@ -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": {} -} \ No newline at end of file diff --git a/docs/BattlePassSettlement.txt b/docs/BattlePassSettlement.txt index 43f97cc..67eb6ba 100644 --- a/docs/BattlePassSettlement.txt +++ b/docs/BattlePassSettlement.txt @@ -25,7 +25,15 @@ BattlePassSettlement:旧赛季结算队列、图1/2/3/4交互和上期18元购 BattlePassArt:图片文案、万圣节数字/南瓜/时钟、图6购买成功展示。 BattlePassSettlementModel:使用当前最新 PASS_CHECK 表计算奖励并合并同类项。 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保护,避免覆盖后续美术调整。 测试命令(客户端目录) +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-ui.cjs 新增模块使用 creator.d.ts 的定向TypeScript检查通过。 diff --git a/tools/test-battle-pass-settlement-check.cjs b/tools/test-battle-pass-settlement-check.cjs new file mode 100644 index 0000000..a854070 --- /dev/null +++ b/tools/test-battle-pass-settlement-check.cjs @@ -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); + } +}); diff --git a/tools/test-battle-pass-settlement.cjs b/tools/test-battle-pass-settlement.cjs index 0b1158b..c067e7c 100644 --- a/tools/test-battle-pass-settlement.cjs +++ b/tools/test-battle-pass-settlement.cjs @@ -138,6 +138,7 @@ function prefabTree(file) { } 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 members=source.statements.find(ts.isClassDeclaration).members; 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});}, 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; - 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. 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, - {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)}}); const create=()=>{ 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; 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}; @@ -162,7 +164,7 @@ function homeSettlementFixture() { home.start();const queue=Queue.get(home.node);queue.hasVisiblePopup=()=>false; 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',()=>{ @@ -198,18 +200,20 @@ test('battle-pass checks use the same start and foreground hooks as gold miner, assert.equal(f.method('onShow'),undefined); for(const name of ['onLoad','checkAndSetPlayerPassLevel'])assert.doesNotMatch(f.method(name).getText(f.source),/battlePassSettlement|checkBattlePassSettlement/); for(let visit=0;visit<3;visit++) { - const {home,queue}=f.create();queue.update();assert.equal(f.runs.length,visit+1); - f.runs.at(-1).done();home.onDestroy();home.destroyed=true; + const {home,queue}=f.create();queue.update();assert.equal(f.runs.length,1); + if(visit===0)f.runs.at(-1).done();home.onDestroy();home.destroyed=true; 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(); f.show();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. 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); 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.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); 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'); diff --git a/tools/test-battle-pass.cjs b/tools/test-battle-pass.cjs index 78c08bb..5f51b9c 100644 --- a/tools/test-battle-pass.cjs +++ b/tools/test-battle-pass.cjs @@ -47,9 +47,10 @@ function fixture(owned = 18) { const old = body => legacy({ body: { uid: 'u', token: 't', ...body } }); const cc = { fx: { GameConfig: { PASS_CHECK: rows, GM_INFO: { uid: 'u', coin: 100 } }, 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 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'); 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);