MatchMaster/tools/test-cloud-rise-animations.cjs
2026-09-21 12:44:48 +08:00

517 lines
36 KiB
JavaScript

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 fixture() {
const scheduled = [], tweens = [];
class Component { scheduleOnce(fn) { scheduled.push(fn); } }
class Node {
constructor(name = '') { this.name = name; this.children = []; this.scale = 1; this.opacity = 255; this.active = true; }
addChild(node) { this.children.push(node); node.parent=this; }
getChildByName(name) { return this.children.find(n => n.name === name); }
setPosition(x, y) { this.x = x; this.y = y; }
destroy() { this.destroyed = true; }
getComponent() { return this.component || null; }
addComponent() { return this.component={}; }
setContentSize(width,height) { this.width=width;this.height=height; }
off() {}
}
function tween(target) {
const actions = [];
return { target, actions, delay(n) { actions.push({delay:n}); return this; },
to(n, props, options) { actions.push({props, options, duration:n}); return this; }, call(fn) { actions.push({fn}); return this; },
start() { tweens.push(this); return this; } };
}
const cc = { Component, Node, Label:class{}, Sprite:class{static SizeMode={CUSTOM:0};}, SpriteFrame:class{}, String, Color:{WHITE:{}},color:()=>({}),
_decorator:{ccclass:c=>c, property:()=>()=>{}}, isValid:n=>!!n&&!n.destroyed, tween,
Tween:{stopAllByTarget(target) { for (const tween of tweens) if(tween.target === target) tween.stopped=true; }},
instantiate() { const slot=new Node();slot.addChild(new Node('Name'));slot.addChild(new Node('Status'));return slot; },
fx:{GameConfig:{GM_INFO:{username:'我',userId:1001}}} };
const exports={};
vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/cloud_rise_home/scripts/CloudRisePanel.ts','utf8'),
{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017,experimentalDecorators:true}}).outputText,
{exports,cc,console,require:()=>({default:class{}})});
const panel=new exports.default();panel.node=new Node();panel.progressRoster=new Node();panel.progressStart={x:310,y:-950};
panel.rowTemplate={children:[new Node()]};panel.avatar=(slot,player)=>slot.player=player;
panel.matchingSlots=[new Node('left'),new Node('right'),new Node('front')];
panel.avatarTemplates=panel.matchingSlots;const matchingRoot=new Node();panel.matchingSlots.forEach(slot=>matchingRoot.addChild(slot));
panel.matchingSlots.forEach(slot=>{const face=new Node('Face');face.addChild(new Node('Photo'));face.addChild(new Node('Cat'));slot.addChild(face);});
panel.matchingSlots.forEach((slot,i)=>{slot.scale=i===2?1:110/140;slot.x=[-125,140,5][i];slot.y=[-620,-610,-660][i];});
panel.matchingPrompt=new Node();panel.matchingTitle={node:new Node(),string:''};panel.matchingCount={string:'1/100'};
panel.matchingReward={setValue:n=>panel.pool=n};
panel.progressArt={};panel.stageArt=[{}, {}, {}];panel.progressCount={string:''};panel.survivorCount={string:''};
panel.progressPool={setValue:n=>panel.pool=n};panel.updateClocks=()=>{};
panel.tracks=[4,6,8].map(n=>({children:Array.from({length:n},(_,i)=>({x:i%2?-200:200,y:-800+i*90}))}));
const progress=new Node('Progress');progress.activeInHierarchy=true;
for(const [name,x,y] of [['PrizeCloud',0,200],['StartingCloud',330,-1050]]){const cloud=new Node(name);cloud.setPosition(x,y);progress.addChild(cloud);}
panel.pages=[null,null,progress];
const stage = (alive, score=0) => ({stage:1,target:5,status:score===5?'won':'playing',success_num:score,
pool:10000,survivors:alive+1,opponents:Array.from({length:99},(_,i)=>({username:'玩家'+i,alive:i<alive}))});
const flushTweens=()=>{ for(const t of tweens.splice(0)) if(!t.stopped) for(const a of t.actions) {
if(a.props) Object.assign(t.target,a.props); if(a.fn) a.fn();
} };
const advance=async()=>{flushTweens();const fn=scheduled.shift();if(fn)fn();await Promise.resolve();await Promise.resolve();};
return {panel,stage,tweens,scheduled,advance,flushTweens,cc};
}
test('matching accumulates a representative stack without replacing avatars and counts the full 100 players',async()=>{
const f=fixture();const stage=f.stage(99);stage.pool=23456;
f.panel.resetMatchingAvatars();f.flushTweens();
const self=f.panel.matchingAvatars[0];assert.equal(self.opacity,255);
const task=f.panel.playMatching(stage);
assert.equal(f.panel.matchingAvatars.length,19);assert.equal(f.panel.pool,23456);
assert.equal(new Set(f.panel.matchingAvatars).size,19);
assert.equal(self.opacity,255,'already visible self is not hidden when opponents arrive');
const bindings=f.panel.matchingAvatars.map(n=>n.player);
await f.advance();assert.equal(f.panel.matchingCount.string,'100/100');
assert.equal(f.panel.matchingPrompt.active,true);
assert.equal(f.panel.matchingTitle.node.active,false);
assert(f.panel.matchingAvatars.every(slot=>slot.opacity===255&&slot.active));
assert.equal(self.scale,1);
const [left,right]=f.panel.matchingAvatars.slice(1,3);
assert(left.x<self.x&&right.x>self.x,'avatars fill both sides of the player');
assert(Math.abs(left.y-self.y)<15&&Math.abs(right.y-self.y)<15,'side avatars occupy the front row');
assert(f.panel.matchingAvatars.slice(1).every(n=>n.zIndex<self.zIndex),'player always renders in front');
assert(f.panel.matchingAvatars.slice(3).every(n=>n.scale<self.scale));
const boxes=f.panel.matchingAvatars.map(n=>({x:n.x,y:n.y,size:n.scale*140}));
assert.equal(new Set(boxes.map(b=>b.x+':'+b.y)).size,19,'each arrival has its own position');
assert(Math.max(...boxes.map(b=>b.x+b.size/2))-Math.min(...boxes.map(b=>b.x-b.size/2))>350);
assert(boxes.every(b=>Math.abs(b.x)+b.size/2<284),'stack stays within the cloud width');
for(let i=1;i<19;i++)assert.equal(bindings[i],stage.opponents[Math.floor((i-1)*99/18)]);
await f.advance();await task;
assert.deepEqual(f.panel.matchingAvatars.map(n=>n.player),bindings,'no replacement after more arrivals');
assert(f.panel.matchingAvatars.every(slot=>slot.opacity===255),'all remain until matching leaves');
});
function floatingFixture() {
const f=fixture(),p=f.panel;
const page=new p.node.constructor('Progress');page.activeInHierarchy=true;
['StageArt','PrizeCloud','Chest','ProgressReward','StartingCloud'].forEach((name,i)=>{
const node=new p.node.constructor(name);node.x=0;node.y=i*100;page.addChild(node);
});
p.pages=[null,null,page];p.mode='progress';p.progressRoster.y=0;
p.tracks.forEach((track,i)=>track.active=i===0);
return {...f,page};
}
test('all progress clouds float gently out of phase while chest, reward and standing avatars stay attached',()=>{
const f=floatingFixture(),p=f.panel,page=f.page;
p.prepareProgressRoster(f.stage(20,2),[0,1]);
const nodes=page.children.filter(n=>n.name!=='StageArt').concat(p.tracks[0].children);
const hidden=p.tracks.slice(1).flatMap(track=>track.children).map(node=>[node,node.y]);
const rest=new Map(nodes.map(node=>[node,node.y]));
const cloud=p.tracks[0].children[1];
for(let frame=0;frame<600;frame++) {
p.update(1/60);
nodes.forEach(node=>assert(Math.abs(node.y-rest.get(node))<=12.000001));
const delta=name=>{const node=page.getChildByName(name);return node.y-rest.get(node);};
assert(Math.abs(delta('PrizeCloud')-delta('Chest'))<1e-9);
assert(Math.abs(delta('PrizeCloud')-delta('ProgressReward'))<1e-9);
assert(Math.abs(p.progressSlots[0].node.y-cloud.y-60)<1e-9);
hidden.forEach(([node,y])=>assert.equal(node.y,y,'hidden stage stays at its authored position'));
}
assert(nodes.every(node=>Math.abs(node.y-rest.get(node))>0.01),'each cloud receives motion');
assert.notEqual(nodes[0].y-rest.get(nodes[0]),nodes[nodes.length-1].y-rest.get(nodes[nodes.length-1]));
assert.equal(f.tweens.length,0,'idle motion does not interfere with avatar tweens');
});
test('cloud floating continues during elimination and restores positions on page hiding and disable',()=>{
const f=floatingFixture(),p=f.panel;
p.prepareProgressRoster(f.stage(20,2),[0]);
const cloud=p.tracks[0].children[1],restY=cloud.y;
p.update(0.5);assert.notEqual(cloud.y,restY);
p.prepareProgressRoster(f.stage(19,2),[0]);
assert.equal(cloud.y,restY);assert.equal(p.progressRoster.y,0);
assert.equal(p.progressSlots[0].node.y,restY+60);
p.update(0.5);p.busy=true;p.mode='elimination';const previous=cloud.y;p.update(1/60);
assert.notEqual(cloud.y,previous);assert.equal(p.progressSlots[0].node.y,cloud.y+60);
p.busy=false;p.update(0.5);p.mode='participants';p.update(1/60);
assert.equal(cloud.y,restY);
p.mode='progress';p.update(0.5);p.onDisable();
assert.equal(cloud.y,restY);assert.equal(p.progressRoster.y,0);
p.update(0.5);p.onDestroy();assert.equal(cloud.y,restY);
});
test('during elimination, landed avatars follow the next floating cloud and falling avatars detach',async()=>{
const f=floatingFixture(),p=f.panel;p.mode='elimination';
const before=f.stage(79,1),after=f.stage(49,2);
const task=p.playElimination({before,after});
const from=p.tracks[0].children[0],to=p.tracks[0].children[1],self=p.progressSlots[0].node;
p.update(0.3);assert.equal(self.y,from.y+60);
await f.advance();assert.equal(p.restingAvatars.has(self),false,'jump is driven by its tween');
p.update(0.3);await f.advance();p.update(0.3);
assert.equal(self.y,to.y+60,'landed self follows the destination while other players jump');
await f.advance();
const falling=p.progressSlots.find(s=>s.index>=49).node;
assert.equal(p.restingAvatars.has(falling),false);
await f.advance();await f.advance();assert.equal(await task,true);
});
test('switching stage floats only the newly active Step nodes',()=>{
const f=floatingFixture(),p=f.panel;
const old=p.tracks[0].children.map(n=>n.y),next=p.tracks[1].children.map(n=>n.y);
p.update(0.4);p.tracks[0].active=false;p.tracks[1].active=true;p.update(0.4);
assert.deepEqual(p.tracks[0].children.map(n=>n.y),old);
assert(p.tracks[1].children.some((n,i)=>n.y!==next[i]));
p.onDisable();assert.deepEqual(p.tracks[1].children.map(n=>n.y),next);
});
test('starting another match disposes the previous roster only at the start of that match',async()=>{
const f=fixture();const task=f.panel.playMatching(f.stage(99));await f.advance();await f.advance();await task;
const clones=f.panel.matchingAvatars.slice(3);assert(clones.every(n=>!n.destroyed));
f.panel.resetMatchingAvatars();assert(clones.every(n=>n.destroyed));
assert.equal(f.panel.matchingAvatars.length,1);assert.equal(f.panel.matchingSlots[0].active,false);
});
test('destroying a matching page cancels the avatar tweens and stops later waves',async()=>{
const f=fixture();const task=f.panel.playMatching(f.stage(99));
f.panel.node.destroy();f.panel.onDestroy();await task;
assert(f.tweens.every(t=>t.stopped));assert.equal(f.panel.matchingCount.string,'1/100');
});
test('success: self jumps first, then survivors jump, then eliminated representatives fall',async()=>{
const f=fixture(),before=f.stage(79,1),after=f.stage(49,2);
const task=f.panel.playElimination({before,after});
assert.equal(f.panel.progressSlots.length,19);assert.equal(f.panel.survivorCount.string,'80/100');
assert(f.panel.progressSlots.every(s=>s.index<80),'earlier eliminated players are absent');
const self=f.panel.progressSlots[0].node;
const [left,right]=f.panel.progressSlots.slice(1,3).map(s=>s.node);
assert(left.x<self.x&&right.x>self.x&&left.y-self.y===8&&right.y-self.y===8);
assert(f.panel.progressSlots.slice(1).every(s=>s.node.zIndex<self.zIndex));
await f.advance();assert.deepEqual(f.tweens.map(t=>t.target),[self]);
await f.advance();assert.equal(f.panel.progressCount.string,'2/5');
assert(f.tweens.every(t=>f.panel.progressSlots.find(s=>s.node===t.target).index<49));
assert(f.tweens.every(t=>!t.actions.some(a=>a.props?.opacity===0)));
const launches=f.tweens.map(t=>t.actions.find(a=>'delay' in a).delay);
assert(launches[2]<launches[1],'nearby avatars launch in overlapping waves, not a uniform queue');
const tilts=f.tweens.map(t=>{const target={};t.actions.find(a=>a.props).options.onUpdate(target,.5);return target.angle;});
assert(tilts.some(n=>n<0)&&tilts.some(n=>n>0),'jump arcs lean in both directions');
assert(Math.max(...tilts.map(Math.abs))>=14,'staggered jumps retain different tilts');
await f.advance();assert(f.tweens.every(t=>t.actions.some(a=>a.props?.opacity===0)));
const drops=f.tweens.map(t=>t.actions.find(a=>a.props?.opacity===0).props);
assert(drops.some(p=>p.angle>0)&&drops.some(p=>p.angle<0),'eliminations tumble in both directions');
const counts=[];let displayed=f.panel.survivorCount.string;
Object.defineProperty(f.panel.survivorCount,'string',{get:()=>displayed,set:value=>{displayed=value;counts.push(Number(value.split('/')[0]));}});
f.tweens.reverse(); // Slower avatars may finish after those which launched later.
assert.equal(self.opacity,255);
await f.advance();assert.equal(f.panel.survivorCount.string,'50/100');
assert(counts.every((n,i)=>!i||n<=counts[i-1]),'out-of-order drops never increase remaining players');
assert(f.panel.progressSlots.filter(s=>s.index>=49).every(s=>!s.node.active));
assert(f.panel.progressSlots.filter(s=>s.index<49).every(s=>s.node.opacity===255));
assert(f.panel.progressSlots.filter(s=>s.index<49).every(s=>s.node.angle===0),'survivors straighten after landing');
await f.advance();assert.equal(await task,true);
});
test('failure: survivors jump first, self falls second, other eliminated players fall last',async()=>{
const f=fixture(),before=f.stage(79,2),after=f.stage(39,2);after.status='lost';after.survivors=39;after.round=3;
const task=f.panel.playElimination({before,after}),self=f.panel.progressSlots[0].node;
await f.advance();assert(f.tweens.length>0);assert(f.tweens.every(t=>t.target!==self));
assert(f.tweens.every(t=>!t.actions.some(a=>a.props?.opacity===0)));
await f.advance();assert.deepEqual(f.tweens.map(t=>t.target),[self]);
assert.equal(f.panel.survivorCount.string,'80/100');
await f.advance();assert.equal(self.opacity,0);assert.equal(f.panel.survivorCount.string,'79/100');
assert(f.tweens.every(t=>t.target!==self&&t.actions.some(a=>a.props?.opacity===0)));
await f.advance();assert.equal(f.panel.survivorCount.string,'39/100');
assert.equal(f.panel.progressCount.string,'2/5','failure does not advance the player score');
const destination=f.panel.cloudPosition(after,3);
assert(f.panel.progressSlots.filter(s=>s.index>=0&&s.index<39).every(s=>s.node.y>=destination.y));
await f.advance();assert.equal(await task,true);
});
test('final win with no eliminations still moves the stack to the reward cloud',async()=>{
const f=fixture(),before=f.stage(49,4),after=f.stage(49,5),task=f.panel.playElimination({before,after});
await f.advance();await f.advance();await f.advance();await f.advance();assert.equal(await task,true);
assert.equal(f.panel.progressCount.string,'5/5');assert.equal(f.panel.survivorCount.string,'50/100');
assert.equal(f.panel.progressSlots[0].node.y,f.panel.cloudPosition(after,5).y);
assert(f.panel.progressSlots.every(s=>s.node.active));
});
test('each stage final round sends survivors to the chest cloud on both win and loss',async()=>{
for(const [stage,target] of [[1,5],[2,7],[3,9]])for(const won of [true,false]){
const f=fixture(),p=f.panel,prize=p.pages[2].getChildByName('PrizeCloud');
const before={...f.stage(20,target-1),stage,target,status:'playing'};
const after={...f.stage(10,won?target:target-1),stage,target,round:target,status:won?'won':'lost',survivors:won?11:10};
const task=p.playElimination({before,after});
for(let i=0;i<10;i++)await f.advance();
assert.equal(await task,true);
const self=p.progressSlots[0].node;
assert.equal(self.active,won);
const landed=p.progressSlots.filter(s=>s.node.active);
assert(landed.length>0);
for(const slot of landed)assert.equal(p.restingAvatars.get(slot.node).cloud,prize);
if(won){assert.equal(self.x,prize.x);assert.equal(self.y,prize.y);}
const ys=landed.map(s=>s.node.y);prize.y+=8;p.followRestingClouds();
landed.forEach((s,i)=>assert.equal(s.node.y,ys[i]+8));
// Reopening a completed stage also uses the reward cloud, with no out-of-range Step access.
if(won){p.prepareProgressRoster(after,[0,1]);assert.equal(p.restingAvatars.get(p.progressSlots[0].node).cloud,prize);}
}
});
test('destroying the panel cancels cloud jumps and leaves presentation unacknowledged',async()=>{
const f=fixture(),task=f.panel.playElimination({before:f.stage(99),after:f.stage(49,1)});
await f.advance();f.panel.node.destroy();f.panel.onDestroy();
assert.equal(await task,false);assert.equal(f.panel.animationWaits.length,0);assert(f.tweens.every(t=>t.stopped));
});
test('failure with no surviving opponents drops self first and finishes at zero',async()=>{
const f=fixture(),before=f.stage(2,0),after=f.stage(0,0);after.status='lost';after.survivors=0;
const task=f.panel.playElimination({before,after}),self=f.panel.progressSlots[0].node;
await f.advance();assert.deepEqual(f.tweens.map(t=>t.target),[self]);
await f.advance();assert.equal(f.panel.survivorCount.string,'2/100');
await f.advance();assert.equal(f.panel.survivorCount.string,'0/100');
await f.advance();assert.equal(await task,true);assert(f.panel.progressSlots.every(s=>!s.node.active));
});
test('all stage timeouts move survivors to the next cloud and drop self plus eliminated opponents',async()=>{
for(const stage of [1,2,3]) for(const score of [0,[5,7,9][stage-1]-1]){
const f=fixture(),before={...f.stage(99,score),stage,target:[5,7,9][stage-1]},
after={...f.stage(49,score),stage,target:before.target,round:score+1,status:'expired',survivors:49};
const task=f.panel.playElimination({before,after}),self=f.panel.progressSlots[0].node;
for(let i=0;i<8;i++)await f.advance();assert.equal(await task,true);
assert.equal(self.opacity,0);assert.equal(f.panel.progressCount.string,score+'/'+before.target);
assert.equal(f.panel.survivorCount.string,'49/100');assert.equal(f.panel.keepEliminationRoster,true);
const cloud=f.panel.cloudForScore(after,score+1);
const survivors=f.panel.progressSlots.filter(s=>s.index>=0&&s.index<49);
const eliminated=f.panel.progressSlots.filter(s=>s.index>=49);
assert(survivors.length>0&&eliminated.length>0);
assert(survivors.every(s=>s.node.opacity===255));assert(eliminated.every(s=>s.node.opacity===0));
assert(survivors.every(s=>f.panel.restingAvatars.get(s.node)?.cloud===cloud));
}
});
test('elimination falls half as far, emits fading smoke, and retains floating survivors on the failure page',async()=>{
const f=floatingFixture(),p=f.panel,Node=p.node.constructor;
f.page.getChildByName('PrizeCloud').component={spriteFrame:{}};
const avatar=new Node();avatar.setPosition(100,200);let done=0;
p.dropAvatar(avatar,0,()=>done++,0);const fall=f.tweens[0].actions.find(a=>a.props?.opacity===0);
assert.equal(fall.props.y,55);f.flushTweens();assert.equal(done,1);assert.equal(avatar.active,false);
const smoke=p.progressRoster.children.filter(n=>n.name==='EliminationSmoke');
assert.equal(smoke.length,5);assert(smoke.every(n=>n.x===55&&n.y===55&&n.opacity===210));
assert(f.tweens.every(t=>t.actions.some(a=>a.props?.opacity===0)));f.flushTweens();assert(smoke.every(n=>n.destroyed));
p.prepareProgressRoster(f.stage(20,2),[0,1],false);p.mode='result';p.keepEliminationRoster=true;
const before=p.progressSlots[0].node.y;p.update(.5);assert.notEqual(p.progressSlots[0].node.y,before);
assert(p.progressSlots.every(s=>s.index>=0&&s.node.active));
});
test('avatar sizes stay fixed while stack spacing follows cloud size and screen edges',()=>{
const f=fixture(),p=f.panel;p.design={width:1080};const stage=f.stage(99,1),cloud=p.tracks[0].children[0];
Object.assign(cloud,{width:240,scaleX:1});p.prepareProgressRoster(stage,Array.from({length:18},(_,i)=>i));
const small=p.progressSlots[0].node.scale;assert(Math.abs(small-.65*.9)<1e-10);
const smallDensity=Math.abs(p.progressSlots[1].node.x-cloud.x)/small;cloud.width=360;p.prepareProgressRoster(stage,Array.from({length:18},(_,i)=>i));
assert.equal(p.progressSlots[0].node.scale,small);
assert(p.progressSlots.slice(1).every(s=>Math.abs(s.node.scale-.52*.9)<1e-10));
assert(Math.abs(p.progressSlots[1].node.x-cloud.x)/p.progressSlots[0].node.scale>smallDensity,'larger clouds have looser spacing relative to avatar size');
const start=p.pages[2].getChildByName('StartingCloud');start.width=900;stage.success_num=0;p.prepareProgressRoster(stage,Array.from({length:18},(_,i)=>i));
assert(p.progressSlots.every(({node:n})=>Math.abs(n.x)+70*n.scale<540));
assert(p.progressSlots.slice(1).every(s=>s.node.zIndex<p.progressSlots[0].node.zIndex));
});
test('all three stages keep avatar sizes from the last step through the chest landing and redraw',async()=>{
for(const [stageIndex,target] of [[1,5],[2,7],[3,9]]){
const f=fixture(),p=f.panel;
const before={...f.stage(18,target-1),stage:stageIndex,target,status:'playing'};
const after={...f.stage(18,target),stage:stageIndex,target,status:'won'};
Object.assign(p.tracks[stageIndex-1].children.at(-1),{width:240,scaleX:1});
Object.assign(p.pages[2].getChildByName('PrizeCloud'),{width:600,scaleX:1});
const task=p.playElimination({before,after});
const sizes=p.progressSlots.map(s=>s.node.scale);
for(let i=0;i<5;i++){
await f.advance();assert.deepEqual(p.progressSlots.map(s=>s.node.scale),sizes);
}
assert.equal(await task,true);
assert.equal(p.restingAvatars.get(p.progressSlots[0].node).cloud,p.pages[2].getChildByName('PrizeCloud'));
p.prepareProgressRoster(after,Array.from({length:18},(_,i)=>i));
assert.deepEqual(p.progressSlots.map(s=>s.node.scale),sizes);
}
});
test('loss settlement prevents duplicate exit actions and checks scene liveness after playback',async()=>{
const source=fs.readFileSync('assets/Script/Map.ts','utf8');
const begin=source.indexOf(' private cloudRiseLossPlaying = false;'),end=source.indexOf(' async againLevel()',begin);
const exports={},calls=[];let resolve,alive=true;
vm.runInNewContext(ts.transpileModule('export default class MapFixture {'+source.slice(begin,end)+'}',
{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,
{exports,cc:{find:()=>({})},CloudRiseService:{finish:won=>calls.push(won)},
CloudRiseView:{playElimination:()=>new Promise(done=>resolve=done)}});
const map=new exports.default();map.isMapRuntimeAlive=()=>alive;
const first=map.settleCloudRiseLoss();assert.equal(await map.settleCloudRiseLoss(),false);
assert.deepEqual(calls,[false]);resolve();assert.equal(await first,true);
const stale=map.settleCloudRiseLoss();alive=false;resolve();assert.equal(await stale,false);
alive=true;resolve=null;
assert.equal(await map.settleCloudRiseLoss(false),true);
assert.equal(resolve,null,'abandon records failure without starting a scene animation');
assert.deepEqual(calls,[false,false,false]);
});
test('victory reveals title and chest together, then coins, then people; close stays locked until complete',async()=>{
const f=fixture(),p=f.panel;
p.victoryGroups=['title','chest','coins','people'].map(name=>({name,scale:1,opacity:255}));
p.buttons=Array.from({length:6},()=>({active:true}));p.errorLabel={node:{active:true}};
let rendered=0;p.render=()=>rendered++;
const task=p.animateVictory();assert.equal(p.busy,true);assert.equal(p.buttons[0].active,false);
for(const names of [['title','chest'],['coins'],['people']]) {
assert.deepEqual(f.tweens.map(t=>t.target.name),names);
assert(p.victoryGroups.filter(g=>names.includes(g.name)).every(g=>g.opacity===0));
await f.advance();assert(p.victoryGroups.filter(g=>names.includes(g.name)).every(g=>g.opacity===255));
}
await task;assert.equal(p.busy,false);assert.equal(rendered,1);
});
test('leaving during victory stops later reveal groups',async()=>{
const f=fixture(),p=f.panel;
p.victoryGroups=['title','chest','coins','people'].map(name=>({name,scale:1,opacity:255}));
p.buttons=Array.from({length:6},()=>({}));p.errorLabel={node:{}};
p.render=()=>assert.fail('destroyed victory must not render');
const task=p.animateVictory();await f.advance();p.node.destroy();p.onDestroy();await task;
assert.equal(p.victoryGroups[2].opacity,0);assert(f.tweens.every(t=>t.stopped));
});
test('main Next and hidden-button guide continuations both wait for activity presentation',async()=>{
const source=fs.readFileSync('assets/Script/Map.ts','utf8');
const begin=source.indexOf(' async winLevel('),end=source.indexOf(' this.clearLayerTwoWinCoinFly();',begin);
let resolve,calls=0;const exports={};
vm.runInNewContext(ts.transpileModule('export default class NextFixture {'+source.slice(begin,end)+'return true;}}',
{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,
{exports,cc:{find:()=>({}),isValid:()=>true},CloudRiseView:{continueFromNextLevel:()=>{calls++;return new Promise(done=>resolve=done);}}});
const map=new exports.default(),control={_touch:true,setTouch(v){this._touch=v;}};
map.isMapRuntimeAlive=()=>true;map.getSettlementNextButton=()=>({getComponent:()=>control});
const first=map.winLevel();assert.equal(control._touch,false);await map.winLevel();assert.equal(calls,1);
resolve(false);await first;assert.equal(control._touch,true);
const retry=map.winLevel();resolve(true);assert.equal(await retry,true);assert.equal(calls,2);
control._touch=true;const guided=map.winLevel(true);assert.equal(calls,3);assert.equal(control._touch,false);
resolve(true);assert.equal(await guided,true);
});
test('passing a level shows the main win popup without automatically presenting CloudRise',()=>{
const source=fs.readFileSync('assets/Script/Map.ts','utf8');
const begin=source.indexOf(' this.check_NewMode();',source.indexOf(' nextLevel('));
const end=source.indexOf(' }, 660);',begin)+' }, 660);'.length;
let callback,shown=0;const context={check_NewMode(){},node:{},showWinWhenReady:()=>shown++};
vm.runInNewContext('(function(){'+source.slice(begin,end)+'}).call(context)',{
context,cc:{isValid:()=>true},setTimeout:fn=>callback=fn,
CloudRiseView:{playElimination:()=>assert.fail('must wait for the Next click')}});
assert.equal(shown,0);callback();assert.equal(shown,1);
});
test('Retry waits for loss presentation before restarting the level',async()=>{
const source=fs.readFileSync('assets/Script/Map.ts','utf8');
const begin=source.indexOf(' async againLevel()'),end=source.indexOf(' if (this.SceneManager',begin);
const exports={},order=[];let release;
const cc={fx:{AudioManager:{_instance:{playEffect(){}}},GameConfig:{GM_INFO:{hp:5}},GameTool:{getUserPowerTime:()=>false}}};
vm.runInNewContext(ts.transpileModule('export default class RetryFixture {'+source.slice(begin,end)+"order.push('restart');}}",
{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,
{exports,cc,order,MapConroler:{_instance:{powerState:false}}});
const map=new exports.default();map.isMapRuntimeAlive=()=>true;map.againCanTouch=true;
map.settleCloudRiseLoss=(play=true)=>{assert.equal(play,true);order.push('animation');return new Promise(done=>release=done);};
const retry=map.againLevel();assert.deepEqual(order,['animation']);release(true);await retry;
assert.deepEqual(order,['animation','restart']);
});
test('Map and pause return-home paths record loss without playing it before scene transition',async()=>{
const mapSource=fs.readFileSync('assets/Script/Map.ts','utf8');
const begin=mapSource.indexOf(' async returnHome(event, customEventData)'),end=mapSource.indexOf(' cc.fx.AudioManager',begin);
let recorded=0;const exports={},MapConroler={};
vm.runInNewContext(ts.transpileModule('export default class ReturnFixture {'+mapSource.slice(begin,end)+'return true;}}',
{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,{exports,MapConroler});
const map=new exports.default();MapConroler._instance=map;
map.settleCloudRiseLoss=async play=>{assert.equal(play,false);recorded++;return true;};
assert.equal(await map.returnHome(null,false),true);assert.equal(recorded,1);
const pauseSource=fs.readFileSync('assets/Script/SceneManager.ts','utf8');
const a=pauseSource.indexOf(' async returnHome()'),b=pauseSource.indexOf(' this.closePause();',a);
const pauseExports={};
vm.runInNewContext(ts.transpileModule('export default class PauseFixture {'+pauseSource.slice(a,b)+'return true;}}}',
{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,{exports:pauseExports,MapConroler});
const pause=new pauseExports.default(),control={_touch:true,setTouch(v){this._touch=v;}};
pause.isPopupRuntimeAlive=()=>true;pause.getDynamicPopup=()=>({getChildByName:()=>({getChildByName:()=>({getComponent:()=>control})})});
assert.equal(await pause.returnHome(),true);assert.equal(recorded,2);assert.equal(control._touch,false);
});
test('the Lose prefab abandon action records cloudRise without animation before Health and prevents repeated clicks',async()=>{
const source=fs.readFileSync('assets/Script/Map.ts','utf8');
const begin=source.indexOf(' async homeBtn('),end=source.indexOf(' //打开健康条',begin);
const exports={},order=[];let resolve;
const tween=()=>({to(){return this;},start(){}});
const cc={isValid:()=>true,tween,fx:{AudioManager:{_instance:{playEffect(){}}},
GameConfig:{GM_INFO:{winStreak:0,otherLevel:0,revive:0}},GameTool:{getUserPowerTime:()=>true,shushu_Track(){}}}};
vm.runInNewContext(ts.transpileModule('export default class AbandonFixture {'+source.slice(begin,end)+'}',
{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,{exports,cc});
const children={Health:{active:false,getChildByName:()=>({})},WinStreak:{active:false}};
const lose={getChildByName:name=>children[name]},root={getChildByName:()=>lose};
const map=new exports.default();map.node={active:true};map.homeCanTouch=true;
map.isMapRuntimeAlive=()=>true;map.getLoseRoot=()=>root;map.hideLoseReasonPanels=()=>order.push('health');map.trackFinishi=()=>{};
map.settleCloudRiseLoss=play=>{assert.equal(play,false);order.push('settle');return new Promise(done=>resolve=done);};
const abandoned=map.homeBtn(null,'time');
assert.deepEqual(order,['settle']);assert.equal(children.Health.active,false);
await map.homeBtn(null,'time');assert.deepEqual(order,['settle']);
resolve(true);await abandoned;assert.deepEqual(order,['settle','health']);assert.equal(children.Health.active,true);
// Merely showing revival choices must not call the final-failure path.
map.homeCanTouch=true;map.reviewState=true;await map.homeBtn(null,'time');assert.deepEqual(order,['settle','health']);
const prefab=JSON.parse(fs.readFileSync('assets/lose/prefab/Lose.prefab'));
assert(prefab.filter(o=>o.handler==='homeBtn').length>5,'all reason panels share the real abandon handler');
});
test('Help TA confirmation waits for CloudRise, prevents duplicate scheduling and restores buttons on failure',async()=>{
const source=fs.readFileSync('assets/Script/Map.ts','utf8');
const begin=source.indexOf(' async winLevel('),end=source.indexOf(' this.clearLayerTwoWinCoinFly();',begin);
const confirmStart=source.indexOf(' onNewModeConfirmed()'),confirmEnd=source.indexOf(' // 点击新功能弹窗的返回按钮',confirmStart);
let release,calls=0,settlements=0;const scheduled=[],exports={};
const cc={find:()=>({}),isValid:()=>true};
vm.runInNewContext(ts.transpileModule('export default class MapFixture {'+source.slice(confirmStart,confirmEnd)+source.slice(begin,end)+'this.transitioned=true;return true;}}',
{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,
{exports,cc,CloudRiseView:{continueFromNextLevel:()=>{calls++;return new Promise(done=>release=done);}}});
const map=new exports.default(),control={_touch:true,setTouch(v){this._touch=v;}};
const next={active:true,getComponent:()=>control},home={active:true};
map.gameOverNode={active:false,getChildByName:()=>home};map.getSettlementNextButton=()=>next;
map.getNewModeRoot=()=>null;map.isMapRuntimeAlive=()=>true;map.releaseNewModePanel=()=>{};
map.playWinSettlement=()=>settlements++;map.scheduleOnce=fn=>scheduled.push(fn);
// Execute the actual NewMode confirmation button rather than invoking winLevel directly.
const modeExports={};
vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/NewMode/script/NewMode.ts','utf8'),
{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017,experimentalDecorators:true}}).outputText,
{exports:modeExports,cc:{_decorator:{ccclass:c=>c,property:(...args)=>args.length>1?undefined:()=>{}},Component:class{},Label:class{},
fx:{AudioManager:{_instance:{playEffect(){}}}}}});
const mode=new modeExports.default();mode.node={active:true,emit:name=>{assert.equal(name,'new-mode-confirmed');map.onNewModeConfirmed();}};
let pending;const continueLevel=map.winLevel.bind(map);map.winLevel=automatic=>pending=continueLevel(automatic);
mode.clickBtn();map.onNewModeConfirmed();assert.equal(scheduled.length,1);assert.equal(settlements,1);
assert.equal(home.active,false);assert.equal(next.active,false);assert.equal(calls,0);
scheduled.shift()();assert.equal(calls,1);assert.equal(map.transitioned,undefined);
release(false);await pending;
assert.equal(next.active,true);assert.equal(home.active,true);assert.equal(control._touch,true);
assert.equal(map.autoNextAfterNewMode,false);assert.equal(map.transitioned,undefined);
const retry=map.winLevel();await map.winLevel();assert.equal(calls,2);
release(true);await retry;assert.equal(map.transitioned,true);assert.equal(settlements,1);
});
test('maximum-level confirmation uses the same continuation gate',async()=>{
const source=fs.readFileSync('assets/Script/Map.ts','utf8');
const begin=source.indexOf(' winLevel2()'),end=source.indexOf(' // ============================================',begin);
for(const atMaximum of [false,true]) {
let stored='',calls=0;const exports={};
vm.runInNewContext(ts.transpileModule('export default class MaxFixture {'+source.slice(begin,end)+'}',
{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,
{exports,cc:{fx:{StorageMessage:{getStorage:()=>stored,setStorage:(key,value)=>stored=value},GameTool:{maxLevel:()=>atMaximum}}}});
const map=new exports.default();map.isMapRuntimeAlive=()=>true;map.setWinSpecialTipsVisible=()=>{};
map.winLevel=()=>calls++;map.winLevel2();if(atMaximum){assert.equal(calls,0);map.winLevel2();}
assert.equal(calls,1);
}
});
test('promotion follows one smooth curve bounded just above its destination and lands straight',()=>{
const f=fixture();
for(const [x,y] of [[250,500],[-220,100],[0,0]])for(let variation=-1;variation<5;variation++){
const avatar=new f.cc.Node();avatar.x=0;avatar.y=0;
f.panel.jumpAvatar(avatar,{x,y},.1,{y},variation);
const t=f.tweens.pop(),motion=t.actions.filter(a=>a.props);assert.equal(motion.length,1);
assert(!('scale' in motion[0].props),'jumping never changes avatar size');
const points=[];for(let i=0;i<=100;i++){const n={};motion[0].options.onUpdate(n,i/100);points.push(n);}
assert.equal(points[0].x,0);assert.equal(points[0].y,0);
assert.equal(points.at(-1).x,x);assert.equal(points.at(-1).y,y);assert(Math.abs(points.at(-1).angle)<1e-10);
assert(Math.max(...points.map(n=>n.y))<=Math.max(0,y)+42);
const speeds=points.slice(1).map((p,i)=>Math.hypot(p.x-points[i].x,p.y-points[i].y));
assert(speeds[0]<1&&speeds.at(-1)<1,'soft takeoff and landing');
}
});