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{ 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.xself.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.zIndexn.scale({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=ii)); 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.xself.x&&left.y>self.y&&left.y-self.y<=8&&right.y===left.y); assert(f.panel.progressSlots.slice(1).every(s=>s.node.zIndext.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]{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{ 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{ 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'); } });