const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const ts = require('typescript'); const root = path.resolve(__dirname, '..'); const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value)); function fixture() { const storage = new Map(), calls = [], callbacks = []; const info = { uid: 'u', userId: 1001, coin: 50, level: 99, otherLevel: 0 }; const cc = { warn() {}, fx: { GameConfig: { GM_INFO: info }, StorageMessage: { getStorage: key => clone(storage.get(key)), setStorage: (key, value) => storage.set(key, clone(value)), } } }; let offline = false, holdStatus = false, rewardMode = '', matchMode = ''; const rewardCallbacks = [], matchCallbacks = [], cancelled = new Set(); const data = { serverNow: Date.now(), available: true, period: { periodId: 'p1', enabled: true, startsAt: Date.now()-1000, endsAt: Date.now()+86400000 }, run: null, coinAmount: 50 }; function newStage(stage, levelAmount) { return { stage, status: 'playing', target: [5, 7, 9][stage - 1], start_level: levelAmount + 1, success_num: 0, attemptId: null, results: [], reward: 0, pool: [10000,15000,20000][stage-1], survivors: 100 }; } const api = { setUserCoin(done) { data.coinAmount = info.coin; calls.push({ action: 'coin_save' }); done({ code: 1 }); }, setUserLevel() { throw new Error('CloudRise must not upload mainline progress before matching'); }, POST(url, body, done) { assert.equal(url, "cloudRise/index"); calls.push(clone(body)); if (offline) { done({ code: 0, msg: 'offline' }); return; } if (!data.period && !data.run) { done({ code: 0, data: null, msg: '活动未开启' }); return; } let code = 1; const run = data.run, stage = run && run.stages.at(-1); if (body.action === 'prepare_match' && !cancelled.has(body.matchId)) { data.matching = { id:body.matchId, stage:newStage(body.stage, body.levelAmount) }; } else if(body.action === 'cancel_match') { cancelled.add(body.matchId); if(data.matching?.id===body.matchId)data.matching=null; } else if(body.action === 'confirm_match') { if(run?.stages.some(s=>s.matchId===body.matchId)) {} else if(data.matching?.id!==body.matchId||cancelled.has(body.matchId))code=0; else { const next={...data.matching.stage,matchId:body.matchId}; data.run=run?{...run,status:'playing',stage:next.stage,stages:[...run.stages,next]}: {id:'r1',periodId:'p1',status:'playing',stage:1,expiresAt:Date.now()+86400000,stages:[next]}; data.available=false;data.matching=null; } } if (body.action === 'start' && body.periodId !== data.period.periodId) code = 0; else if (body.action === 'start' && !run) { data.available = false; data.run = { id: 'r1', periodId: body.periodId, stage: 1, status: 'playing', expiresAt: Date.now() + 86400000, stages: [newStage(1, body.levelAmount)] }; } else if (body.action === 'start_stage' && run.status === 'waiting') { run.stage++; run.status = 'playing'; run.stages.push(newStage(run.stage, body.levelAmount)); } else if (body.action === 'begin' && run.status === 'playing') stage.attemptId = body.attemptId; else if (body.action === 'finish' && !stage.results.includes(body.attemptId)) { if (stage.attemptId !== body.attemptId) code = 0; else { stage.results.push(body.attemptId); stage.attemptId = null; if (body.outcome === 'win') { stage.success_num++; if (stage.success_num === stage.target) { stage.status = 'won'; run.status = run.stage === 3 ? 'completed' : 'waiting'; } } else { stage.status = 'lost'; run.status = 'failed'; } } } if (body.action === 'save_reward' && rewardMode === 'fail') { rewardMode = ''; done({ code: 0, msg: 'save failed' }); return; } if (body.action === 'save_reward') { const won = run.stages.find(s => s.stage === body.stage); if (won && won.status === 'won' && !won.rewardSaved) { won.rewardSaved = true; won.reward = body.reward; data.coinAmount = body.coinAmount; } } const response = { code, data: clone(data) }; if(body.action==='prepare_match'&&matchMode==='hold'){matchMode='';matchCallbacks.push(()=>done(response));return;} if(body.action==='confirm_match'&&matchMode==='lost'){matchMode='';done({code:0,msg:'response lost'});return;} if (body.action === 'save_reward' && rewardMode === 'lost') { rewardMode = ''; done({ code: 0, msg: 'response lost' }); return; } if (body.action === 'save_reward' && rewardMode === 'hold') { rewardMode = ''; rewardCallbacks.push(() => done(response)); return; } if (holdStatus && body.action === 'status') { holdStatus = false; callbacks.push(() => done(response)); } else done(response); }, }; function load(name, imports = {}) { const exports = {}; const code = fs.readFileSync(path.join(root, name === 'CloudRiseRuntime' ? 'assets/cloud_rise_home/scripts/CloudRiseRuntime.ts' : 'assets/Script/module/Config/' + name + '.ts'), 'utf8'); vm.runInNewContext(ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS } }).outputText, { exports, cc, console, Date, Math, require(name) { assert.ok(name in imports, name); return imports[name]; } }); return exports.default; } const boot = () => load('CloudRiseRuntime', { '../../Script/module/Pay/Utils': { default: api }, '../../Script/module/Config/CloudRiseService': { default: load('CloudRiseService', {'../Pay/Utils':{default:api}}) } }); function bootBridge() { Object.assign(data.period, {enabled:true,startsAt:Date.now()-1000,endsAt:Date.now()+86400000}); const bridge=load('CloudRiseService', {'../Pay/Utils':{default:api}}); cc.Prefab=class {}; cc.assetManager={loadBundle(name,done) { if (name === 'cloud_rise_home') load('CloudRiseRuntime', {'../../Script/module/Pay/Utils':{default:api},'../../Script/module/Config/CloudRiseService':{default:bridge}}); done(null,{load(path,type,loaded){loaded(null,{addRef(){}});}}); }}; return bridge; } return { storage, calls, callbacks, info, data, boot, bootBridge, cc, rewardCallbacks, matchCallbacks, matchMode(value){matchMode=value;}, rewardMode(value) { rewardMode = value; }, offline(value) { offline = value; }, holdStatus() { holdStatus = true; } }; } async function enrolled(f) { const service = f.boot(); assert.equal(await service.startStage(), true); return service; } async function win(service) { assert.equal(service.prepareRound(() => {}), true); service.finish(true); await service.sync(); } test('expiry after matching without playing queues one durable elimination for each stage',async()=>{ for(const number of [1,2,3]){ const f=fixture(),service=await enrolled(f); const stage=f.data.run.stages[0];stage.stage=number;stage.target=[5,7,9][number-1];f.data.run.stage=number; stage.opponents=Array.from({length:99},(_,i)=>({username:'bot'+i,alive:true})); await service.sync(); f.data.run.status='expired';stage.status='expired';stage.round=1;stage.survivors=49; stage.opponents.forEach((o,i)=>o.alive=i<49); await service.sync();const item=service.elimination(); assert(item);assert.equal(item.after.status,'expired');assert.equal(item.after.stage,number); assert.equal(item.after.success_num,0);assert.equal(item.after.survivors,49);assert.equal(item.after.round,1); assert.equal(item.before.opponents.filter(o=>o.alive).length,99); assert.equal(item.after.opponents.filter(o=>o.alive).length,49); const restored=f.boot();await restored.sync();assert.equal(restored.elimination().key,item.key); restored.acknowledgeElimination(item.key);await restored.sync();assert.equal(restored.elimination(),null); } }); test('matching sends current local progress for each stage without uploading mainline levels',async()=>{ const f=fixture(),service=f.boot(); for(let stage=1;stage<=3;stage++){ f.info.level=[103,156,210][stage-1]; const matchId='local-progress-'+stage; assert.equal(await service.prepareMatch(matchId),true); const request=f.calls.find(c=>c.action==='prepare_match'&&c.matchId===matchId); assert.equal(request.levelAmount,f.info.level); assert.equal(await service.confirmMatch(matchId),true); assert.equal(service.stage().start_level,f.info.level+1); if(stage<3)for(let i=0;i<[5,7,9][stage-1];i++)await win(service); } }); test('reenrolling after a same-period server reset cannot inherit stage 1/2 result acknowledgements',async()=>{ const f=fixture(),service=await enrolled(f); for(let stage=1;stage<=2;stage++) { for(let i=0;i<[5,7][stage-1];i++){await win(service);service.acknowledgeElimination(service.elimination().key);} service.acknowledgeResult();assert.equal(service.unseenResult(),false); if(stage===1)await service.startStage(); } const oldId=service.run().id; f.data.run=null;f.data.available=true; assert.equal(await service.startStage(),true);assert.equal(service.run().id,oldId); for(let stage=1;stage<=3;stage++) { for(let i=0;i<[5,7,9][stage-1];i++){await win(service);service.acknowledgeElimination(service.elimination().key);} assert.equal(service.unseenResult(),true,'every newly completed stage must show its reward'); const coins=f.info.coin; service.acknowledgeResult();await service.sync(); assert.equal(service.unseenResult(),false);assert.equal(f.info.coin,coins,'presentation does not grant again'); if(stage<3)await service.startStage(); } }); test('manual next stage: five wins wait; ordinary mainline and status never start stage two', async () => { const f = fixture(), service = await enrolled(f); for (let i = 0; i < 5; i++) await win(service); assert.equal(service.run().status, 'waiting'); assert.equal(f.info.coin, 150); const before = f.calls.length; assert.equal(service.prepareRound(() => {}), true); service.finish(true); await service.sync(); assert.equal(f.calls.slice(before).some(c => ['begin', 'finish', 'start_stage'].includes(c.action)), false); f.info.level = 120; const expiresAt = service.run().expiresAt; await service.startStage(); assert.equal(service.stage().stage, 2); assert.equal(service.stage().start_level, 121); assert.equal(service.run().expiresAt, expiresAt); assert.equal(f.calls.filter(c => c.action === 'start_stage').length, 1); }); test('revive/background keep the same attempt; an actual final failure ends it once', async () => { const f = fixture(), service = await enrolled(f); assert.equal(service.prepareRound(() => {}), true); await service.sync(); const before = f.calls.filter(c => c.action === 'begin').length; assert.equal(service.prepareRound(() => {}), true); await service.sync(); assert.equal(f.calls.filter(c => c.action === 'begin').length, before); service.finish(false); service.finish(false); await service.sync(); assert.equal(service.run().status, 'failed'); assert.equal(f.calls.filter(c => c.action === 'finish').length, 1); assert.equal(f.info.coin, 50); }); test('cold restart interrupts an unfinished round, but a saved offline victory is replayed as a victory', async () => { for (const outcome of [null, 'win']) { const f = fixture(); let service = await enrolled(f); assert.equal(service.prepareRound(() => {}), true); await service.sync(); f.offline(true); if (outcome) { service.finish(true); await service.sync(); } service = f.boot(); f.offline(false); await service.sync(); assert.equal(service.run().status, outcome ? 'playing' : 'failed'); assert.equal(service.stage().success_num, outcome ? 1 : 0); assert.equal(f.calls.filter(c => c.action === 'finish').at(-1).outcome, outcome || 'interrupted'); assert.equal(service.pending(), false); } }); test('unplayed home restart has no interruption; other modes do not create an attempt', async () => { const f = fixture(); await enrolled(f); const service = f.boot(); await service.sync(); f.info.otherLevel = 1; assert.equal(service.prepareRound(() => {}), true); service.finish(false); await service.sync(); assert.equal(f.calls.some(c => c.action === 'begin' || c.action === 'finish'), false); assert.equal(service.run().status, 'playing'); }); test('a result queued during final status is flushed before sync resolves', async () => { const f = fixture(), service = await enrolled(f); service.prepareRound(() => {}); await service.sync(); f.holdStatus(); const pending = service.sync(); service.finish(true); f.callbacks.shift()(); await pending; assert.equal(service.pending(), false); assert.equal(service.stage().success_num, 1); }); test('unavailable activity does not block an unenrolled player; repeated starts share one request', async () => { const f = fixture(), service = f.boot(); f.offline(true); assert.equal(await service.beginRound(), true); assert.equal(service.prepareRound(() => {}), true); f.offline(false); await Promise.all([service.startStage(), service.startStage()]); assert.equal(f.calls.filter(c => c.action === 'start').length, 1); }); test('waiting for the next stage permits ordinary mainline even when status is temporarily offline', async () => { const f = fixture(); let service = await enrolled(f); for (let i = 0; i < 5; i++) await win(service); service = f.boot(); f.offline(true); assert.equal(await service.beginRound(), true); assert.equal(service.prepareRound(() => {}), true); service.finish(false); assert.equal(service.pending(), false); assert.equal(service.run().status, 'waiting'); }); test('an uncertain signup from an ended period cannot poison the next period signup queue', async () => { const f = fixture(); f.data.period.periodId = 'p2'; f.storage.set('cloudRise:1001', { snapshot: null, attempt: null, seenResults: [], queue: [{ action: 'start', periodId: 'p1' }] }); const service = f.boot(); assert.equal(await service.sync(), true); assert.equal(service.pending(), false); assert.equal(await service.startStage(), true); assert.equal(service.run().periodId, 'p2'); }); test('WeChat custom manifest declares the activity subpackage and the engine includes label effects', () => { const manifest = JSON.parse(fs.readFileSync(path.join(root, 'build-templates/wechatgame/game.json'), 'utf8')); assert.deepEqual(manifest.subpackages.filter(p => p.name.startsWith('cloud_rise')), ['matching', 'stage1', 'stage2', 'stage3', 'art'].map(name => ({name: 'cloud_rise_' + name, root: 'subpackages/cloud_rise_' + name}))); const settings = JSON.parse(fs.readFileSync(path.join(root, 'settings/project.json'), 'utf8')); assert.equal(settings['excluded-modules'].includes('Label Effect'), false); }); test('main-package bridge loads the real runtime and retains completion popups after the final stage', async () => { const f=fixture(), service=f.bootBridge(); assert.equal(await service.sync(),true); assert.equal(service.allowsAutomaticResults(),true); for(let stage=1;stage<=3;stage++) { assert.equal(await service.startStage(),true); for(let round=0;round<[5,7,9][stage-1];round++) await win(service); if(stage<3)assert.equal(service.run().status,'waiting'); } assert.equal(service.run().status,'completed'); assert.equal(service.unseenResult(),true); assert.equal(service.allowsAutomaticResults(),true); assert.equal(f.info.coin,500); }); test('client rounds the reward up, reports it and applies it only after persistence', async () => { const f=fixture(), service=await enrolled(f); f.data.run.stages[0].survivors=3; for(let i=0;i<4;i++) await win(service); f.rewardMode('hold'); assert.equal(service.prepareRound(()=>{}),true); service.finish(true); const pending=service.sync(); while(!f.rewardCallbacks.length) await new Promise(resolve=>setImmediate(resolve)); assert.equal(f.info.coin,50); const report=f.calls.find(c=>c.action==='save_reward'); assert.equal(report.reward,3340); assert.equal(report.coinAmount,3390); f.info.coin+=10; f.rewardCallbacks.shift()(); await pending; assert.equal(f.info.coin,3400); assert.equal(f.data.coinAmount,3400); await service.sync(); assert.equal(f.info.coin,3400); }); test('stage rewards round to tens including fractional shares and exact multiples, without recrediting saved receipts',async()=>{ for(const [pool,survivors,expected] of [[10001,50,210],[10050,50,210],[10000,50,200],[1,100,10]]) { const f=fixture(),service=await enrolled(f); Object.assign(f.data.run.stages[0],{pool,survivors}); for(let i=0;i<5;i++)await win(service); assert.equal(f.data.run.stages[0].reward,expected); assert.equal(f.info.coin,50+expected); await service.sync();assert.equal(f.info.coin,50+expected); // Historical receipts remain authoritative; changing the formula must not grant again. f.data.run.stages[0].reward=201; await service.sync();assert.equal(f.info.coin,50+expected); assert.equal(f.calls.filter(c=>c.action==='save_reward').length,1); } }); test('failed or lost reward saves retry without applying twice', async () => { for(const mode of ['fail','lost']) { const f=fixture(),service=await enrolled(f); Object.assign(f.data.run.stages[0],{pool:10001,survivors:50}); for(let i=0;i<4;i++) await win(service); f.rewardMode(mode); await win(service); assert.equal(f.info.coin,50); assert.equal(await service.sync(),true); assert.equal(f.info.coin,260); assert.equal(f.data.coinAmount,260); await service.sync(); assert.equal(f.info.coin,260); } }); test('relogin after a lost reward response uses saved server coins and does not grant again', async () => { const f=fixture(); let service=await enrolled(f); for(let i=0;i<4;i++) await win(service); f.rewardMode('lost'); await win(service); assert.equal(f.info.coin,50); f.info.coin=f.data.coinAmount; service=f.boot(); assert.equal(await service.sync(),true); assert.equal(f.info.coin,150); assert.equal(f.calls.filter(c=>c.action==='save_reward').length,1); }); test('a reward response from a previous account cannot credit the newly logged-in account', async () => { const f=fixture(),service=await enrolled(f); for(let i=0;i<4;i++) await win(service); f.rewardMode('hold'); service.prepareRound(()=>{}); service.finish(true); const pending=service.sync(); while(!f.rewardCallbacks.length) await new Promise(resolve=>setImmediate(resolve)); f.info.userId=2002; f.info.coin=900; f.rewardCallbacks.shift()(); await pending; assert.equal(f.info.coin,900); }); test('closed activity clears stale eligibility and uncertain signup without blocking ordinary play', async () => { const f=fixture(),service=f.boot(); await service.sync(); assert.equal(service.visible(),true); f.data.period=null; f.data.available=false; f.storage.set('cloudRise:1001',{snapshot:service.data(),queue:[{action:'start',periodId:'p1'}],attempt:null,seenResults:[]}); const restarted=f.boot(); assert.equal(await restarted.sync(),false); assert.equal(restarted.error,'活动未开启'); assert.equal(restarted.visible(),false); assert.equal(restarted.pending(),false); assert.equal(restarted.prepareRound(()=>{}),true); }); test('fresh new-period status suppresses automatic popups for settled historical runs',async()=>{ const f=fixture(),service=await enrolled(f); service.prepareRound(()=>{});service.finish(false);await service.sync(); assert.equal(service.unseenResult(),true); f.data.period.periodId='p2';f.data.available=true; await service.sync(); assert.equal(service.run(),null);assert.equal(service.stage(),null);assert.equal(service.unseenResult(),false); assert.equal(service.data().run.periodId,'p1','legacy response is preserved internally'); }); test('confirmed wins and losses queue durable presentations once across polls and restart', async () => { const f = fixture(), service = await enrolled(f); assert.equal(service.elimination(), null); await win(service); const first = service.elimination(); assert.equal(first.before.success_num, 0); assert.equal(first.after.success_num, 1); assert.equal(first.before.survivors, first.after.survivors, 'zero eliminations still presents a win'); await service.sync(); await service.sync(); service.acknowledgeElimination(first.key); assert.equal(service.elimination(), null); await win(service); await win(service); const restored = f.boot(); await restored.sync(); assert.equal(restored.elimination().after.success_num, 2); restored.acknowledgeElimination(restored.elimination().key); assert.equal(restored.elimination().after.success_num, 3); restored.acknowledgeElimination(restored.elimination().key); restored.prepareRound(() => {}); restored.finish(false); await restored.sync(); const loss=restored.elimination();assert.equal(loss.after.status,'lost');assert(loss.key.endsWith(':lost')); await restored.sync();restored.acknowledgeElimination(loss.key);await restored.sync(); assert.equal(restored.elimination(),null); }); test('offline loss replays once after reconnect and survives restart',async()=>{ const f=fixture(),service=await enrolled(f);service.prepareRound(()=>{});await service.sync();f.offline(true); service.finish(false);await service.sync();assert.equal(service.elimination(),null); const restored=f.boot();f.offline(false);await restored.sync();const loss=restored.elimination(); assert.equal(loss.before.status,'playing');assert.equal(loss.after.status,'lost'); const again=f.boot();await again.sync();assert.equal(again.elimination().key,loss.key); again.acknowledgeElimination(loss.key);await again.sync();assert.equal(again.elimination(),null); }); test('offline victory presents only after confirmation and final stage win is not lost on reward-save failure', async () => { const f = fixture(), service = await enrolled(f); service.prepareRound(() => {}); await service.sync(); f.offline(true); service.finish(true); await service.sync(); assert.equal(service.elimination(), null); const restored = f.boot(); f.offline(false); await restored.sync(); assert.equal(restored.elimination().after.success_num, 1); restored.acknowledgeElimination(restored.elimination().key); for (let i = 0; i < 3; i++) { await win(restored); restored.acknowledgeElimination(restored.elimination().key); } f.rewardMode('fail'); await win(restored); const final = restored.elimination(); assert.equal(final.after.status, 'won'); await restored.sync(); restored.acknowledgeElimination(final.key); await restored.sync(); assert.equal(restored.elimination(), null); }); const settleMatches=async()=>{for(let i=0;i<30;i++)await Promise.resolve();}; test('cancel after prepare was sent preserves eligibility and an immediate retry cannot be overwritten',async()=>{ const f=fixture(),service=f.boot();f.matchMode('hold'); const first=service.prepareMatch('match-one');await settleMatches();assert.equal(f.matchCallbacks.length,1); service.cancelMatch('match-one');const second=service.prepareMatch('match-two'); assert.equal(f.data.run,null);assert.equal(f.data.available,true); f.matchCallbacks.shift()();assert.equal(await first,false);assert.equal(await second,true); assert.equal(f.data.matching.id,'match-two');assert.equal(service.run(),null); assert.equal(await service.confirmMatch('match-two'),true);assert.equal(service.run().status,'playing'); assert.deepEqual(f.calls.filter(c=>['prepare_match','cancel_match','confirm_match'].includes(c.action)).map(c=>[c.action,c.matchId]), [['prepare_match','match-one'],['cancel_match','match-one'],['prepare_match','match-two'],['confirm_match','match-two']]); }); test('offline cancellation survives restart without blocking ordinary play or consuming participation',async()=>{ const f=fixture();let service=f.boot();assert.equal(await service.prepareMatch('match-offline'),true); f.offline(true);service.cancelMatch('match-offline');await service.sync(); assert.equal(service.pending(),true);assert.equal(service.prepareRound(()=>{}),true); service=f.boot();let resumed=false;assert.equal(service.prepareRound(()=>{resumed=true;}),false);await settleMatches(); assert.equal(resumed,true);assert.equal(f.data.run,null); f.offline(false);assert.equal(await service.sync(),true);assert.equal(f.data.matching,null);assert.equal(f.data.available,true); assert.equal(await service.prepareMatch('match-retry'),true);assert.equal(await service.confirmMatch('match-retry'),true); }); test('cancel next-stage matching retains saved rewards and permits matching the same stage again',async()=>{ const f=fixture(),service=await enrolled(f);for(let i=0;i<5;i++)await win(service); const before=clone(service.run()),coins=f.info.coin; assert.equal(await service.prepareMatch('stage-two-old'),true);service.cancelMatch('stage-two-old');await service.sync(); assert.deepEqual(clone(service.run()),before);assert.equal(f.info.coin,coins); assert.equal(await service.prepareMatch('stage-two-new'),true);f.matchMode('lost'); assert.equal(await service.confirmMatch('stage-two-new'),true);assert.equal(service.run().stage,2); assert.equal(service.run().stages.length,2);assert.equal(f.info.coin,coins);assert.equal(service.run().expiresAt,before.expiresAt); });