MatchMaster/tools/test-cloud-rise-animations.cjs
2026-09-24 16:50:43 +08:00

697 lines
48 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 retains simulated avatars and appends exactly the remaining real profiles',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);
f.panel.addSimulatedMatch();f.panel.mode='matching';f.panel.matchPhase='submitting';
f.panel.update(1.61);f.flushTweens();
assert.equal(f.panel.matchingCount.string,'6/100');
const simulated=f.panel.matchingAvatars.slice();
const simulatedPoses=simulated.map(n=>({x:n.x,y:n.y,scale:n.scale,z:n.zIndex}));
assert(simulated.slice(1).every(n=>Number(n.player.useravatarIcon)>=0&&Number(n.player.useravatarIcon)<=16));
f.panel.matchPhase='animating';
const task=f.panel.playMatching(stage);
for(let i=0;i<100;i++)await f.advance();await task;
assert.equal(f.panel.matchingAvatars.length,100);assert.equal(f.panel.pool,23456);
assert.equal(new Set(f.panel.matchingAvatars).size,100);
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,'99/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,0.65*0.9);
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,100,'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');
assert.deepEqual(f.panel.matchingAvatars.slice(0,6),simulated);
assert.deepEqual(simulated.map(n=>({x:n.x,y:n.y,scale:n.scale,z:n.zIndex})),simulatedPoses,
'switching to real profiles must not reposition simulated arrivals');
for(let i=6;i<100;i++)assert.equal(bindings[i],stage.opponents[i-6]);
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('matching and all three starting clouds use identical 100-avatar geometry within the 1.2x bounds',()=>{
const f=fixture(),p=f.panel;p.design={width:1080};p.resetMatchingAvatars();
for(let i=0;i<99;i++)p.appendMatchingAvatar({username:'player'+i});f.flushTweens();
const matched=p.matchingAvatars.map(n=>({x:n.x,y:n.y+760,scale:n.scale,z:n.zIndex}));
for(let stageNumber=1;stageNumber<=3;stageNumber++){
const prefab=JSON.parse(fs.readFileSync('assets/cloud_rise/stage'+stageNumber+'/prefab/CloudRise.prefab'));
const cloud=p.pages[2].getChildByName('StartingCloud');
const authoredCloud=prefab.find(n=>n._name==='StartingCloud'),start=prefab.find(n=>n._name==='ProgressStart');
cloud.width=authoredCloud._contentSize.width;cloud.scaleX=authoredCloud._trs.array[7];
[cloud.x,cloud.y]=authoredCloud._trs.array;
[p.progressStart.x,p.progressStart.y]=start._trs.array;
const stage={...f.stage(99),stage:stageNumber};
p.prepareProgressRoster(stage,Array.from({length:99},(_,i)=>i));
assert.equal(p.progressSlots.length,100);
p.progressSlots.forEach(({node:n},i)=>{
const m=matched[i];assert(Math.abs(n.x-p.progressStart.x-m.x)<0.001);assert(Math.abs(n.y-p.progressStart.y-m.y)<0.001);
assert.equal(n.scale,m.scale);assert.equal(n.zIndex,m.z);
});
for(const alive of [99,56,18,2,0]){
stage.opponents.forEach((o,i)=>o.alive=i<alive);
p.prepareProgressRoster(stage,Array.from({length:alive},(_,i)=>i));
const slots=p.progressSlots.map(s=>s.node),bounds=slots.map(n=>({left:n.x-n.scale*70,right:n.x+n.scale*70,bottom:n.y-n.scale*70,top:n.y+n.scale*70}));
assert(Math.max(...bounds.map(b=>b.right))-Math.min(...bounds.map(b=>b.left))<=409.281);
assert(Math.max(...bounds.map(b=>b.top))-Math.min(...bounds.map(b=>b.bottom))<=241.601);
assert(bounds.every(b=>b.right<=540-24));
assert(slots.every((n,i)=>n.scale===(i===0?0.65:0.52)*0.9));
}
}
});
test('each matching slot is reserved once for 100 players and new arrivals never move settled avatars',()=>{
const f=fixture(),p=f.panel;
const calculate=p.matchingPositions.bind(p);let calculated=0;
p.matchingPositions=()=>{calculated++;return calculate();};
p.resetMatchingAvatars();f.flushTweens();assert.equal(p.matchingLayout.length,99);
for(let i=0;i<99;i++){
const existing=p.matchingAvatars.map(n=>({x:n.x,y:n.y,scale:n.scale,z:n.zIndex}));
p.appendMatchingAvatar({username:'player'+i});
assert(f.tweens.every(t=>t.target===p.matchingAvatars.at(-1)), 'only the arriving avatar may animate');
f.flushTweens();
assert.deepEqual(p.matchingAvatars.slice(0,-1).map(n=>({x:n.x,y:n.y,scale:n.scale,z:n.zIndex})),existing);
const arrived=p.matchingAvatars.at(-1),pose=p.matchingLayout[i];
assert.equal(arrived.x,pose.x);assert.equal(arrived.y,pose.y);assert.equal(arrived.scale,pose.scale);
}
assert.equal(calculated,1);
});
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));for(let i=0;i<101;i++)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,'2/100');
});
test('simulated arrivals add five per two seconds and never finish without the server',()=>{
const f=fixture(),p=f.panel;p.resetMatchingAvatars();p.addSimulatedMatch();
p.mode='matching';p.matchPhase='submitting';
assert.equal(p.matchingCount.string,'2/100');
p.update(2.001);assert.equal(p.matchingCount.string,'7/100');
p.update(100);assert.equal(p.matchingCount.string,'99/100');assert.equal(p.matchingAvatars.length,99);
p.update(100);assert.equal(p.matchingAvatars.length,99);
p.matchPhase='idle';p.update(10);assert.equal(p.matchingAvatars.length,99);
});
test('success: self jumps first, then all survivors jump, then all eliminated players 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,80);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&&left.y-self.y<=8&&right.y===left.y);
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('loss and expiry show failure text immediately while avatar motion still completes',async()=>{
for(const status of ['lost','expired','playing']){
const f=fixture(),p=f.panel,before=f.stage(8,1),after={...f.stage(4,1),status,round:2};
p.failureMessage={active:false};p.progressStatsContents=[{active:true},{active:true}];
const task=p.playElimination({before,after}),failed=status!=='playing';
assert.equal(p.failureMessage.active,failed,'text is correct before the first animation wait');
assert(p.progressStatsContents.every(n=>n.active===!failed));
assert.equal(p.busy,true,'Continue still waits for avatar movement');
for(let i=0;i<8;i++)await f.advance();
assert.equal(await task,true);assert.equal(p.failureMessage.active,failed);
assert.equal(p.keepEliminationRoster,failed);
if(failed)assert.equal(p.progressSlots[0].node.active,false,'the player still falls off the cloud');
}
});
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'));
const landed=p.progressSlots.map(s=>({x:s.node.x,y:s.node.y}));
p.prepareProgressRoster(after,Array.from({length:18},(_,i)=>i));
assert.deepEqual(p.progressSlots.map(s=>s.node.scale),sizes);
assert.deepEqual(p.progressSlots.map(s=>({x:s.node.x,y:s.node.y})),landed,'redraw must use the same final landing positions');
}
});
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=>new f.cc.Node(name));
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=>new f.cc.Node(name));
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('win return-home buttons wait for CloudRise Continue before navigating, and allow retry if presentation fails',async()=>{
for(const route of ['Map','SceneManager']) {
const source=fs.readFileSync('assets/Script/'+route+'.ts','utf8');
const method=route==='Map'?'returnHome':'returnHomeScene';
const begin=source.indexOf(' async '+method+'(');
const end=source.indexOf(route==='Map'?' cc.fx.AudioManager':' cc.fx.GameConfig.locatePositionOnHome',begin);
assert(begin>=0 && end>begin);
const exports={},order=[],MapConroler={};let release,alive=true;
vm.runInNewContext(ts.transpileModule('export default class HomeFixture {'+source.slice(begin,end)+"order.push('transition/home');return true;}}",
{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,
{exports,order,MapConroler,cc:{find:()=>({})},CloudRiseView:{continueFromNextLevel:()=>{
order.push('settlement');return new Promise(done=>release=done);
}}});
const view=new exports.default();view.gameWin=true;MapConroler._instance=view;
view.isMapRuntimeAlive=view.isPopupRuntimeAlive=()=>alive;
view.settleCloudRiseLoss=()=>assert.fail('a won level must not record a loss');
const first=view[method](null,'');
assert.deepEqual(order,['settlement']);
await view[method](null,'');assert.deepEqual(order,['settlement'],'ignore duplicate taps while waiting');
release(false);await first;assert.equal(view.returningHome,false);
const retry=view[method](null,'');assert.deepEqual(order,['settlement','settlement']);
release(true);assert.equal(await retry,true);
assert.deepEqual(order,['settlement','settlement','transition/home']);
await view[method](null,'');assert.equal(order.length,3,'ignore duplicate taps during transition');
view.returningHome=false;
const destroyed=view[method](null,'');alive=false;release(true);await destroyed;
assert.equal(order.at(-1),'settlement','do not navigate from a destroyed scene');
}
});
test('victory chest sparkles and reward glow pulse while coins stay opaque, then restore on exit',async()=>{
const f=fixture(),p=f.panel;
p.victoryGroups=['title','chest','coins','people'].map(name=>new f.cc.Node(name));
const chest=new f.cc.Node('Chest'),sparkles=new f.cc.Node('ChestSparkles');
const star=new f.cc.Node('Stars'),glow=new f.cc.Node('Glow'),coins=new f.cc.Node('Coins');
star.opacity=220;glow.opacity=190;
p.victoryGroups[1].addChild(chest);chest.addChild(sparkles);sparkles.addChild(star);
p.victoryGroups[2].addChild(glow);p.victoryGroups[2].addChild(coins);
p.buttons=Array.from({length:6},()=>({}));p.errorLabel={node:{}};p.render=()=>{};
p.mode='result';p.victoryPage={active:true};
const task=p.animateVictory();
const authoredChildren=sparkles.children.length;
p.update(0.3);
assert(star.opacity<220);assert(glow.opacity<190);assert.equal(coins.opacity,255);
assert.equal(sparkles.children.length,authoredChildren,'collecting lights must not change the hierarchy');
await f.advance();await f.advance();await f.advance();await task;
p.mode='overview';p.update(0.1);
assert.equal(star.opacity,220);assert.equal(glow.opacity,190);assert.equal(p.victorySparkles.length,0);
});
test('Map and pause return-home paths play loss 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;
let release,finished=false;
map.settleCloudRiseLoss=play=>{assert.equal(play,true);recorded++;return new Promise(resolve=>release=resolve);};
const returning=map.returnHome(null,false).then(result=>{finished=true;return result;});
await Promise.resolve();assert.equal(finished,false);assert.equal(recorded,1);
release(true);assert.equal(await returning,true);
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})})});
finished=false;
const exiting=pause.returnHome().then(result=>{finished=true;return result;});
await Promise.resolve();assert.equal(finished,false);assert.equal(recorded,2);assert.equal(control._touch,false);
release(true);assert.equal(await exiting,true);
});
test('overview opens with the monthly-card bounce and closes after its reverse animation',()=>{
const f=fixture(),p=f.panel;p.design=new f.cc.Node();p.background=new f.cc.Node();p.popupScale=0.37;
p.openPopup();assert.equal(p.popupAnimating,true);assert.equal(p.design.scale,0.37/7);
const open=f.tweens.find(t=>t.target===p.design);
assert.deepEqual(Array.from(open.actions.filter(a=>a.props),a=>[a.duration,a.props.scale]),[
[0.08,0.37*8/7],[0.08,0.37*6/7],[0.08,0.37]]);
f.flushTweens();assert.equal(p.design.scale,0.37);assert.equal(p.popupAnimating,false);
let closed=0;p.closePopup(()=>closed++);p.closePopup(()=>closed++);
assert.equal(closed,0);assert.equal(p.popupAnimating,true);
const close=f.tweens.find(t=>t.target===p.design);
assert(close.actions[0].props.scale>p.popupScale);assert(close.actions[1].props.scale<p.popupScale);
f.flushTweens();assert.equal(closed,1);assert.equal(p.design.opacity,0);
});
test('overview stars pulse, victory rays loop without rotating coins, and both reset on exit',async()=>{
const f=fixture(),p=f.panel,Node=f.cc.Node;
const overview=new Node(),top=new Node('PanelTop'),lights=new Node('ChestSparkles'),star=new Node('Stars');
overview.addChild(top);top.addChild(lights);lights.addChild(star);star.opacity=200;p.pages[0]=overview;
p.update(0.3);assert(star.opacity<200);p.mode='matching';p.update(0.1);assert.equal(star.opacity,200);
p.victoryGroups=Array.from({length:4},()=>new Node());
const rays=new Node('Rays'),coin=new Node('Coins');rays.angle=15;coin.angle=0;
p.victoryGroups[2].addChild(rays);p.victoryGroups[2].addChild(coin);
p.buttons=Array.from({length:6},()=>({}));p.errorLabel={node:{}};p.render=()=>{};
p.mode='result';p.victoryPage={active:true};const task=p.animateVictory();
p.update(6);assert.equal(rays.angle,195);assert.equal(coin.angle,0);
p.update(6);assert.equal(rays.angle,15);
await f.advance();await f.advance();await f.advance();await task;
p.mode='progress';p.update(0.1);assert.equal(rays.angle,15);assert.equal(p.victoryRays,null);
});
test('only the current badge has one reusable white-gold halo with a 20px bright rim',()=>{
const f=fixture(),p=f.panel,badge=new f.cc.Node('Badge1');badge.width=228;badge.height=223;
const strokes=[];
f.cc.Node.prototype.addComponent=function(){return {ellipse(x,y,rx,ry){assert.equal(rx,120);assert.equal(ry,117.5);},stroke(){strokes.push(this.lineWidth);}};};
p.highlightStage(badge,true);assert.deepEqual(strokes,[31,25,20]);assert.equal(badge.children.length,1);
p.highlightStage(badge,false);assert.equal(badge.children[0].active,false);
p.highlightStage(badge,true);assert.equal(badge.children.length,1);assert.equal(badge.children[0].active,true);
});
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');
}
});