Files
MatchMaster/tools/test-start-boosters.cjs
T
2026-10-16 16:24:28 +08:00

577 lines
37 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
// Reuse the real-prefab Cocos fixture without registering the older suite's tests.
const support = { exports: {} };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, 'test-rainbow-streak.cjs'), 'utf8')
+ '\nmodule.exports = { load, method, rules, engine, block, geometry, cloneNode, cutFixture, controllerFixture };', {
require: name => name === 'node:test' ? () => {} : require(name),
module: support, __dirname, console, process, Set, Math,
});
const { load, method, rules, engine, block, geometry, cloneNode, cutFixture, controllerFixture } = support.exports;
const rocket = load('assets/Script/RocketRules.ts', engine, { './RainbowRules': rules });
const loadoutRules = load('assets/Script/StartBoostLoadout.ts', engine, {});
const positions = cells => Array.from(cells, p => p.x + ',' + p.y).sort();
const makeMap = blocks => ({ blocks: blocks.map(b => b.node), buttonBlock: [], wallArray: [] });
test('every cell in all 23 shapes can be cut; fragments retain every other cell exactly once', () => {
for (let shape = 0; shape < 23; shape++) {
const cells = rules.RAINBOW_SHAPES[shape], cuts = rocket.ROCKET_CUTS[shape];
assert.equal(cuts.length, cells.length);
for (const cut of cuts) {
const remaining = cut.fragments.flatMap(f => rules.RAINBOW_SHAPES[f.shape].map(p => ({ x:p.x+f.offset.x,y:p.y+f.offset.y })));
assert.deepEqual(positions([...remaining, cut.cell]), positions(cells));
assert.equal(new Set(positions(remaining)).size, cells.length - 1);
}
assert.ok(rocket.rocketCuts(cells, true).every(cut => cut.fragments.length === 1));
}
assert.ok(rocket.ROCKET_CUTS[18].some(cut => cut.fragments.length === 4));
assert.equal(rocket.rocketCuts(rules.RAINBOW_SHAPES[0], true).length, 0);
});
test('ordinary, covered/mechanism and singleton tiers; edge protection also excludes special singletons', () => {
const plain = block(5), floor = block(5, 0, {floor:1}), question = block(5,16), single = block(0);
const map = makeMap([plain,floor,question,single]);
assert.deepEqual(Array.from(rocket.rocketCandidates(map), c => c.priority), [0,1,1,2]);
for (const type of [1,2,3,4,5,6,7,8,12,13,14,17,18,20,21]) {
const special = block(3,type);
const candidates = rocket.rocketCandidates(makeMap([special]));
assert.ok(candidates[0].cuts.every(c => c.fragments.length === 1));
assert.equal(rocket.rocketCandidates(makeMap([block(0,type)])).length, 0);
}
const door = {special:2,color:2,colorStartArray:[]};
map.wallArray = [{getChildByName:()=>({getComponent:()=>door})}];
assert.equal(rocket.rocketCandidates(map).some(c=>c.block===single),false);
door.special=0; door.color=7; door.colorStartArray=[2,3];
assert.equal(rocket.rocketCandidates(map).some(c=>c.block===single),false);
map.wallArray=[]; map.buttonBlock=[{getComponent:()=>({color:2})}];
assert.equal(rocket.rocketCandidates(map).length,0);
const glue=block(5,9), partner=block(5); glue.block_Info.node=partner.node;
assert.equal(rocket.rocketCandidates(makeMap([glue,partner])).length,0);
const stack=block(5,1), upper=block(5,10); upper.color=7; stack.block_Info.node=upper.node;
const stacked=makeMap([stack,upper]); stacked.buttonBlock=[{getComponent:()=>({color:7})}];
assert.equal(rocket.rocketCandidates(stacked).length,0);
});
test('pool recalculation precedes distinct-origin preference; repeated hits stay possible', () => {
const a=block(5), b=block(5), mech=block(5,2), single=block(0);
const map=makeMap([a,b,mech,single]), hit=new Set([a.node.uuid]);
assert.equal(rocket.chooseRocketTarget(rocket.rocketCandidates(map),hit,3,()=>0).target.block,b);
hit.add(b.node.uuid);
assert.equal(rocket.chooseRocketTarget(rocket.rocketCandidates(map),hit,3,()=>0).target.block,a);
map.blocks=[mech.node,single.node];
assert.equal(rocket.chooseRocketTarget(rocket.rocketCandidates(map),hit,2,()=>0).target.block,mech);
});
function rocketFixture(shape,type=0) {
const f=cutFixture(shape,type);
const cc={...engine, instantiate(source) {
if(source.prefabShape===undefined) return cloneNode(source);
const extra=geometry(source.prefabShape);
extra.init=function(info) {
Object.assign(this,{block_Info:info,blockId:info.id,color:info.color,type:info.type});
this.initBlocks(); this.posX=(this.node.x-65)/120;this.posY=(this.node.y+60)/120;
this.setMapBlock=place;place.call(this);
if(info.type===16)this.node.addChild(cover('question',info.questionTime));
if(info.floor)for(const p of this.allBlocks){const floor=cover('floor',info.floorTime);floor.setPosition(-65+p.x*120,60+p.y*120);this.node.addChild(floor);}
};
return extra.node;
}};
function place(){this.allBlocks.forEach(p=>Object.assign(f.map.mapBlocksWall[this.posX+p.x][this.posY+p.y].components.MapBlock,{block_Id:this.node.uuid,nowColor:this.color}));}
f.apply=load('assets/Script/RocketCut.ts',cc,{'./RainbowCut':f.cut,'./RainbowRules':rules,'./RocketRules':rocket}).applyRocketCut;
return f;
}
function cover(name,time){
const node=new engine.Node(name), component=name==='floor'?'Floor':'Question';
node.components[component]={time,init(value,x,y){this.time=value;this.posX=x;this.posY=y;}};
const icon=new engine.Node('icon');icon.addComponent(engine.Sprite);node.addChild(icon);node.addChild(new engine.Node('time'));
return node;
}
test('actual prefab cuts, including 2/3/4-way splits, leave correct independent colliders and occupancy without elimination events',()=>{
for(let shape=0;shape<23;shape++)for(const cut of rocket.ROCKET_CUTS[shape]){
const f=rocketFixture(shape), original=f.b.node.uuid;
const expected=positions(f.b.allBlocks.filter(p=>p.x!==cut.cell.x||p.y!==cut.cell.y).map(p=>({x:p.x+5,y:p.y+5})));
f.apply(f.map,f.b,cut);
const actual=[];
for(const node of f.map.blocks){
const b=node.getComponent('Block');
assert.equal(b.color,2);
assert.equal(b.rocketOrigin,original);
for(const p of b.allBlocks){
const x=b.posX+p.x,y=b.posY+p.y;actual.push({x,y});
assert.equal(f.map.mapBlocksWall[x][y].components.MapBlock.block_Id,node.uuid);
}
assert.deepEqual(positions(node.getComponent(engine.PolygonCollider).points),positions(geometry(b.block_Info.block).node.getComponent(engine.PolygonCollider).points));
}
assert.deepEqual(positions(actual),expected);
assert.equal(f.map.mapBlocksWall[5+cut.cell.x][5+cut.cell.y].components.MapBlock.block_Id,'');
assert.equal(f.map.blocks.length,cut.fragments.length);
assert.equal(f.effects(),0);
}
});
test('1932 square can take three sequential edge hits and retains one ordinary cell',()=>{
const f=rocketFixture(5),hit=new Set(); Object.assign(f.map,{buttonBlock:[],wallArray:[]});
for(let left=3;left>0;left--){
const choice=rocket.chooseRocketTarget(rocket.rocketCandidates(f.map),hit,left,()=>0.8);
assert.ok(choice);assert.equal(choice.cut.fragments.length,1);
f.apply(f.map,choice.target.block,choice.cut);hit.add(choice.target.origin);
}
assert.equal(f.map.blocks.length,1);assert.equal(f.b.allBlocks.length,1);assert.equal(f.effects(),0);
});
test('question and stationary floor shells follow all four remnants and preserve current counters',()=>{
const f=rocketFixture(18,16);
Object.assign(f.b.block_Info,{floor:3,floorTime:99,questionTime:99});
f.b.node.addChild(cover('question',4));
for(const p of f.b.allBlocks){const floor=cover('floor',7);floor.setPosition(-65+p.x*120,60+p.y*120);f.b.node.addChild(floor);}
f.apply(f.map,f.b,rocket.ROCKET_CUTS[18].find(c=>c.fragments.length===4));
assert.equal(f.map.blocks.length,4);
for(const node of f.map.blocks){
const b=node.getComponent('Block'),q=node.getChildByName('question');
assert.equal(q.getComponent('Question').time,4);
const floors=node.children.filter(n=>n.getComponent('Floor'));
assert.equal(floors.length,1);assert.equal(floors[0].getComponent('Floor').time,7);
assert.equal(floors[0].getComponent('Floor').posX,b.posX);
assert.equal(floors[0].getComponent('Floor').posY,b.posY);
}
assert.equal(f.effects(),0);
});
test('a stack keeps both layers aligned after the same edge cell is removed',()=>{
const f=rocketFixture(5,1),upper=geometry(5);upper.type=10;upper.block_Info.type=10;
upper.block_Info.node=f.b.node;f.b.block_Info.node=upper.node;f.map.node.addChild(upper.node);f.map.blocks.push(upper.node);
const cut=rocket.ROCKET_CUTS[5][0];f.apply(f.map,f.b,cut);
assert.equal(f.b.allBlocks.length,3);assert.equal(upper.allBlocks.length,3);
assert.equal(upper.block_Info.node,f.b.node);assert.equal(f.b.block_Info.node,upper.node);
assert.equal(upper.block_Info.block,f.b.block_Info.block);assert.equal(upper.posX,f.b.posX);assert.equal(upper.posY,f.b.posY);
assert.equal(f.effects(),0);
});
test('simultaneous shot planning leaves the board untouched and all planned cuts remain legal after earlier impacts',()=>{
for(let shape=0;shape<23;shape++)for(const value of [0,0.37,0.99]){
const f=rocketFixture(shape);const before=positions(f.b.allBlocks);
const shots=rocket.planRocketShots(f.map,3,()=>value);
assert.deepEqual(positions(f.b.allBlocks),before);assert.equal(f.map.blocks.length,1);
assert.equal(new Set(shots.map(s=>s.x+','+s.y)).size,shots.length);
const hit=new Set();
for(let i=0;i<shots.length;i++){
const shot=shots[i],id=f.map.mapBlocksWall[shot.x][shot.y].components.MapBlock.block_Id;
const target=f.map.blocks.find(n=>n.uuid===id).getComponent('Block');
const pool=rocket.rocketCandidates(f.map),candidate=pool.find(c=>c.block===target);
assert.ok(candidate);assert.equal(candidate.priority,Math.min(...pool.map(c=>c.priority)));
assert.equal(shot.origin,candidate.origin);
if(pool.some(c=>c.priority===candidate.priority&&!hit.has(c.origin)))assert.equal(hit.has(candidate.origin),false);
assert.ok(candidate.cuts.some(c=>JSON.stringify(c)===JSON.stringify(shot.cut)));
hit.add(candidate.origin);f.apply(f.map,target,shot.cut);
}
assert.equal(f.map.blocks.reduce((sum,n)=>sum+n.getComponent('Block').allBlocks.length,0),before.length-shots.length);
assert.equal(f.effects(),0);
}
// 强制先拆开 L 形,再命中其两格残块,最后转入单格池。
const f=rocketFixture(6),others=[block(0),block(0)];
others.forEach((b,i)=>{b.posX=1;b.posY=1+i;f.map.mapBlocksWall[1][1+i].components.MapBlock.block_Id=b.node.uuid;});
f.map.blocks.push(...others.map(b=>b.node));
const center=rocket.ROCKET_CUTS[6].findIndex(c=>c.fragments.length===2);
const randoms=[0,(center+0.1)/4,0,0,0,0];
const shots=rocket.planRocketShots(f.map,3,()=>randoms.shift());
assert.equal(shots[0].cut.fragments.length,2);
assert.equal(shots.length,3);
for(const shot of shots){
const id=f.map.mapBlocksWall[shot.x][shot.y].components.MapBlock.block_Id;
const target=f.map.blocks.find(n=>n.uuid===id).getComponent('Block');
assert.ok(rocket.rocketCandidates(f.map).find(c=>c.block===target).cuts.some(c=>JSON.stringify(c)===JSON.stringify(shot.cut)));
f.apply(f.map,target,shot.cut);
}
assert.equal(f.map.blocks.reduce((sum,n)=>sum+n.getComponent('Block').allBlocks.length,0),3);
});
function openingFixture(f=rocketFixture(5),random=()=>0.8,loadout={streak:3,hourglass:true,rocket:true}) {
engine.Node.prototype.setScale=function(x,y=x){this.scaleX=x;this.scaleY=y;};
Object.defineProperty(engine.Node.prototype,'childrenCount',{get(){return this.children.length;},configurable:true});
// zhandan.json 的真实 eff 长 1.4 秒,避免短 Mock 漏掉爆炸被提前截断的问题。
class Label{};class LabelOutline{};class Skeleton{constructor(){this.track={animation:{duration:1.4},trackTime:0};this.setAnimation=()=>this.track;}}
class OpeningNode extends engine.Node {
convertToNodeSpaceAR(p){return engine.v2(p.x-this.x,p.y-this.y);}
}
const canvas=new engine.Node('Canvas'),events=[],displayed=[],impacts=[],usedCarried=[];
Object.assign(f.map,{wallArray:[],buttonBlock:[],timeLabel:new engine.Node('time'),timeNumber:120,
addTime:method('assets/Script/Map.ts','addTime',{NumberToImage:{getTimeMargi3:n=>displayed.push(n)}}),Block_Prop:[]});
const addTime=f.map.addTime;
f.map.addTime=function(n,refresh){events.push('time');addTime.call(this,n,refresh);};
const top=new engine.Node('Top'),clock=new engine.Node('time_icon');
canvas.addChild(top);top.addChild(f.map.timeLabel);top.addChild(clock);clock.setPosition(-120,620);
clock.convertToWorldSpaceAR=()=>engine.v2(-120,620);
f.map.timeLabel.setPosition(0,600);
for(const name of ['hourglass','rocket','rocketBox'])f.map[name+'Prefab']=new engine.Node(name);
f.map.hourglassPrefab.setContentSize(228,322);
f.map.rocketPrefab.setContentSize(212,361);
const boxFrames=[null,...[[336,329],[406,378],[483,436]].map(([width,height])=>({getRect:()=>({width,height})}))];
f.map.rocketBoxPrefab.setContentSize(483,436);
f.map.rocketBoxPrefab.addComponent(engine.Sprite).spriteFrame=boxFrames[3];
f.map.SceneManager={getStartBoostBoxFrame:streak=>boxFrames[streak]};
const bomb=new engine.Node('boom'),effect=new engine.Node('zhandan');effect.addComponent(Skeleton);bomb.addChild(effect);f.map.Block_Prop[6]=bomb;
for(const column of f.map.mapBlocksWall)for(const cell of column)
cell.convertToWorldSpaceAR=p=>engine.v2(cell.x+p.x,cell.y+p.y);
const listeners={};
const cc={...engine,Node:OpeningNode,macro:{...engine.macro,ONE:1},Label,LabelOutline,winSize:{width:750,height:1334},find:()=>canvas,instantiate(source){
const node=cloneNode(source);
node.convertToWorldSpaceAR=p=>engine.v2(node.x+p.x*node.scaleX+(node.parent?.x||0),
node.y+p.y*node.scaleY+(node.parent?.y||0));
return node;
},
game:{EVENT_HIDE:'hide',EVENT_SHOW:'show',on:(event,fn)=>listeners[event]=fn,off:event=>delete listeners[event]}};
const module={exports:{}};
const ts=require('typescript');
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(__dirname,'../assets/Script/StartBoostOpening.ts'),'utf8'),
{compilerOptions:{target:ts.ScriptTarget.ES2017,module:ts.ModuleKind.CommonJS}}).outputText,
{module,exports:module.exports,cc,sp:{Skeleton},require:name=>name==='./RocketRules'?{...rocket,
planRocketShots:(map,count)=>rocket.planRocketShots(map,count,random)}:
name==='./StartBoostLoadout'?loadoutRules:
name==='./NumberToImage'?{default:{getTimeMargi3:n=>displayed.push(n)}}:
{applyRocketCut(map,target,cut){
const candidate=rocket.rocketCandidates(map).find(c=>c.block===target);
assert.ok(candidate&&candidate.cuts.some(c=>JSON.stringify(c)===JSON.stringify(cut)),
'each impact must still be a legal cut of the current target');
impacts.push({x:target.posX+cut.cell.x,y:target.posY+cut.cell.y,origin:candidate.origin});
events.push('rocket');f.apply(map,target,cut);
}},Set,Math,Uint8Array});
const opening=new module.exports.default(f.map,loadout,prop=>usedCarried.push(prop));
return {f,opening,events,displayed,impacts,usedCarried,boxFrames,listeners,clock,Label,addTime,Opening:module.exports.default};
}
test('all seven props launch together with distinct trails, grant exactly 40 seconds and three cuts only on impact',()=>{
const {f,opening,events,displayed,listeners,clock,Label,addTime,Opening}=openingFixture();
const assertMatchingSize=()=>{
const hourglass=opening.items[0];
for(const rocket of opening.items.slice(opening.hourglassCount)){
if(!rocket.active&&opening.launched)continue;
const referenceScale=opening.launched?hourglass.scaleY/0.7:hourglass.scaleY;
assert.ok(Math.abs(rocket.height*rocket.scaleY-hourglass.height*referenceScale)<1e-6);
assert.equal(rocket.scaleX,rocket.scaleY);
}
};
assertMatchingSize();
assert.equal(opening.update(30,true),false);assert.equal(events.length,0);
listeners.hide();assert.equal(opening.update(30,false),false);assert.equal(events.length,0);listeners.show();
const boxScale=opening.box.scaleX,boxY=opening.box.y;
opening.update(0.25,false);
assert.equal(opening.box.x,0);assert.equal(opening.box.y,boxY);assert.notEqual(opening.box.scaleX,boxScale);
assert.ok(opening.carriedEntries.every(e=>e.node.active&&!e.arrived));assert.equal(events.length,0);
opening.update(0.3,false);
assert.equal(opening.carriedEntries.length,2);
assert.ok(opening.carriedEntries.every(e=>e.node.active&&e.trail&&!e.arrived));
assert.ok(opening.carriedEntries[0].from.x<opening.carriedEntries[1].from.x);
assert.ok(opening.carriedEntries.every(e=>e.from.y<e.to.y&&e.to.y<0));
const entryPositions=opening.carriedEntries.map(e=>[e.node.x,e.node.y]);
listeners.hide();opening.update(30,false);listeners.show();
assert.deepEqual(opening.carriedEntries.map(e=>[e.node.x,e.node.y]),entryPositions);
opening.update(0.1,false);
assert.ok(opening.carriedEntries.every(e=>e.arrived));assert.equal(opening.flights.length,0);
assert.equal(opening.box.scaleX,boxScale);assert.equal(opening.box.opacity,255);
const settledPositions=opening.carriedEntries.map(e=>[e.node.x,e.node.y]);
assert.equal(events.length,0);assert.equal(f.b.allBlocks.length,4);
opening.update(0.2,false);
assert.deepEqual(opening.carriedEntries.map(e=>[e.node.x,e.node.y]),settledPositions);
assert.ok(opening.box.active&&opening.launchCloud.active&&opening.launchCloud.opacity>0);
assert.equal(opening.flights.length,0);assert.equal(events.length,0);
opening.update(0.1,false);
assert.equal(opening.box.active,false);assert.ok(opening.launchCloud.active);
assert.equal(opening.box.x,0);assert.equal(opening.box.y,boxY);
assert.equal(opening.flights.length,7);assert.ok(opening.flights.every(f=>f.node.active&&!f.landed));
assert.ok(opening.items.slice(0,opening.hourglassCount).every(n=>Math.abs(n.scaleY-0.65*0.7)<1e-6&&n.scaleX===n.scaleY));
assert.ok(opening.launchCloud.children.every(n=>n.scaleX===n.scaleY),'smoke groups stay circular');
assert.equal(events.length,0);assert.equal(f.b.allBlocks.length,4);
assert.equal(opening.flights.filter(f=>f.trail.node.name==='rocketSmoke').length,3);
assert.equal(opening.flights.filter(f=>f.trail.node.name==='hourglassGlow').length,4);
const clockTarget=opening.root.convertToNodeSpaceAR(clock.convertToWorldSpaceAR(engine.v2(0,0)));
for(const flight of opening.flights.slice(0,4))assert.deepEqual([flight.to.x,flight.to.y],[clockTarget.x,clockTarget.y]);
for(const flight of opening.flights.slice(0,4))assert.equal(flight.timeText.getComponent(Label).string,'+10');
for(const flight of opening.flights){
if(flight.shot){assert.ok(flight.trail.startSize>=36);assert.ok(flight.trail.endSize>=60);}
else {
assert.ok(flight.trail.startSize<36&&flight.trail.endSize<flight.trail.startSize);
assert.ok(flight.trail.emissionRate<opening.flights[4].trail.emissionRate/3);
assert.notEqual(flight.trail.spriteFrame,opening.flights[4].trail.spriteFrame);
}
assert.equal(flight.trail.dstBlendFactor,flight.shot?771:1);
}
listeners.hide();opening.update(30,false);assert.equal(events.length,0);listeners.show();
const hourglasses=opening.flights.filter(f=>!f.shot);
const bends=hourglasses.map(f=>f.control.x-(f.from.x+f.to.x)/2);
assert.ok(bends[0]<0&&bends[1]>0&&bends[2]<0&&bends[3]>0);
assert.equal(new Set(bends).size,4);
// Measure actual movement rather than trusting the precomputed length table.
const travelled=opening.flights.map(()=>0),arrivalTimes=opening.flights.map(()=>null),speeds=[];
const rising=new Set(),falling=new Set();
const step=1/240;
let done=false,checkedPause=false,sawStaggeredArrival=false,sawEffectsPending=false,sawDigitsPending=false;
for(let frame=1;frame<1200&&!done;frame++){
const previous=opening.flights.map(f=>({x:f.node.x,y:f.node.y,landed:f.landed}));
const beforeTime=f.map.timeNumber;
done=opening.update(step,false);
assertMatchingSize();
let newlyLanded=0;
opening.flights.forEach((flight,i)=>{
if(previous[i].landed)return;
const distance=Math.hypot(flight.node.x-previous[i].x,flight.node.y-previous[i].y);
travelled[i]+=distance;
if(flight.shot&&flight.node.y>previous[i].y)rising.add(i);
if(flight.shot&&flight.node.y<previous[i].y)falling.add(i);
if(flight.landed){
arrivalTimes[i]=frame*step;
if(!flight.shot)newlyLanded++;
assert.deepEqual([flight.node.x,flight.node.y],[flight.to.x,flight.to.y]);
if(!flight.shot)assert.equal(flight.timeText.active,false);
else {
const track=flight.impact.getComponent('Skeleton').track;
assert.equal(track.trackTime,0.3333,'rocket impacts skip the bomb fuse and start at the explosion');
assert.ok(flight.impactSeconds>=1.4-track.trackTime,'the remaining real explosion must finish');
}
assert.ok(flight.impactSeconds>=flight.trail.life+flight.trail.lifeVar,'the existing trail must finish');
}else if(!flight.shot)speeds.push(distance/step);
});
assert.equal(f.map.timeNumber,beforeTime+10*newlyLanded);
const landed=hourglasses.filter(f=>f.landed).length;
assert.equal(f.map.timeNumber,120+landed*10);
if(landed>0&&landed<hourglasses.length)sawStaggeredArrival=true;
const flying=opening.flights.some(f=>!f.landed);
const effectsPending=opening.flights.some(f=>f.landed&&f.impactElapsed<f.impactSeconds);
const digitsPending=opening.displayedTime!==opening.timeTarget;
if(flying||effectsPending||digitsPending)assert.equal(done,false);
if(!flying&&effectsPending)sawEffectsPending=true;
if(digitsPending)sawDigitsPending=true;
if(opening.elapsed>=0.65)assert.equal(opening.launchCloud.active,false,'layered launch smoke finishes before opening completion');
if(newlyLanded&&!checkedPause){
const state=JSON.stringify([opening.elapsed,f.map.timeNumber,displayed,
opening.flights.map(f=>[f.node.x,f.node.y,f.landed,f.impactElapsed])]);
listeners.hide();assert.equal(opening.update(30,false),false);listeners.show();
assert.equal(opening.update(30,true),false);
assert.equal(JSON.stringify([opening.elapsed,f.map.timeNumber,displayed,
opening.flights.map(f=>[f.node.x,f.node.y,f.landed,f.impactElapsed])]),state);
checkedPause=true;
}
}
assert.equal(done,true);assert.equal(checkedPause,true);
assert.ok(sawStaggeredArrival&&sawEffectsPending&&sawDigitsPending);
assert.equal(new Set(arrivalTimes.slice(0,hourglasses.length)).size,hourglasses.length);
assert.ok(Math.max(...speeds)/Math.min(...speeds)<1.02,'hourglasses keep the same speed along every curve');
const averageSpeeds=travelled.map((distance,i)=>distance/arrivalTimes[i]);
for(const [first,last,expected] of [[0,hourglasses.length,1320],[hourglasses.length,opening.flights.length,950]]){
const group=averageSpeeds.slice(first,last);
assert.ok(Math.max(...group)/Math.min(...group)<1.02,'each prop type keeps one travel speed');
assert.ok(group.every(speed=>Math.abs(speed/expected-1)<0.02),'rocket timing must not change hourglass speed');
}
assert.equal(rising.size,3);assert.equal(falling.size,3);
assert.equal(events.filter(event=>event==='time').length,4);
assert.equal(events.filter(event=>event==='rocket').length,3);
assert.equal(opening.launchCloud.active,false);
assert.equal(f.map.timeNumber,160);assert.equal(f.b.allBlocks.length,1);
assert.deepEqual(displayed,Array.from({length:40},(_,i)=>121+i));
// 普通道具调用仍立即更新数字;只有开场演出显式延迟显示。
addTime.call(f.map,10);assert.equal(displayed[displayed.length-1],170);
opening.dispose();assert.equal(Object.keys(listeners).length,0);
assert.equal(opening.update(10,false),true);assert.equal(events.length,7);
for(const carried of [{hourglass:true,rocket:false},{hourglass:false,rocket:true},{hourglass:false,rocket:false}]){
const next=rocketFixture(5);
Object.assign(next.map,{timeLabel:f.map.timeLabel,Block_Prop:f.map.Block_Prop,
hourglassPrefab:f.map.hourglassPrefab,rocketPrefab:f.map.rocketPrefab,rocketBoxPrefab:f.map.rocketBoxPrefab,
SceneManager:f.map.SceneManager});
const entry=new Opening(next.map,{streak:3,...carried},()=>{});
entry.update(0.65,false);
assert.equal(entry.carriedEntries.length,Number(carried.hourglass)+Number(carried.rocket));
assert.equal(entry.carriedEntries.some(e=>!e.rocket),carried.hourglass);
assert.equal(entry.carriedEntries.some(e=>e.rocket),carried.rocket);
entry.update(0.3,false);
assert.equal(entry.flights.filter(f=>!f.shot).length,3+Number(carried.hourglass));
assert.equal(entry.flights.filter(f=>f.shot).length,2+Number(carried.rocket));
entry.dispose();assert.equal(Object.keys(listeners).length,0);
}
});
test('real streak tiers and carried selections determine counts, box frame and actual-use callbacks',()=>{
const rewards=[[0,0],[2,0],[2,1],[3,2]];
for(let streak=0;streak<=3;streak++)for(const hourglass of [false,true])for(const rocket of [false,true]){
const loadout={streak,hourglass,rocket};
const {f,opening,usedCarried,boxFrames}=openingFixture(rocketFixture(5),()=>0.8,loadout);
const [freeHourglass,freeRocket]=rewards[streak];
assert.equal(opening.hourglassCount,freeHourglass+Number(hourglass));
assert.equal(opening.rocketCount,freeRocket+Number(rocket));
assert.equal(opening.box.active,streak>0);
assert.equal(opening.carriedEntries.length,Number(hourglass)+Number(rocket));
if(streak>0){
assert.equal(opening.box.getComponent(engine.Sprite).spriteFrame,boxFrames[streak]);
const rect=boxFrames[streak].getRect();
assert.equal(opening.box.height,436,'box height remains stable');
assert.ok(Math.abs(opening.box.width/opening.box.height-rect.width/rect.height)<1e-8,'box aspect ratio is preserved');
}
for(const entry of opening.carriedEntries){
const expectedIndex=entry.rocket?opening.hourglassCount+freeRocket:freeHourglass;
assert.equal(entry.node,opening.items[expectedIndex]);
}
for(const node of opening.items.slice(opening.hourglassCount)){
assert.ok(Math.abs(node.height*node.scaleY-322*0.65)<1e-6,'rocket-only loadouts keep the same initial size');
assert.equal(node.scaleX,node.scaleY);
}
opening.update(0.85,false);
assert.deepEqual(usedCarried,[],'selection and entry do not consume inventory');
assert.equal(opening.launchCloud.children.length,Number(streak>0)+Number(hourglass)+Number(rocket));
opening.update(0.11,false);
assert.equal(opening.flights.filter(f=>!f.shot).length,freeHourglass+Number(hourglass));
assert.equal(opening.flights.filter(f=>f.shot).length,freeRocket+Number(rocket));
assert.deepEqual(usedCarried,[...(hourglass?['hourglass']:[]),...(rocket?['rocket']:[])]);
let done=false;
for(let frame=0;frame<1200&&!done;frame++)done=opening.update(1/120,false);
assert.equal(done,true,JSON.stringify(loadout));
assert.equal(f.map.timeNumber,120+10*(freeHourglass+Number(hourglass)));
assert.deepEqual(usedCarried,[...(hourglass?['hourglass']:[]),...(rocket?['rocket']:[])],'each carried item is reported only once');
opening.dispose();
}
});
test('empty rocket pools finish without consuming carried rockets, including rocket-only openings',()=>{
for(const loadout of [{streak:0,hourglass:false,rocket:true},{streak:1,hourglass:true,rocket:true},{streak:3,hourglass:false,rocket:true}]){
const {f,opening,usedCarried,events}=openingFixture(rocketFixture(0,2),()=>0,loadout);
opening.update(1.21,false);
assert.equal(opening.flights.some(f=>f.shot),false);
assert.deepEqual(usedCarried,loadout.hourglass?['hourglass']:[]);
if(!opening.hourglassCount)assert.equal(opening.update(0.26,false),false,'finish waits for the existing entry trail to fade');
let done=false;
for(let frame=0;frame<1200&&!done;frame++)done=opening.update(1/120,false);
assert.equal(done,true);
assert.equal(events.includes('rocket'),false);
assert.equal(f.map.blocks.length,1);
assert.equal(opening.launchCloud.active,false);
opening.dispose();
}
});
test('longer arcs preserve repeated square and protected-edge impacts even when all shots land in one update',()=>{
for(const [shape,type,count] of [[5,0,3],[3,2,2]])for(const step of [1/240,0.25,10]){
const f=rocketFixture(shape,type),shots=rocket.planRocketShots(f.map,3,()=>0);
const {opening,impacts,usedCarried}=openingFixture(f,()=>0);
assert.equal(shots.length,count);
// The later targets are much closer, so their original arcs would overtake the first shot.
shots.forEach((shot,i)=>f.map.mapBlocksWall[shot.x][shot.y].convertToWorldSpaceAR=
()=>engine.v2(500,i===0?-1000:600));
opening.update(1.21,false);
const flights=opening.flights.filter(f=>f.shot);
assert.equal(usedCarried.includes('rocket'),count>2,'free rockets are used before consuming a carried rocket');
assert.ok(flights.every(f=>!f.landed&&f.node.active));
assert.equal(new Set(flights.map(f=>f.shot.origin)).size,1);
for(let i=1;i<flights.length;i++){
const previous=flights[i-1].distances.at(-1),current=flights[i].distances.at(-1);
assert.ok(current>=previous+95-1e-6,'same-origin arrivals retain at least 0.1 seconds separation');
assert.ok(flights[i].control.y>Math.max(flights[i].from.y,flights[i].to.y)+480,
'only the otherwise-too-short later arc is extended');
}
let done=false;
for(let frame=0;frame<3000&&!done;frame++){
done=opening.update(step,false);
if(opening.flights.some(f=>!f.landed||f.impactElapsed<f.impactSeconds))assert.equal(done,false);
}
assert.equal(done,true);
assert.deepEqual(impacts.map(s=>[s.x,s.y,s.origin]),Array.from(shots,s=>[s.x,s.y,s.origin]));
assert.equal(f.map.blocks.length,1);assert.equal(f.b.allBlocks.length,1);
assert.equal(f.effects(),0);opening.dispose();
}
});
test('different original blocks may land out of planning order and finish only after the last explosion',()=>{
const f=rocketFixture(5);
for(const [x,y] of [[2,10],[10,10]]){
const b=geometry(5);b.posX=x;b.posY=y;b.node.setPosition(x*120+65,y*120-60);
b.setMapBlock=f.b.setMapBlock;b.setMapBlock();f.map.node.addChild(b.node);f.map.blocks.push(b.node);
}
const shots=rocket.planRocketShots(f.map,3,()=>0),{opening,impacts}=openingFixture(f,()=>0);
assert.equal(new Set(shots.map(s=>s.origin)).size,3);
shots.forEach((shot,i)=>f.map.mapBlocksWall[shot.x][shot.y].convertToWorldSpaceAR=
()=>engine.v2(500,[-1000,650,200][i]));
opening.update(1.21,false);
const flights=opening.flights.filter(f=>f.shot);
assert.ok(flights[1].distances.at(-1)<flights[0].distances.at(-1));
for(const flight of flights)assert.equal(flight.control.y,Math.max(flight.from.y,flight.to.y)+480,
'independent origins keep their normal arcs');
let done=false,sawLastExplosion=false;
for(let frame=0;frame<3000&&!done;frame++){
done=opening.update(1/240,false);
if(impacts.length===3&&flights.some(f=>f.impactElapsed<f.impactSeconds)){
sawLastExplosion=true;assert.equal(done,false);
}
}
assert.equal(done,true);assert.equal(sawLastExplosion,true);
assert.equal(impacts.length,3);assert.notEqual(impacts[0].origin,shots[0].origin);
assert.deepEqual(positions(impacts),positions(shots));
assert.equal(f.map.blocks.reduce((sum,n)=>sum+n.getComponent('Block').allBlocks.length,0),9);
assert.equal(f.effects(),0);opening.dispose();
});
test('B opening casts before first touch without starting countdown, then preserves normal recharge behavior',()=>{
const f=controllerFixture([block(5)]);f.map.gameStart=false;f.map.scheduleCallback=null;
f.controller.startOpening();assert.equal(f.controller.casting,true);
assert.equal(f.controller.updateOpening(1),true);
assert.equal(f.applied.length,1);assert.equal(f.map.gameStart,false);assert.equal(f.controller.energy.value,0);
f.controller.update(10);assert.equal(f.applied.length,1);assert.equal(f.controller.energy.value,0);
f.map.gameStart=true;f.map.scheduleCallback=()=>{};
f.controller.update(1);assert.equal(f.controller.energy.value,1);
});
test('first touch/countdown is rejected while the booster opening is pending',()=>{
const start=method('assets/Script/Map.ts','startUpdate');
const map={startBoostPreparing:true,gameStart:false};
start.call(map);assert.equal(map.gameStart,false);
const countdown=method('assets/Script/Map.ts','startTimeCutDown');
countdown.call(map);assert.equal(map.scheduleCallback,undefined);
const touch=method('assets/Script/Block.ts','touchStart',{MapConroler:{default:{_instance:map},_instance:map}});
touch.call({},{});assert.equal(map.gameStart,false);
});
test('all shipped levels retain legal targets for three sequential shots under the final priority/protection rules',()=>{
const folder=path.join(__dirname,'../assets/custom/Json');
const files=fs.readdirSync(folder).filter(name=>/^level\d+\.json$/.test(name));
const sort=method('assets/Script/Map.ts','sortBlock');
for(const file of files){
const data=JSON.parse(fs.readFileSync(path.join(folder,file),'utf8').replace(/^\uFEFF/,''));
for(const randomValue of [0,0.37,0.99]){
const originals=data.BLOCK_INFO[0].map(info=>({...info}));
const sorted=sort.call({floorMove:originals.some(b=>b.floorMove===true)},originals);
const blocks=sorted.map(info=>{
const b=block(info.block,info.type,info);b.color=info.color;
if(info.type!==16&&info.colorArray!=null)b.color=Number(String(info.colorArray)[0])+1;
b.flowerType=info.flowerSend?1:info.flowerReceive?2:0;
return b;
});
for(let i=0;i<blocks.length;i++){
const b=blocks[i];
if(b.type===9&&blocks[i+1]){b.block_Info.node=blocks[i+1].node;i++;}
else if(b.type===1){const upper=block(b.block_Info.block,10,{...b.block_Info,color:b.block_Info.stacking});upper.color=b.block_Info.stacking;b.block_Info.node=upper.node;}
}
const map=makeMap(blocks);
map.buttonBlock=(data.LEVEL_INFO[0].risefall||[]).filter(info=>info.unLockFall&&info.color>0&&!('vine'in info))
.map(info=>({getComponent:()=>({color:info.color})}));
map.wallArray=data.WALL_INFO[0].map(info=>({getChildByName:()=>({getComponent:()=>({
special:info.special,color:info.stickLength?30:info.color,
colorStartArray:info.colorArray!=null?String(info.colorArray).split('').map(c=>Number(c)+1):[]
})})}));
const hit=new Set();
for(let left=3;left>0;left--){
const choice=rocket.chooseRocketTarget(rocket.rocketCandidates(map),hit,left,()=>randomValue);
assert.ok(choice,file+' shot '+(4-left));
const b=choice.target.block;hit.add(choice.target.origin);
map.blocks=map.blocks.filter(n=>n!==b.node);
for(const fragment of choice.cut.fragments){
const child=block(fragment.shape,b.type,{...b.block_Info,block:fragment.shape});
child.color=b.color;child.flowerType=b.flowerType;child.rocketOrigin=choice.target.origin;
map.blocks.push(child.node);
}
}
}
}
console.log('Rocket scan: '+files.length+' level files, 3 paths per level, 3 shots per path.');
});