const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); const ts = require('typescript'); function load(file, cc, imports, now, target = ts.ScriptTarget.ES2017) { const exports = {}; const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target, experimentalDecorators: true } }).outputText; vm.runInNewContext(code, { exports, cc, Date: { now }, require: name => { assert(name in imports, name); return imports[name]; } }); return exports.default; } const period = (id = 'cloudRise', start = 11000, end = 20000) => ({ activityId: id, periodId: id + ':p1', configVersion: 'v1', startsAt: start, endsAt: end, unlockLevel: 100, phase: 'upcoming' }); const response = (activities = [period()], serverNow = 10000, refreshAt = serverNow + 30000) => ({ code: 1, data: { schemaVersion: 1, serverNow, refreshAt, windowEndsAt: serverNow + 604800000, activities } }); function fixture(storage = new Map(), target = ts.ScriptTarget.ES2017) { let now = 10000, environment = 'test/', storageFails = false; const requests = [], events = new Map(); let resourceCycles = 0; class Component { scheduled = new Set(); schedule(fn) { this.scheduled.add(fn); } unschedule(fn) { this.scheduled.delete(fn); } } const cc = { _decorator: { ccclass: c => c }, Component, isValid: () => true, warn() {}, game: { EVENT_SHOW: 'show', EVENT_HIDE: 'hide', on(name, fn, target) { if (!events.has(name)) events.set(name, []); events.get(name).push({ fn, target }); }, off(name, fn, target) { const kept = (events.get(name) || []).filter(e => e.fn !== fn || e.target !== target); if (kept.length) events.set(name, kept); else events.delete(name); } }, fx: { GameConfig: { GM_INFO: { userId: 1001, token: 'token-a' } }, StorageMessage: { getStorage(key) { if (storageFails) throw Error('storage'); return storage.get(key); }, setStorage(key, data) { if (storageFails) throw Error('storage'); storage.set(key, structuredClone(data)); }, removeStorage(key) { if (storageFails) throw Error('storage'); storage.delete(key); } } } }; const service = load('assets/Script/module/Config/ActivityScheduleService.ts', cc, { './ActivityBundleLoader': { default: { beginCycle() { resourceCycles++; } } }, '../Pay/Utils': { default: { apiBaseUrl: () => environment, POST(path, body, resolve) { requests.push({ path, body, resolve }); } } } }, () => now, target); return { service, cc, storage, requests, events, get resourceCycles() { return resourceCycles; }, setNow: n => now = n, setEnv: e => environment = e, failStorage: () => storageFails = true, loadHome: () => load('assets/Script/HomeActivitySchedule.ts', cc, { '../home_popup_queue/HomePopupQueue': {default:{get:()=>({completePreparation(){}})}}, './module/Config/CloudRiseService': {default:{sync:async()=>true}}, './module/Config/ActivityScheduleService': { default: service }, './HomeActivityEntryLayout': { default: { refresh() { } } } }, () => now), emit: name => { for (const event of (events.get(name) || []).slice()) event.fn.call(event.target); } }; } const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); }; test('ES5 static lifecycle callbacks work when invoked without a class receiver', async () => { const f = fixture(new Map(), ts.ScriptTarget.ES5); const login = f.service.afterLogin(); f.requests[0].resolve(response([])); await login; const show = f.service.onForeground, hide = f.service.onBackground; hide(); const first = show(), concurrent = show(); assert.equal(first, concurrent); assert.equal(f.requests.length, 2); f.requests[1].resolve(response([])); await first; hide(); const next = show(); assert.equal(f.requests.length, 3); f.requests[2].resolve(response([])); await next; assert.equal(f.resourceCycles, 3); }); test('first Home reuses login in flight or completed; later Home and foreground refresh once with a shared resource cycle', async () => { for (const completed of [false, true]) { const f = fixture(), login = f.service.afterLogin(); assert.equal(f.requests.length, 1); assert.equal(f.resourceCycles, 1); if (completed) { f.requests[0].resolve(response([period('pyramid', 9000, 90000)])); await login; } const Home = f.loadHome(), home = new Home(); home.onEnable(); assert.equal(f.requests.length, 1); if (!completed) f.requests[0].resolve(response([period('pyramid', 9000, 90000)])); await login; await flush(); assert.equal(f.requests.length, 1); assert.equal(f.resourceCycles, 1); f.emit('hide'); f.emit('show'); assert.equal(f.requests.length, 2); assert.equal(f.resourceCycles, 2); f.requests[1].resolve(response([period('pyramid', 9000, 90000)])); await flush(); for (let n=0;n<100;n++) for (const tick of home.scheduled) tick(); await f.service.sync(false); assert.equal(f.requests.length, 2); home.onDisable(); home.onEnable(); assert.equal(f.requests.length, 3); assert.equal(f.resourceCycles, 3); f.requests[2].resolve(response([])); await flush(); home.onDisable(); f.emit('hide'); f.emit('show'); assert.equal(f.requests.length, 4, 'foreground also refreshes outside Home'); f.requests[3].resolve(response([])); await flush(); } }); test('a new account cannot reuse a previous login result on its first Home', async () => { const f=fixture(), first=f.service.afterLogin();f.requests[0].resolve(response());await first;await f.service.enterHome(); f.cc.fx.GameConfig.GM_INFO={userId:2002,token:'b'};const login=f.service.afterLogin(), home=f.service.enterHome(); assert.equal(f.requests.length,2);assert.equal(f.requests[1].body.uid,'2002'); f.requests[1].resolve(response([]));assert.equal(await login,true);assert.equal(await home,true);assert.equal(f.requests.length,2); }); test('Pyramid candidate keeps server time and scope; frequency belongs to the home queue', async () => { const f = fixture(); Object.assign(f.cc.fx.GameConfig.GM_INFO, { uid: 'player-a', level: 50 }); const start = 16 * 3600000 - 1000, end = start + 3 * 86400000; let current = { ...period('pyramid', start, end), unlockLevel: 50 }; async function sync() { const task = f.service.sync(true); f.requests.at(-1).resolve(response([current], start)); await task; } await sync(); const reminder = f.service.pyramidReminder(); assert(reminder); f.service.markPyramidReminderShown(reminder); assert(f.service.pyramidReminder(), 'legacy daily marker does not suppress a new login'); await sync(); assert(f.service.pyramidReminder()); f.setNow(11000); const tomorrow = f.service.pyramidReminder(); assert.equal(tomorrow.day, reminder.day + 1); f.service.markPyramidReminderShown(reminder); assert(f.service.pyramidReminder()); current = { ...current, periodId: 'pyramid:new' }; await sync(); f.setNow(12000); f.service.markPyramidReminderShown(tomorrow); assert(f.service.pyramidReminder()); f.cc.fx.GameConfig.GM_INFO.level = 49; assert.equal(f.service.pyramidReminder(), null); f.cc.fx.GameConfig.GM_INFO.level = 50; f.cc.fx.GameConfig.GM_INFO.uid = 'player-b'; await sync(); assert.notEqual(f.service.pyramidReminder().key, reminder.key); f.setEnv('production/'); await sync(); assert.notEqual(f.service.pyramidReminder().key, reminder.key); f.setNow(12000 + end - start); assert.equal(f.service.pyramidReminder(), null); }); test('CloudRise candidate uses server Beijing date and ignores legacy daily markers', async () => { const f = fixture(); f.cc.fx.GameConfig.GM_INFO.level = 100; const serverNow = 16 * 3600000 - 1000; async function sync() { const task = f.service.sync(true); f.requests.at(-1).resolve(response([period('cloudRise', 0, 3 * 86400000)], serverNow)); await task; } assert.equal(f.service.cloudRiseReminder(), null); await sync(); const first = f.service.cloudRiseReminder(); assert.equal(first.day, 0); f.service.markCloudRiseReminderShown(first); assert(f.service.cloudRiseReminder()); await sync(); assert(f.service.cloudRiseReminder(), 'queue controls automatic batches'); const restarted = fixture(f.storage); restarted.cc.fx.GameConfig.GM_INFO.level = 100; const task = restarted.service.sync(); restarted.requests[0].resolve(response([period('cloudRise', 0, 3 * 86400000)], serverNow)); await task; assert(restarted.service.cloudRiseReminder(), 'return-home candidates remain available'); f.setNow(11000); assert.equal(f.service.cloudRiseReminder().day, 1, 'server midnight opens the next day'); f.cc.fx.GameConfig.GM_INFO.level = 99; assert.equal(f.service.cloudRiseReminder(), null); f.cc.fx.GameConfig.GM_INFO.level = 100; f.cc.fx.GameConfig.GM_INFO.userId = 1002; await sync(); assert.notEqual(f.service.cloudRiseReminder().key, first.key); f.service.markCloudRiseReminderShown(first); assert(f.service.cloudRiseReminder(), 'stale account cannot mark current account'); f.cc.fx.GameConfig.GM_INFO.userId = 1001; f.setEnv('production/'); await sync(); assert.notEqual(f.service.cloudRiseReminder().key, first.key); f.setNow(4 * 86400000); assert.equal(f.service.cloudRiseReminder(), null, 'expired activity is not promoted'); }); test('pyramid schedule works without private detail fields and is account-scoped', async () => { const f = fixture(), query = f.service.sync(); const reply = response([period('cloudRise', 9000), period('pyramid', 9000)]); f.requests[0].resolve(reply); await query; assert.ok(f.service.active('pyramid')); assert.ok(f.service.active('cloudRise')); assert.equal(f.requests[0].body.pyramidPeriodId, undefined); await f.service.sync(); assert.equal(f.requests.length, 1); f.cc.fx.GameConfig.GM_INFO.uid = 'another-database-id'; assert.equal(f.service.active('pyramid'), null); }); test('cache environment resolver exactly matches POST routing in development, trial and production', () => { let version, opened; const cc = { fx: { GameTool: { getWechatGameVersion: () => version }, GameConfig: { GM_INFO: {} } }, loader: { getXMLHttpRequest: () => ({ open(_method, url) { opened = url; }, setRequestHeader() { }, send() { } }) } }; const Utils = load('assets/Script/module/Pay/Utils.ts', cc, { '../../Sdk/MiniGameSdk': { MiniGameSdk: {} }, '../Config/BattlePassModel': {}, '../Config/BattlePassSettlementModel': {}, '../Config/BattlePassSettlementCheck': { default: {} }, '../Config/ClientUpdate': { default: { blocked: false, handleResponse: value => value } }, '../Config/ServerEnvironment': { default: { current: () => ['开发版', '体验版'].includes(version) ? 'test' : 'production' } } }, () => 10000, ts.ScriptTarget.ES5); // Match the project's target for existing Utils identifiers. for (version of ['开发版', '体验版', '正式版', 'web']) { const expected = ['开发版', '体验版'].includes(version) ? Utils.testHttpip : Utils.httpip; assert.equal(Utils.apiBaseUrl(), expected); Utils.POST('activityConfig/list', {}, () => { }); assert.equal(opened, expected + 'activityConfig/list'); } }); const openingResponse = (overrides = {}, now = 10000) => { const res = response([], now); res.data.openingRules = [{ activityId: 'cloudRise', enabled: true, unlockLevel: 100, cooldownMs: 43200000, lastEndedAt: null, hasOpenPeriod: false, ...overrides }]; return res; }; const openSuccess = (now = 10000) => ({ code: 1, data: { serverNow: now, period: period('cloudRise', now, now + 86400000) } }); test('Home checks opening at the unlock boundary and shows the entry only after a successful response', async () => { const f = fixture(); f.cc.fx.GameConfig.GM_INFO.level = 100; const first = f.service.checkCloudRiseOpening(() => true), duplicate = f.service.checkCloudRiseOpening(() => true); f.requests[0].resolve(openingResponse()); await flush(); assert.equal(f.requests.length, 2); assert.equal(f.requests[1].path, 'cloudRise/index'); assert.equal(f.requests[1].body.action, 'open_period'); assert.equal(f.requests[1].body.levelAmount, 100); assert.equal(f.service.active('cloudRise'), null); f.requests[1].resolve(openSuccess()); assert.equal(await first, true); assert.equal(await duplicate, true); assert.equal(f.service.active('cloudRise').startsAt, 10000); assert.equal(f.requests.length, 2); }); test('Home rejects locked, disabled, already opened and not-yet-cooled-down states', async () => { for (const [level, rule] of [[99, {}], [100, { enabled: false }], [100, { hasOpenPeriod: true }], [100, { lastEndedAt: 10000 - 43200000 }], [100, { lastEndedAt: 9000 }]]) { const f = fixture(); f.cc.fx.GameConfig.GM_INFO.level = level; const check = f.service.checkCloudRiseOpening(() => true); f.requests[0].resolve(openingResponse(rule)); assert.equal(await check, false); assert.equal(f.requests.length, 1); } const f = fixture(); f.cc.fx.GameConfig.GM_INFO.level = 100; const check = f.service.checkCloudRiseOpening(() => true); f.requests[0].resolve(openingResponse({ lastEndedAt: 10000 - 43200000 - 1 })); await flush(); assert.equal(f.requests.length, 2); f.requests[1].resolve(openSuccess()); assert.equal(await check, true); }); test('polling alone never opens periods; leaving Home during its list request cancels the opening decision', async () => { const f = fixture(); f.cc.fx.GameConfig.GM_INFO.level = 100; const query = f.service.sync(); f.requests[0].resolve(openingResponse()); await query; assert.equal(f.requests.length, 1); let home = true; const check = f.service.checkCloudRiseOpening(() => home); home = false; f.requests[1].resolve(openingResponse()); assert.equal(await check, false); assert.equal(f.requests.length, 2); }); test('opening failures never fabricate an entry and a later Home visit can retry', async () => { const f = fixture(); f.cc.fx.GameConfig.GM_INFO.level = 100; for (const reply of [null, { code: 0 }, openSuccess()]) { const check = f.service.checkCloudRiseOpening(() => true); f.requests.at(-1).resolve(openingResponse()); await flush(); f.requests.at(-1).resolve(reply); assert.equal(await check, !!reply && reply.code === 1); assert.equal(!!f.service.active('cloudRise'), !!reply && reply.code === 1); } }); test('opening responses are isolated by account and cannot be erased by an older list', async () => { const f = fixture(); f.cc.fx.GameConfig.GM_INFO.level = 100; const check = f.service.checkCloudRiseOpening(() => true); f.requests[0].resolve(openingResponse()); await flush(); const stale = f.service.sync(true); f.requests[1].resolve(openSuccess()); await flush(); f.requests[2].resolve(openingResponse()); await stale; assert.equal(await check, true); assert.ok(f.service.active('cloudRise')); const second = f.service.checkCloudRiseOpening(() => true); f.requests[3].resolve(openingResponse()); await flush(); f.cc.fx.GameConfig.GM_INFO = { userId: 2002, token: 'token-b', level: 100 }; f.requests[4].resolve(openSuccess()); assert.equal(await second, false); assert.equal(f.service.active('cloudRise'), null); }); test('login and home share an in-flight request; all activity ids are cached, no private state', async () => { const f = fixture(), a = f.service.sync(true), b = f.service.sync(true); assert.equal(f.requests.length, 1); assert.equal(f.requests[0].path, 'activityConfig/list'); assert.deepEqual(JSON.parse(JSON.stringify(f.requests[0].body)), { uid: '1001' }); f.requests[0].resolve(response([period(), period('goldMiner'), period('futureGame')])); assert.equal(await a, true); assert.equal(await b, true); assert.equal(f.storage.size, 1); assert.equal(f.service.periods('futureGame').length, 1); assert.equal(f.service.active('cloudRise'), null); }); test('fresh upcoming schedule starts and ends locally between requests, ignoring stale phase', async () => { const f = fixture(), seen = []; const task = f.service.sync(); f.requests[0].resolve(response()); await task; const off = f.service.subscribe(() => seen.push(!!f.service.active('cloudRise'))); f.service.tick(); f.setNow(10999); f.service.tick(); assert.equal(f.service.active('cloudRise'), null); f.setNow(11000); f.service.tick(); assert.equal(f.service.active('cloudRise').periodId, 'cloudRise:p1'); f.setNow(20000); f.service.tick(); assert.equal(f.service.active('cloudRise'), null); assert.deepEqual(seen, [true, false]); assert.equal(f.requests.length, 1); off(); f.setNow(10000); f.service.tick(); assert.equal(seen.length, 2); }); test('server clock, current period selection and unknown activities are independent of device clock', async () => { const f = fixture(); f.setNow(900000); const task = f.service.sync(); const current = period('cloudRise', 10000, 20000), future = { ...period('cloudRise', 18000, 28000), periodId: 'next' }; f.requests[0].resolve(response([future, current, { ...period('goldMiner', 10000, 20000), purchaseEnabled: false }], 15000)); await task; assert.equal(f.service.now(), 15000); assert.equal(f.service.active('cloudRise').periodId, current.periodId); assert.equal(f.service.active('goldMiner').purchaseEnabled, false); f.setNow(903000); assert.equal(f.service.active('cloudRise').periodId, 'next'); }); test('local ticks never poll; explicit refresh discovers an event published after login', async () => { const f = fixture(); const first = f.service.sync(); f.requests[0].resolve(response([])); await first; f.setNow(39999); f.service.tick(); assert.equal(f.requests.length, 1); f.setNow(40000); f.service.tick(); assert.equal(f.requests.length, 1); assert.equal(await f.service.sync(false), true); assert.equal(f.requests.length, 1); const refresh = f.service.sync(true); f.requests[1].resolve(response([period('cloudRise', 35000, 90000)], 40000)); await refresh; assert(f.service.active('cloudRise')); f.setNow(69999); f.service.tick(); assert.equal(f.requests.length, 2); }); test('refreshAt does not trigger requests; pyramid starts and ends using calibrated server time', async () => { const f = fixture(); f.setNow(900000); const task = f.service.sync(); f.requests[0].resolve(response([period('pyramid')], 10000, 11000)); await task; f.setNow(901000); f.service.tick(); assert(f.service.active('pyramid')); f.setNow(909999); f.service.tick(); assert(f.service.active('pyramid')); f.setNow(910000); f.service.tick(); assert.equal(f.service.active('pyramid'), null); f.setNow(999999999); f.service.tick(); assert.equal(f.requests.length, 1); }); test('refresh and transient failures preserve valid entries; a successful empty list removes them', async () => { const f = fixture(); f.storage.set('cloudRise:1001', { run: 'personal' }); f.storage.set('activitySchedule:v2:prod/:1001', { otherEnvironment: true }); for (const res of [{ code: 0 }, { code: 1, data: null }, response([{ ...period(), endsAt: 'bad' }])]) { const fresh = f.service.sync(true); f.requests.at(-1).resolve(response([period('cloudRise', 9000)])); await fresh; assert(f.service.active('cloudRise')); assert(f.storage.has('activitySchedule:v2:test/:1001')); const task = f.service.sync(true); assert.equal(f.service.hasSchedule(), true); assert.equal(f.storage.has('activitySchedule:v2:test/:1001'), true); f.requests.at(-1).resolve(res); assert.equal(await task, false); assert(f.service.active('cloudRise')); assert.equal(f.storage.has('activitySchedule:v2:test/:1001'), true); } assert.deepEqual(f.storage.get('cloudRise:1001'), { run: 'personal' }); assert.deepEqual(f.storage.get('activitySchedule:v2:prod/:1001'), { otherEnvironment: true }); const clear = f.service.sync(true); f.requests.at(-1).resolve(response([])); await clear; assert.equal(f.service.hasSchedule(), true); assert.equal(f.service.active('cloudRise'), null); }); test('cold start ignores disk snapshots and reentrant refreshes share the pending request', async () => { const f = fixture(new Map([['activitySchedule:v2:test/:1001', { receivedAt: 10000, data: response([period('cloudRise', 9000)]).data }]])); assert.equal(f.service.active('cloudRise'), null, 'new login does not restore a previous personal period'); let invalidated = 0; f.service.subscribe(() => { if (!f.service.hasSchedule()) { invalidated++; void f.service.sync(true); } }); const task = f.service.sync(true); assert.equal(invalidated, 1); assert.equal(f.requests.length, 1); assert.equal(f.service.hasSchedule(), false); f.requests[0].resolve(response([period('goldMiner', 9000)])); await task; assert.equal(f.service.active('cloudRise'), null); assert(f.service.active('goldMiner')); f.service.tick(); assert(f.service.active('goldMiner'), 'throttled ticks do not clear a fresh schedule'); assert.equal(f.requests.length, 1); }); test('expired cache window is not authoritative and a corrupt cache/storage failure does not block fetching', async () => { const f = fixture(); f.failStorage(); const task = f.service.sync(); f.requests[0].resolve(response([period('cloudRise', 9000, 999999999)])); assert.equal(await task, true); assert(f.service.active('cloudRise')); f.setNow(604810000); assert.equal(f.service.hasSchedule(), false); assert.equal(f.service.active('cloudRise'), null); }); test('test/prod caches stay isolated, including responses that finish after an environment switch', async () => { const f = fixture(), old = f.service.sync(); f.setEnv('prod/'); assert.equal(f.service.hasSchedule(), false); const current = f.service.sync(); f.requests[0].resolve(response()); assert.equal(await old, false); assert.equal(f.storage.size, 0); f.requests[1].resolve(response([period('goldMiner', 9000)])); assert.equal(await current, true); assert(f.storage.has('activitySchedule:v2:prod/:1001')); assert.equal(f.service.active('cloudRise'), null); f.setEnv('test/'); assert.equal(f.service.hasSchedule(), false); }); test('home lifecycle only checks time locally, refreshes on foreground and removes callbacks on exit', async () => { const f = fixture(), Home = f.loadHome(), home = new Home(); home.onEnable(); assert.equal(home.scheduled.size, 1); assert.equal(f.requests.length, 1); f.requests[0].resolve(response([period('cloudRise', 11000, 200000)])); await flush(); f.setNow(50000); for (const tick of home.scheduled) tick(); assert.equal(f.requests.length, 1); assert(f.service.active('cloudRise')); f.emit('hide'); assert.equal(home.scheduled.size, 0); f.setNow(15000); f.emit('show'); assert.equal(home.scheduled.size, 1); assert.equal(f.requests.length, 2); assert(f.service.active('cloudRise'), 'foreground request preserves the valid entry'); f.requests[1].resolve(response([period()], 15000)); await flush(); assert(f.service.active('cloudRise')); home.onDisable(); assert.equal(home.scheduled.size, 0); assert.equal(f.events.size, 0); home.onEnable(); assert.equal(home.scheduled.size, 1); home.onDisable(); }); test('personal activity cache and in-flight responses are isolated by account and login token', async () => { const f = fixture(), first = f.service.sync(); f.cc.fx.GameConfig.GM_INFO = { userId: 2002, token: 'token-b' }; const second = f.service.sync(); assert.equal(f.requests[1].body.uid, '2002'); f.requests[0].resolve(response([period('cloudRise', 9000)])); assert.equal(await first, false); assert.equal(f.service.active('cloudRise'), null); f.requests[1].resolve(response([period('cloudRise', 9500)])); assert.equal(await second, true); assert(f.storage.has('activitySchedule:v2:test/:2002')); assert(!f.storage.has('activitySchedule:v2:test/:1001')); const old = f.service.sync(true); f.cc.fx.GameConfig.GM_INFO.token = 'new-login'; const fresh = f.service.sync(true); assert.equal(f.requests.length, 4); f.requests[2].resolve(response([period('cloudRise', 9000)])); assert.equal(await old, false); f.requests[3].resolve(response([])); assert.equal(await fresh, true); assert.equal(f.service.active('cloudRise'), null); }); test('activity list waits for a logged-in account and token', async () => { const f = fixture(); f.cc.fx.GameConfig.GM_INFO = {}; assert.equal(await f.service.sync(), false); assert.equal(f.requests.length, 0); });