Files
shanhe-puzzle/tests/game.test.cjs
T
codex-bot 4845826deb
Shanhe source and offline package checks / verify (pull_request) Failing after 5s
feat: sync Shanhe 2.2 source and add remote package verification
2026-11-01 17:13:46 +08:00

86 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs');
fs.mkdirSync('artifacts',{recursive:true});
const C=require('../src/core.js'),{Game,SPEEDS}=require('../src/game.js'),M=require('matter-js');
const box={window:{}};vm.runInNewContext(fs.readFileSync('src/maps.js','utf8'),box);const data=box.window.MAP_DATA;
const square=(id,x,y)=>({id:String(id),name:'地区'+id,center:[x+50,y+50],rings:[[[x,y],[x+100,y],[x+100,y+100],[x,y+100]]]});
const level={id:'100',name:'两块',width:200,height:100,regions:[square(0,0,0),square(1,100,0)],edges:[[0,1,0]]};
function game(l=level,s=0){return new Game(l,420,560,s,()=>{},()=>.5);}
// Connectivity fixtures start with the complete map inside the tank. Boundary
// correction deliberately no longer moves other, unrelated pieces in formation.
function alignWholeMap(g,tx=20,ty=130){g.rescale(Math.min(g.scale,(g.w-tx-20)/(g.level.width*1.06),(g.h-ty-20)/(g.level.height*1.06)));for(let i=0;i<g.level.regions.length;i++)g.createGroup([i],{tx,ty,angle:0});}
test('相邻但贴错边、方向不一致、非邻接地区均不可合并',()=>{const a={ids:[0],tx:50,ty:100,angle:0};assert.equal(C.canSnap(a,{ids:[1],tx:150,ty:100,angle:0},level.edges,22),null);assert.equal(C.canSnap(a,{ids:[1],tx:50,ty:100,angle:Math.PI/2},level.edges,22),null);assert.equal(C.canSnap(a,{ids:[2],tx:50,ty:100,angle:0},level.edges,22),null);assert.ok(C.canSnap(a,{ids:[1],tx:62,ty:100,angle:0},level.edges,22));});
test('刚体旋转后按正确方位吸合,并且一局可达通关',()=>{const g=game();g.status='playing';g.queue=[];const pose={tx:300,ty:200,angle:Math.PI/2};g.createGroup([0],pose);g.createGroup([1],{...pose,tx:308});assert.ok(g.tryMerges());assert.equal(g.groups.length,1);assert.equal(g.groups[0].ids.length,2);assert.equal(g.status,'won');assert.equal(g.score,1100);g.destroy();});
test('跨海独立片可连接,且按跨海倍率计分',()=>{const l={...level,edges:[[0,1,1]]},g=game(l,1);g.status='playing';g.createGroup([0],{tx:10,ty:120,angle:0});g.createGroup([1],{tx:12,ty:120,angle:0});g.tryMerges();assert.equal(g.score,1620);g.destroy();});
test('出片无重复,暂停冻结计时,四档实际间隔与重力递增',()=>{const g=game();g.start();g.spawn();const clock=g.clock;g.status='paused';g.tick(34);assert.equal(g.clock,clock);g.status='playing';g.nextAt=0;g.tick(16);assert.equal(g.dropped,2);assert.equal(new Set(g.groups.flatMap(x=>x.ids)).size,2);for(let i=1;i<4;i++){assert.ok(SPEEDS[i].interval<SPEEDS[i-1].interval);assert.ok(SPEEDS[i].gravity>SPEEDS[i-1].gravity);}g.destroy();});
test('左右晃动可反复使用且不扣分',()=>{const g=game();g.start();g.spawn();g.queue=[];g.score=200;for(let i=0;i<20;i++){assert.ok(g.nudge(i%2?-1:1));for(let j=0;j<9;j++)g.tick(16.67);}assert.equal(g.score,200);assert.equal(g.shakeCount,20);g.destroy();});
test('堆顶有出生保护,落地支撑的堆积稳定堵塞警戒线才判负',()=>{const l={id:'100',name:'真实堆积',width:200,height:300,regions:Array.from({length:4},(_,i)=>square(i,0,0)),edges:[]},g=game(l);g.status='playing';g.queue=[];for(let i=0;i<4;i++){const p=g.createGroup([i],{tx:0,ty:0,angle:0});M.Body.setPosition(p.body,{x:210,y:g.h-(i+.5)*100*g.scale+.05*i});M.Body.setStatic(p.body,true);}for(let i=0;i<100;i++)g.tick(34);assert.equal(g.status,'playing');for(let i=0;i<130;i++)g.tick(34);assert.ok(g.groups.every(p=>p.grounded));assert.equal(g.status,'lost');g.destroy();});
test('全国每个真实关卡均连通、每片轮廓可生成有限的刚体',()=>{assert.equal(data.levels[0].regions.length,34);assert.equal(data.levels.length,35);let count=0;for(const l of data.levels){const seen=new Set([0]);let changed=true;while(changed){changed=false;for(const e of l.edges){if(seen.has(e[0])!==seen.has(e[1])){seen.add(e[0]);seen.add(e[1]);changed=true;}}}assert.equal(seen.size,l.regions.length,l.name+' must be completable');const g=game(l);for(let i=0;i<l.regions.length;i++){const p=g.createGroup([i],{tx:0,ty:0,angle:0});assert.ok(Number.isFinite(p.body.mass)&&p.body.mass>0,l.name+' '+i);assert.ok(Number.isFinite(p.body.position.x));M.Composite.remove(g.engine.world,p.body);g.groups=[];count++;}g.destroy();}assert.equal(count,529);});
test('完整的全国地图可全部吸合,海岛无死局',()=>{const g=game(data.levels[0]);g.status='playing';g.queue=[];alignWholeMap(g);for(let i=0;i<40;i++)g.tryMerges();assert.equal(g.status,'won');assert.equal(g.groups.length,1);assert.equal(g.groups[0].ids.length,34);g.destroy();});
test('窗口缩放保留进度与组合',()=>{const g=game();g.start();g.spawn();g.score=120;g.resize(360,440);assert.equal(g.score,120);assert.equal(g.dropped,1);assert.equal(g.groups.length,1);assert.ok(Number.isFinite(g.groups[0].body.position.x));g.destroy();});
test('损坏或恶意纪录输入安全降级',()=>{assert.deepEqual(C.safeRecord(null),{});assert.deepEqual(C.safeRecord({'100000:0':123,'x':7,'330100:2':-1}),{'100000:0':123});});
test('27个省内关卡包含333地级单元及30省直辖地区,全部可拼完',()=>{const prefectures=data.levels.filter(l=>l.kind==='prefecture');assert.equal(prefectures.length,27);assert.equal(prefectures.reduce((n,l)=>n+l.regions.length,0),363);assert.equal(prefectures.reduce((n,l)=>n+l.regions.filter(r=>r.unitType==='prefecture').length,0),333);assert.equal(prefectures.reduce((n,l)=>n+l.regions.filter(r=>r.unitType==='province-direct').length,0),30);for(const l of prefectures){assert.ok(l.regions.every(r=>r.unitType==='prefecture'||r.unitType==='province-direct'),l.name+'仅含省域直接子区域');const g=game(l);g.status='playing';g.queue=[];alignWholeMap(g);for(let i=0;i<l.regions.length+3;i++)g.tryMerges();assert.equal(g.status,'won',l.name+'可通关');g.destroy();}});
test('任意静止角度均保留,不自动回正或夹回旧倾角范围',()=>{for(const angle of [-1.1,.8,Math.PI/2]){const g=game();g.status='playing';g.queue=[];g.engine.gravity.y=0;const p=g.createGroup([0],{tx:0,ty:0,angle});M.Body.setPosition(p.body,{x:210,y:280});for(let i=0;i<180;i++)g.tick(1000/60);assert.ok(Math.abs(p.body.angle-angle)<1e-8);g.destroy();}});
test('自由角动量自然衰减后停在新角度,旋转速度仍有上限',()=>{const g=game();g.status='playing';g.queue=[];g.engine.gravity.y=0;const p=g.createGroup([0],{tx:0,ty:0,angle:.6});M.Body.setPosition(p.body,{x:210,y:280});M.Body.setAngularVelocity(p.body,.012);for(let i=0;i<240;i++){g.tick(1000/60);assert.ok(Math.abs(p.body.angularVelocity)<=.0251);}assert.ok(p.body.angle>.9);assert.ok(Math.abs(p.body.angularVelocity)<.001);g.destroy();});
test('左右按键驱动容器弹簧,并对所有碎片施加惯性,而非只移动选中块',()=>{const g=game();g.start();g.spawn();g.queue=[];const b=g.createGroup([g.groups[0].ids[0]===0?1:0],{tx:180,ty:160,angle:0});const a=g.groups[0];const ax=a.body.position.x,bx=b.body.position.x;assert.ok(g.nudge(-1));g.tick(16.67);assert.ok(g.shake.offset<0);assert.ok(a.body.position.x!==ax);assert.ok(b.body.position.x!==bx);assert.equal(g.score,0);g.status='paused';const offset=g.shake.offset;assert.equal(g.nudge(1),false);g.tick(16.67);assert.equal(g.shake.offset,offset);g.destroy();});
test('全国完整地图初始宽度可容纳,旋转后过大的组合仍自动缩放并通关',()=>{const l=data.levels[0],g=new Game(l,320,400,0);assert.ok(l.width*g.scale<=g.w-20,'完整全国初始宽度应能放进容器');const scale=g.scale;g.status='playing';g.queue=[];g.createGroup(Array.from({length:33},(_,i)=>i),{tx:10,ty:150,angle:Math.PI/2});g.createGroup([33],{tx:10,ty:150,angle:Math.PI/2});g.tryMerges();assert.equal(g.status,'won');assert.ok(g.scale<scale);const b=g.groups[0].body.bounds;assert.ok(b.max.x-b.min.x<=g.w-19);assert.ok(b.max.y-b.min.y<=g.h-78-23);g.destroy();});
test('真实落地会回弹并产生碰撞反馈,落地碎片可被晃动轻轻颠起',()=>{const g=game();g.start();g.spawn();g.queue=[];let sawBounce=false;for(let i=0;i<480;i++){g.tick(16.67);if(g.groups[0].body.velocity.y<-.15)sawBounce=true;}assert.ok(sawBounce);assert.ok(g.collisionCount>0);const body=g.groups[0].body,start=body.position.y;g.nudge(1);assert.ok(body.velocity.y<0,'睡眠中的已落地碎片也会轻弹');let highest=start;for(let i=0;i<45;i++){g.tick(16.67);highest=Math.min(highest,body.position.y);}assert.ok(start-highest>4);assert.ok(start-highest<75);g.destroy();});
test('反复交替晃动限制平移和旋转速度,不会把碎片无限加速或甩出框外',()=>{const g=game();g.start();g.spawn();g.queue=[];for(let i=0;i<600;i++){if(i%9===0)g.nudge(Math.floor(i/9)%2?-1:1);g.tick(16.67);const b=g.groups[0].body;assert.ok(Math.abs(b.velocity.x)<=7.201);assert.ok(b.velocity.y<=9.001);assert.ok(Math.abs(b.angularVelocity)<=.0251);const real=M.Bounds.create(b.vertices);assert.ok(real.min.x>=-3&&real.max.x<=g.w+3);assert.ok(real.max.y<=g.h+4);}assert.ok(g.collisionCount>0);assert.ok(g.impacts.length<=20);g.destroy();});
test('上晃作用于整个框和每个组合,冷却防连点,暂停时不可操作',()=>{const g=game();g.start();g.spawn();g.queue=[];g.createGroup([g.groups[0].ids[0]===0?1:0],{tx:180,ty:160,angle:0});assert.equal(g.nudgeUp(),true);assert.ok(g.groups.every(p=>p.body.velocity.y<0));assert.equal(g.nudgeUp(),false);g.tick(16.67);assert.ok(g.shake.y<0);g.status='paused';assert.equal(g.nudgeUp(),false);g.destroy();});
test('四档弹性实际递增且反复三向晃动始终受速度/框体限制',()=>{const {BOUNCES}=require('../src/game.js');assert.equal(BOUNCES.length,4);let heights=[];for(const b of [0,1,3,2]){const g=new Game(level,420,560,0,()=>{},()=>.5,b);g.start();g.spawn();g.queue=[];for(let i=0;i<480;i++)g.tick(16.67);const start=g.groups[0].body.position.y;g.nudgeUp();let highest=start;for(let i=0;i<60;i++){g.tick(16.67);highest=Math.min(highest,g.groups[0].body.position.y);}heights.push(start-highest);assert.equal(g.groups[0].body.restitution,BOUNCES[b].restitution);for(let i=0;i<600;i++){if(i%30===0)g.nudgeUp();if(i%9===0)g.nudge(i%2?1:-1);g.tick(16.67);const body=g.groups[0].body,real=M.Bounds.create(body.vertices);assert.ok(Math.abs(body.velocity.x)<=BOUNCES[b].maxX+.001);assert.ok(Math.abs(body.angularVelocity)<=.0251);assert.ok(real.min.x>=-3&&real.max.x<=g.w+3&&real.max.y<=g.h+4);}g.destroy();}for(let i=1;i<heights.length;i++)assert.ok(heights[i]>heights[i-1]*1.15,heights);});
test('地级版与全国版继续保持额外七成的相对缩放',()=>{const {baseScale}=require('../src/game.js');for(const l of data.levels.slice(1)){const same={...l,id:'100000'};assert.ok(Math.abs(baseScale(l,320,435)/baseScale(same,320,435)-.7)<.00001);}});
test('暂停也可主动结束,成绩曲线从零开始、通关含奖励、结束后冻结且只发一次事件',()=>{let ends=0;const g=new Game(level,420,560,0,t=>{if(t==='end')ends++;},()=>.5);g.start();g.spawn();g.queue=[];g.tick(100);g.status='paused';const before=g.clock;g.tick(100);assert.equal(g.clock,before);assert.ok(g.finish());assert.equal(g.status,'ended');assert.equal(g.endReason,'stopped');assert.equal(ends,1);assert.deepEqual(g.history,[{ms:0,score:0},{ms:100,score:0}]);assert.equal(g.finish(),false);g.tick(100);assert.equal(g.clock,100);g.destroy();const won=game();won.status='playing';won.clock=60000;won.queue=[];won.createGroup([0],{tx:20,ty:100,angle:0});won.createGroup([1],{tx:20,ty:100,angle:0});won.tryMerges();assert.equal(won.endReason,'won');assert.deepEqual(won.history,[{ms:0,score:0},{ms:60000,score:1100}]);won.destroy();});
test('不同弹性的纪录分开存储,仍兼容旧版最低弹性纪录',()=>{assert.deepEqual(C.safeRecord({'100000:0':100,'100000:0:1':200,'100000:0:2':300,'100000:0:4':900}),{'100000:0':100,'100000:0:1':200,'100000:0:2':300});});
test('四档下落速度下,实测上晃分别为轻弹、正常和强弹,强弹可颠起至少200px',()=>{const fs=require('node:fs'),results=[];const l={id:'100000',name:'弹跳测试',width:1000,height:1000,regions:[square(0,0,0)],edges:[]};for(let speed=0;speed<4;speed++){let heights=[];for(let bounce=0;bounce<4;bounce++){const g=new Game(l,320,590,speed,()=>{},()=>.5,bounce);g.start();g.spawn();g.queue=[];const body=g.groups[0].body;M.Body.translate(body,{x:0,y:g.h-1-body.bounds.max.y});M.Body.setVelocity(body,{x:0,y:0});M.Sleeping.set(body,true);const start=body.position.y;assert.ok(g.nudgeUp());let highest=start;for(let i=0;i<180;i++){g.tick(16.67);highest=Math.min(highest,body.position.y);}const height=start-highest;heights.push(height);results.push({speed,bounce,height:Number(height.toFixed(1))});g.destroy();}assert.ok(heights[0]>=25&&heights[0]<=55,heights);assert.ok(heights[1]>=85&&heights[1]<=150,heights);assert.ok(heights[2]>=200&&heights[2]<=340,heights);assert.ok(heights[3]>heights[1]*1.15&&heights[3]<heights[2]/1.1,heights);}fs.writeFileSync('artifacts/bounce-probe.json',JSON.stringify({method:'320×590 logical pixels, one supported square, one upward shake, actual physics peak; not real-device performance',results},null,2));});
test('左右晃动会缓存请求,碎片在两次150ms按键之间落地也能颠起',()=>{const l={id:'100000',name:'落地时序',width:1000,height:1000,regions:[square(0,0,0)],edges:[]};for(const delay of [0,25,60,100]){const g=new Game(l,320,590,0,()=>{},()=>.5,1);g.start();g.spawn();g.queue=[];g.clock=delay;const p=g.groups[0],b=p.body;M.Body.translate(b,{x:0,y:g.h-6-b.bounds.max.y});M.Body.setVelocity(b,{x:0,y:1});assert.ok(g.nudge(1));assert.equal(g.hopCount,0,'尚未接触时不在空中加速');for(let i=0;i<9;i++)g.tick(1000/60);assert.equal(g.hopCount,1,'下一次按键前已在落地时触发');assert.ok(b.velocity.y<0);g.destroy();}});
test('过期请求、空中长按和暂停不会凭空产生连续上抛',()=>{const l={id:'100000',name:'时序限制',width:1000,height:1000,regions:[square(0,0,0)],edges:[]},g=new Game(l,320,590,0,()=>{},()=>.5,1);g.start();g.spawn();g.queue=[];g.nudge(1);for(let i=0;i<180;i++)g.tick(1000/60);assert.equal(g.hopCount,0,'初始空中请求已过期');g.status='paused';assert.equal(g.nudge(1),false);const count=g.hopCount;g.tick(100);assert.equal(g.hopCount,count);g.destroy();});
test('容器滑动按位置分配力度:附近更强,远处仍动,没有瞬移或单片抓取',()=>{
const l={id:'100000',name:'位置力度',width:1000,height:1000,regions:[square(0,0,0),square(1,300,0)],edges:[]},g=new Game(l,420,560,0,()=>{},()=>.5,1);g.status='playing';g.queue=[];
const near=g.createGroup([0],{tx:0,ty:0,angle:0}),far=g.createGroup([1],{tx:0,ty:0,angle:0});M.Body.setPosition(near.body,{x:90,y:200});M.Body.setPosition(far.body,{x:330,y:420});const positions=g.groups.map(p=>({...p.body.position}));
assert.ok(g.swipe({x:90,y:200},{x:30,y:-10},32));assert.ok(near.body.velocity.x>far.body.velocity.x*3);assert.ok(far.body.velocity.x>0);assert.ok(near.body.velocity.y<far.body.velocity.y&&far.body.velocity.y<0);assert.deepEqual(g.groups.map(p=>({...p.body.position})),positions);assert.equal(g.swipeCount,1);g.tick(16.67);assert.ok(g.shake.offset>0);assert.ok(g.shake.y<0);g.destroy();
});
test('同样距离快速划动更有力;轻触、无效输入与暂停不触发晃动',()=>{
const run=elapsed=>{const g=game();g.start();g.spawn();g.queue=[];const body=g.groups[0].body,p={...body.position};g.swipe(p,{x:24,y:0},elapsed);const vx=body.velocity.x;g.destroy();return vx;};assert.ok(run(16)>run(64)*1.3);
const g=game();g.start();g.spawn();const p={x:200,y:200};assert.equal(g.swipe(p,{x:.1,y:.1},16),false);assert.equal(g.swipe(p,{x:NaN,y:0},16),false);assert.equal(g.swipe(p,{x:20,y:0},Infinity),false);g.status='paused';assert.equal(g.swipe(p,{x:20,y:0},16),false);assert.equal(g.swipeCount,0);g.destroy();
});
test('四档弹性连续任意方向滑动不超速、穿墙、旋转失控,落地滑动能颠起',()=>{
const l={id:'100000',name:'滑动边界',width:1000,height:1000,regions:[square(0,0,0)],edges:[]};for(let bounce=0;bounce<4;bounce++){
const g=new Game(l,320,590,0,()=>{},()=>.5,bounce);g.start();g.spawn();g.queue=[];const b=g.groups[0].body;M.Body.translate(b,{x:0,y:g.h-1-b.bounds.max.y});M.Sleeping.set(b,true);g.swipe({...b.position},{x:30,y:0},32);assert.ok(b.velocity.y<0,'落地碎片有上弹反馈');
for(let i=0;i<600;i++){g.swipe({x:160,y:450},{x:Math.sin(i*.13)*300,y:Math.cos(i*.17)*300},16.67);g.tick(16.67);const real=M.Bounds.create(b.vertices);assert.ok(Math.abs(b.velocity.x)<=g.bounce.maxX+.001);assert.ok(b.velocity.y<=9.001);assert.ok(Math.abs(b.angularVelocity)<=.0251);assert.ok(real.min.x>=-3&&real.max.x<=g.w+3&&real.max.y<=g.h+4);assert.ok(g.impacts.length<=20);}g.destroy();
}
});
test('每次划动只给一次冲量,短间隔限频,地块方向有分散且随后自由运动',()=>{
const l={id:'100000',name:'单次惯性',width:1000,height:1000,regions:[square(0,0,0),square(1,300,0)],edges:[]};let seq=0;const g=new Game(l,420,560,0,()=>{},()=>[.1,.9,.9,.1][seq++%4],1);g.status='playing';g.queue=[];
const a=g.createGroup([0],{tx:0,ty:100,angle:0}),b=g.createGroup([1],{tx:0,ty:100,angle:0});M.Body.setPosition(a.body,{x:120,y:200});M.Body.setPosition(b.body,{x:260,y:200});assert.ok(g.swipe({x:190,y:200},{x:70,y:0},100));assert.ok(a.body.velocity.x>0&&b.body.velocity.x>0);assert.ok(a.body.velocity.y*b.body.velocity.y<0,'相同大方向下有不同偏转');
const pos={...a.body.position};assert.equal(g.swipe({x:120,y:200},{x:-70,y:0},100),false);for(let i=0;i<15;i++)g.tick(16.67);assert.equal(g.swipeCount,1);assert.notDeepEqual(a.body.position,pos);assert.ok(g.swipe({x:120,y:200},{x:70,y:0},100));assert.equal(g.swipeCount,2);g.destroy();
});
test('待落块先留在预备区,不进入物理世界或落块计数;四档均到点释放',()=>{
for(let speed=0;speed<4;speed++){const g=new Game(level,420,560,speed,()=>{},()=>.5);g.start();const p=g.pendingView();assert.ok(p);assert.equal(g.dropped,0);assert.equal(g.groups.length,0);assert.ok(p.top>=0&&p.bottom<g.dangerY);g.holdPending(p.serial);g.movePending(60,p.serial);const chosen=g.pending.x,deadline=g.nextAt;while(g.clock<deadline-50)g.tick(Math.min(100,deadline-50-g.clock));assert.equal(g.dropped,0);assert.equal(g.releasePending(p.serial),true);assert.equal(g.dropped,0,'定时模式松手不提前');g.holdPending(p.serial);g.tick(50);assert.equal(g.dropped,1,'到点仍按住也落下');assert.notEqual(g.pending.serial,p.serial);const body=g.groups[0],anchor=g.worldPoint(body,g.level.regions[p.id].center);assert.ok(Math.abs(anchor.x-chosen)<.001);assert.equal(g.movePending(330,p.serial),false);assert.equal(g.releasePending(p.serial),false,'旧手指不会释放下一块');assert.equal(g.dropped,1);g.destroy();}
});
test('∞模式等待不自动落下,取消/暂停不释放;仅有效松手释放一次',()=>{
const g=new Game(level,320,590,4,()=>{},()=>.5,3);g.start();const serial=g.pending.serial;for(let i=0;i<1000;i++)g.tick(100);assert.equal(g.dropped,0);assert.equal(g.pending.serial,serial);assert.equal(g.pendingView().remaining,null);assert.equal(g.releasePending(serial),false);g.holdPending(serial);g.cancelPendingDrag(serial);assert.equal(g.releasePending(serial),false);g.holdPending(serial);g.status='paused';const time=g.clock;g.tick(100);assert.equal(g.clock,time);assert.equal(g.releasePending(serial),false);g.cancelPendingDrag(serial);g.status='playing';g.holdPending(serial);assert.equal(g.releasePending(serial),true);assert.equal(g.dropped,1);assert.equal(g.groups.length,1);assert.equal(g.releasePending(serial),false);assert.equal(g.dropped,1);g.destroy();
});
test('预备区调整有真实轮廓左右边界,缩放保留位置比例且暂停不走倒计时',()=>{
const g=new Game(data.levels[0],320,590,0,()=>{},()=>.5);g.start();const serial=g.pending.serial;g.movePending(-999,serial);const x=g.pending.x;g.spawn(serial);let real=M.Bounds.create(g.groups[0].body.vertices);assert.ok(real.min.x>=7.9);g.movePending(999,g.pending.serial);const second=g.pending.serial;g.spawn(second);real=M.Bounds.create(g.groups[1].body.vertices);assert.ok(real.max.x<=g.w-7.9);const third=g.pending.serial;g.movePending(g.w*.7,third);const ratio=g.pending.x/g.w;g.resize(420,650);assert.ok(Math.abs(g.pending.x/g.w-ratio)<.00001);const before=g.pendingView().remaining;g.status='paused';g.tick(100);assert.equal(g.pendingView().remaining,before);assert.ok(x>=8);g.destroy();
});
test('四档弹性按展示顺序递增,新档居中且旧强弹纪录不被挪用',()=>{
const {BOUNCES,BOUNCE_ORDER}=require('../src/game.js');assert.deepEqual(BOUNCE_ORDER,[0,1,3,2]);for(let i=1;i<BOUNCE_ORDER.length;i++){const a=BOUNCES[BOUNCE_ORDER[i-1]],b=BOUNCES[BOUNCE_ORDER[i]];for(const key of ['restitution','hop','rise','push','maxX'])assert.ok(b[key]>a[key],key);}assert.equal(BOUNCES[2].name,'弹力全开');assert.equal(BOUNCES[3].name,'元气弹跳');assert.deepEqual(C.safeRecord({'100000:4:3':123,'100000:0:2':999,'100000:5:0':5}),{'100000:4:3':123,'100000:0:2':999});
});
test('正确相邻地块小幅错位与倾斜更容易吸合,错边、过远、错向和非邻接仍拒绝',()=>{
const merged=game();merged.status='playing';merged.queue=[];merged.createGroup([0],{tx:90,ty:140,angle:-.13});merged.createGroup([1],{tx:114,ty:140,angle:.13});assert.equal(merged.tryMerges(),true);assert.equal(merged.status,'won');merged.destroy();
for(const pose of [{tx:118,ty:140,angle:0},{tx:90,ty:140,angle:.32},{tx:190,ty:140,angle:0}]){const g=game();g.status='playing';g.createGroup([0],{tx:90,ty:140,angle:0});g.createGroup([1],pose);assert.equal(g.tryMerges(),false);assert.equal(g.groups.length,2);g.destroy();}
const noEdge=game({...level,edges:[]});noEdge.status='playing';noEdge.createGroup([0],{tx:90,ty:140,angle:0});noEdge.createGroup([1],{tx:90,ty:140,angle:0});assert.equal(noEdge.tryMerges(),false);noEdge.destroy();
});
test('直辖市县区、港澳分区与台湾县市均有完整关卡且可通关',()=>{const counts={'110000':16,'120000':16,'310000':16,'500000':38,'810000':18,'820000':8,'710000':20};for(const [id,count] of Object.entries(counts)){const l=data.levels.find(l=>l.id===id);assert.equal(l.regions.length,count);const g=game(l);g.status='playing';g.queue=[];alignWholeMap(g);for(let i=0;i<count+2;i++)g.tryMerges();assert.equal(g.status,'won',l.name+'应可通关');g.destroy();}const tw=data.levels.find(l=>l.id==='710000');for(const name of ['台北市','新北市','高雄市','台东县','澎湖县'])assert.ok(tw.regions.some(r=>r.name===name));assert.ok(tw.regions.every(r=>r.name!=='金门县'&&r.name!=='连江县'));assert.ok(tw.scopeNote.includes('移位示意'));});
test('台湾环形县市保留内部空洞,内嵌市不是海岛且不会被县块碰撞填满',()=>{const l=data.levels.find(l=>l.id==='710000');for(const [outer,inner] of [['新北市','台北市'],['嘉义县','嘉义市']]){const a=l.regions.findIndex(r=>r.name===outer),b=l.regions.findIndex(r=>r.name===inner),r=l.regions[a];assert.ok(r.holes&&r.holes.length&&r.physics.length);assert.ok(l.edges.some(e=>((e[0]===a&&e[1]===b)||(e[0]===b&&e[1]===a))&&e[2]===0));const g=game(l),body=g.createGroup([a],{tx:10,ty:100,angle:0}),point=g.worldPoint(body,l.regions[b].label);assert.equal(M.Query.point([body.body],point).length,0,outer+'应留出'+inner+'的位置');g.destroy();}});