diff --git a/assets/home_popup_queue/HomePopupQueue.ts b/assets/home_popup_queue/HomePopupQueue.ts index 751a59ae..fcd0be68 100644 --- a/assets/home_popup_queue/HomePopupQueue.ts +++ b/assets/home_popup_queue/HomePopupQueue.ts @@ -54,8 +54,8 @@ export default class HomePopupQueue extends cc.Component { if (this.policy.record.monthlyRewardPending) this.policy.request('monthlyReward'); HomePopupQueue.loginPending = false; } - completePreparation(source: string): void { this.sources.delete(source); } - holdPreparation(source: string): void { if (this.collecting) this.sources.add(source); } + completePreparation(source: string): void { if (this.sources) this.sources.delete(source); } + holdPreparation(source: string): void { if (this.collecting && this.sources) this.sources.add(source); } start(): void { if (!this.node.getChildByName('sevenDayGift')) this.completePreparation('seven'); } @@ -66,26 +66,28 @@ export default class HomePopupQueue extends cc.Component { && this.policy.eligible(key, typeof type === 'function' ? type() : type); } unavailable(key: string): void { - if (this.policy.ended || this.recordKey !== HomePopupQueue.storageKey() + if (!this.policy || this.policy.ended || this.recordKey !== HomePopupQueue.storageKey() || this.accountToken !== cc.fx.GameConfig.GM_INFO.token || this.session !== HomePopupQueue.sessions[this.recordKey]) return; this.policy.unavailable(key); } requestPresentation(key: string): boolean { - if (this.policy.ended || this.recordKey !== HomePopupQueue.storageKey() + if (!this.policy || this.policy.ended || this.recordKey !== HomePopupQueue.storageKey() || this.accountToken !== cc.fx.GameConfig.GM_INFO.token || this.session !== HomePopupQueue.sessions[this.recordKey]) return false; return this.policy.request(key); } - wasShownToday(key: string): boolean { return !!this.policy.record.shown[key]; } + wasShownToday(key: string): boolean { return !!this.policy && !!this.policy.record.shown[key]; } markShown(key: string): void { if (this.isRunning(key)) this.policy.shown(key); } isRunning(key: string): boolean { - return !this.policy.ended && this.running === key && !cc.fx.GameConfig.isGameplayEntryPreparing() + // Late network/load callbacks may outlive Cocos clearing the component's object fields. + return !!this.policy && !this.policy.ended && this.running === key && !cc.fx.GameConfig.isGameplayEntryPreparing() && this.recordKey === HomePopupQueue.storageKey() && this.accountToken === cc.fx.GameConfig.GM_INFO.token && this.session === HomePopupQueue.sessions[this.recordKey]; } /** A manual popup may have opened while this automatic task was loading. */ canPresent(key: string): boolean { return this.isRunning(key) && !this.hasVisiblePopup(key); } - get monthlyRewardPending(): boolean { return !!this.policy.record.monthlyRewardPending; } + get monthlyRewardPending(): boolean { return !!this.policy && !!this.policy.record.monthlyRewardPending; } setMonthlyRewardPending(): void { + if (!this.policy) return; this.policy.record.monthlyRewardPending = true; const current: PopupRecord = cc.fx.StorageMessage.getStorage(this.recordKey) || this.policy.record; current.monthlyRewardPending = true; @@ -93,14 +95,17 @@ export default class HomePopupQueue extends cc.Component { this.requestPresentation('monthlyReward'); } cancelBatch(): void { - this.policy.end(); this.collecting = false; this.tasks.length = 0; this.running = ''; + if (this.policy) this.policy.end(); + this.collecting = false; + if (this.tasks) this.tasks.length = 0; + this.running = ''; const cancel = this.cancelRun; this.cancelRun = null; try { if (cancel) cancel(); } finally { if (this.backdrop && cc.isValid(this.backdrop, true)) this.backdrop.release(this); } } public has(key: string): boolean { - return this.running === key || this.tasks.some(task => task.key === key); + return this.running === key || !!this.tasks && this.tasks.some(task => task.key === key); } public static get(canvas: cc.Node): HomePopupQueue { @@ -108,9 +113,10 @@ export default class HomePopupQueue extends cc.Component { } public enqueue(key: string, priority: number, run: PopupRun, type?: PopupType): boolean { + if (!this.policy || this.policy.ended) return false; const managed = !!HOME_POPUP_RULES[key]; if (managed && !this.canCollect(key, type)) return false; - if (this.policy.ended || this.running === key || this.tasks.some(task => task.key === key)) return false; + if (this.running === key || this.tasks.some(task => task.key === key)) return false; // Keep accepted daily/login candidates pending until actual visibility, including load failures. this.policy.request(key, typeof type === 'function' ? type() : type); priority = key === 'ordersAfterPassSettlement' ? 33.25 diff --git a/tools/test-home-popup-policy.cjs b/tools/test-home-popup-policy.cjs index cc11e5ce..9a125a61 100644 --- a/tools/test-home-popup-policy.cjs +++ b/tools/test-home-popup-policy.cjs @@ -236,6 +236,49 @@ test('relogin invalidates old queue callbacks even when account and token are un assert.equal(current.policy.hasPending('starterPack'), true, 'old callbacks cannot discard a current pending request'); }); +test('late task callbacks are harmless after destroyed component fields are cleared', () => { + const h = harness(), old = h.create(); + let finish, continueBackdrop, cancelled = 0; + old.enqueue('starterPack', 0, (done, keepBackdrop) => { + finish = done; continueBackdrop = keepBackdrop; + return () => { cancelled++; done(); }; + }); + h.ready(old); old.update(); old.onDestroy(); + assert.equal(cancelled, 1); + // Cocos clears object references after onDestroy; requests still retain their callbacks. + for (const key of ['policy', 'sources', 'tasks', 'session', 'backdrop', 'node']) old[key] = null; + const current = h.create(false), saved = structuredClone([...h.storage]); + finish(); continueBackdrop(); old.markShown('starterPack'); old.unavailable('starterPack'); + old.completePreparation('starter'); old.holdPreparation('starter'); + old.setMonthlyRewardPending(); old.cancelBatch(); old.update(); + assert.equal(old.requestPresentation('starterPack'), false); + assert.equal(old.canCollect('starterPack'), false); + assert.equal(old.isRunning('starterPack'), false); + assert.equal(old.canPresent('starterPack'), false); + assert.equal(old.wasShownToday('starterPack'), false); + assert.equal(old.monthlyRewardPending, false); + assert.equal(old.has('starterPack'), false); + for (const key of ['starterPack', 'ordersAfterPassSettlement']) + assert.equal(old.enqueue(key, 0, () => assert.fail('destroyed task ran')), false); + assert.deepEqual([...h.storage], saved, 'late callbacks cannot change persisted records'); + assert.equal(current.policy.hasPending('starterPack'), true); + assert.equal(current.enqueue('starterPack', 0, done => { current.markShown('starterPack'); done(); }), true); + h.ready(current); current.update(); + assert.equal(current.wasShownToday('starterPack'), true, 'the next home can retry the unfinished popup'); +}); + +test('queue callbacks before onLoad reject work without an initialized policy', () => { + const h = harness(), q = new h.Queue(); + q.unavailable('starterPack'); q.markShown('starterPack'); q.setMonthlyRewardPending(); + assert.equal(q.requestPresentation('starterPack'), false); + assert.equal(q.isRunning('starterPack'), false); + assert.equal(q.wasShownToday('starterPack'), false); + assert.equal(q.monthlyRewardPending, false); + assert.equal(q.enqueue('ordersAfterPassSettlement', 0, () => assert.fail('uninitialized task ran')), false); + q.onDestroy(); + assert.equal(h.storage.size, 0); +}); + for (const scenario of ['four_failures', 'level_purchase', 'leave_during_activation']) test(`lucky offer: deferred ${scenario} activation returns through the real home adapter`, () => { const reason = scenario === 'leave_during_activation' ? 'four_failures' : scenario; const h = harness(), first = h.create(); h.ready(first); first.update(); first.onDestroy();