540 lines
33 KiB
JavaScript
540 lines
33 KiB
JavaScript
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 = [], networkFailures = {}, retryDelays = [];
|
|
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 (networkFailures[body.action] > 0) {
|
|
networkFailures[body.action]--;
|
|
done({code:0,msg:'network failure',networkError:true});return;
|
|
}
|
|
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,
|
|
setTimeout(fn,delay){retryDelays.push(delay);Promise.resolve().then(fn);},
|
|
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, networkFailures, retryDelays, 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('POST marks transport failures for matching retries and invokes the callback only once',()=>{
|
|
const file='assets/Script/module/Pay/Utils.ts';
|
|
const ast=ts.createSourceFile(file,fs.readFileSync(file,'utf8'),ts.ScriptTarget.Latest,true);
|
|
const cls=ast.statements.find(n=>ts.isClassDeclaration(n));
|
|
const methods=cls.members.filter(n=>n.name&&['POST','apiBaseUrl'].includes(n.name.getText(ast))).map(n=>n.getText(ast)).join('\n');
|
|
const exports={},xhr={open(){},setRequestHeader(){},send(){}};
|
|
const cc={loader:{getXMLHttpRequest:()=>xhr},fx:{GameConfig:{GM_INFO:{}},GameTool:{getWechatGameVersion:()=> '开发版'}}};
|
|
vm.runInNewContext(ts.transpileModule('export default class Utils {'+methods+'}',{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,{exports,cc});
|
|
exports.default.testHttpip='https://test.invalid/';const responses=[];
|
|
exports.default.POST('cloudRise/index',{},r=>responses.push(r));
|
|
xhr.ontimeout();xhr.onerror();xhr.readyState=4;xhr.status=0;xhr.onreadystatechange();
|
|
assert.equal(xhr.timeout,5000);assert.equal(responses.length,1);assert.equal(responses[0].networkError,true);
|
|
});
|
|
|
|
test('matching retries network failures with the same match ID, then confirms once',async()=>{
|
|
const f=fixture(),s=f.boot();f.networkFailures.prepare_match=2;
|
|
assert.equal(await s.prepareMatch('network-match'),true);
|
|
const attempts=f.calls.filter(x=>x.action==='prepare_match');assert.equal(attempts.length,3);
|
|
assert(attempts.every(x=>x.matchId==='network-match'&&x.levelAmount===99));
|
|
f.networkFailures.confirm_match=1;assert.equal(await s.confirmMatch('network-match'),true);
|
|
assert.equal(s.run().stages.length,1);assert.equal(s.matchRetryExhausted,false);
|
|
assert.deepEqual(f.retryDelays,[3000,3000,3000]);
|
|
});
|
|
|
|
test('matching exhausts five retries and retains the original durable operation for later recovery',async()=>{
|
|
for(const action of ['status','prepare_match','confirm_match']){
|
|
const f=fixture(),s=f.boot();
|
|
if(action==='confirm_match')assert.equal(await s.prepareMatch('exhausted-match'),true);
|
|
f.networkFailures[action]=6;const before=f.calls.filter(x=>x.action===action).length;
|
|
assert.equal(await (action==='confirm_match'?s.confirmMatch('exhausted-match'):s.prepareMatch('exhausted-match')),false);
|
|
assert.equal(s.matchRetryExhausted,true);assert.equal(f.calls.filter(x=>x.action===action).length-before,6);
|
|
assert.equal(f.retryDelays.length,5);
|
|
if(action!=='status')assert(f.storage.get('cloudRise:1001').queue.some(x=>x.action===action&&x.matchId==='exhausted-match'));
|
|
}
|
|
});
|
|
|
|
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('dismissing timeout presentations is durable and preserves unrelated wins, losses and pending rewards',async()=>{
|
|
const f=fixture(),service=await enrolled(f);
|
|
f.data.run.status='expired';f.data.run.stages[0].status='expired';await service.sync();
|
|
assert(service.elimination());assert(service.unseenResult());
|
|
const saved=f.storage.get('cloudRise:1001');
|
|
saved.eliminations.unshift({key:'ordinary-win',after:{status:'playing'}},{key:'ordinary-loss',after:{status:'lost'}});
|
|
f.storage.set('cloudRise:1001',saved);
|
|
const restored=f.boot();restored.dismissExpiryPresentation();
|
|
assert.equal(restored.unseenResult(),false);
|
|
assert.deepEqual(f.storage.get('cloudRise:1001').eliminations.map(x=>x.key),['ordinary-win','ordinary-loss']);
|
|
const again=f.boot();await again.sync();assert.equal(again.unseenResult(),false);
|
|
assert(!f.storage.get('cloudRise:1001').eliminations.some(x=>x.after.status==='expired'));
|
|
const g=fixture(),winner=await enrolled(g);
|
|
for(let i=0;i<5;i++)await win(winner);
|
|
const before=g.info.coin;winner.dismissExpiryPresentation();assert(winner.unseenResult());assert(winner.elimination());
|
|
assert.equal(g.info.coin,before);assert.equal(winner.stage().rewardSaved,true);
|
|
const h=fixture(),pending=await enrolled(h);
|
|
h.data.run.status='expired';Object.assign(h.data.run.stages[0],{status:'won',success_num:5,rewardSaved:false});
|
|
h.rewardMode('fail');assert.equal(await pending.sync(),false);
|
|
pending.dismissExpiryPresentation();assert.equal(pending.stage().rewardSaved,false);assert.equal(h.info.coin,50);
|
|
assert.equal(await pending.sync(),true);assert.equal(pending.stage().rewardSaved,true);assert(h.info.coin>50);
|
|
});
|
|
|
|
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(service.prepareRound(), true);
|
|
await service.sync();
|
|
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(service.prepareRound(), true);
|
|
await service.sync();
|
|
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 manifest excludes remote activity resources 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 => ['matching', 'stage1', 'stage2', 'stage3', 'art'].includes(p.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;}),true);await settleMatches();
|
|
assert.equal(resumed,false);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);
|
|
});
|
|
|
|
|
|
test('cold offline startup never consumes a board touch or resumes it later',async()=>{
|
|
const f=fixture(),bridge=f.bootBridge();f.offline(true);let resumed=0;
|
|
for(let i=0;i<3;i++){assert.equal(bridge.prepareRound(()=>resumed++),true);await settleMatches();}
|
|
assert.equal(resumed,0);assert.equal(f.calls.length,1,'touches throttle failed recovery');assert.equal(f.data.run,null);
|
|
});
|
|
|
|
test('offline first move and consecutive wins persist in order without blocking play',async()=>{
|
|
const f=fixture(),service=await enrolled(f);f.offline(true);
|
|
for(let round=0;round<2;round++){
|
|
assert.equal(service.prepareRound(),true);const attempt=f.storage.get('cloudRise:1001').attempt;
|
|
assert.equal(service.prepareRound(),true);assert.equal(f.storage.get('cloudRise:1001').attempt.attemptId,attempt.attemptId);
|
|
service.finish(true);await service.sync();
|
|
}
|
|
const saved=f.storage.get('cloudRise:1001');assert.deepEqual(saved.queue.map(x=>x.action),['begin','finish','begin','finish']);
|
|
f.offline(false);assert.equal(await service.sync(),true);assert.equal(service.stage().success_num,2);assert.equal(service.pending(),false);
|
|
});
|
|
|
|
test('unsynced local loss and final win stop recording activity steps but never block boards',async()=>{
|
|
for(const win of [false,true]){
|
|
const f=fixture(),service=await enrolled(f);f.offline(true);
|
|
for(let i=0;i<(win?5:1);i++){assert.equal(service.prepareRound(),true);service.finish(win);await service.sync();}
|
|
const before=clone(f.storage.get('cloudRise:1001').queue);
|
|
assert.equal(service.prepareRound(),true);service.finish(true);assert.deepEqual(f.storage.get('cloudRise:1001').queue,before);
|
|
f.offline(false);assert.equal(await service.sync(),true);assert.equal(service.run().status,win?'waiting':'failed');
|
|
}
|
|
});
|
|
|
|
test('pending signup and expired snapshots never block offline board input',async()=>{
|
|
const f=fixture(),service=await enrolled(f);f.data.serverNow=Date.now();f.data.run.expiresAt=f.data.serverNow-1000;await service.sync();f.offline(true);
|
|
assert.equal(service.prepareRound(),true);assert.equal(f.storage.get('cloudRise:1001').attempt,null);
|
|
const other=fixture();other.storage.set('cloudRise:1001',{snapshot:null,queue:[{action:'confirm_match',matchId:'old'}],attempt:null,seenResults:[]});
|
|
other.offline(true);assert.equal(other.boot().prepareRound(),true);
|
|
});
|