Files
shanhe-puzzle/tests/game.test.cjs
T

37 lines
12 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);}
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();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.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 g=game();g.start();g.queue=[];const a=g.groups[0];M.Body.setStatic(a.body,true);M.Body.setPosition(a.body,{x:150,y:42});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.equal(g.status,'lost');g.destroy();});
test('全国每个真实关卡均连通、每片轮廓可生成有限的刚体',()=>{assert.equal(data.levels[0].regions.length,34);assert.equal(data.levels.length,28);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,367);});
test('完整的全国地图可全部吸合,海岛无死局',()=>{const g=game(data.levels[0]);g.status='playing';g.queue=[];for(let i=0;i<34;i++)g.createGroup([i],{tx:20,ty:100,angle:0});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.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个地级关卡可拼完',()=>{for(const l of data.levels.slice(1)){assert.ok(l.regions.every(r=>Number(r.id)%100===0),l.name+'不得包含县区或县级市');const g=game(l);g.status='playing';g.queue=[];for(let i=0;i<l.regions.length;i++)g.createGroup([i],{tx:25,ty:110,angle:0});for(let i=0;i<l.regions.length+3;i++)g.tryMerges();assert.equal(g.status,'won',l.name+'可通关');g.destroy();}});
test('碎片轻微翻动受角度上限约束,松手后自动回正',()=>{const g=game();g.start();g.queue=[];const a=g.groups[0];g.nudge(1);for(let i=0;i<200;i++)g.tick(16.67);assert.ok(Math.abs(a.body.angle)<.025);g.destroy();});
test('左右按键驱动容器弹簧,并对所有碎片施加惯性,而非只移动选中块',()=>{const g=game();g.start();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,435,0);const old=Math.min((320-52)/l.width,435*.48/l.height);assert.ok(g.scale/old>2.2);const scale=g.scale;g.status='playing';g.queue=[];for(let i=0;i<34;i++)g.createGroup([i],{tx:10,ty:150,angle:0});for(let i=0;i<40;i++)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.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.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.angle)<=.1601);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.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,3);let heights=[];for(let b=0;b<3;b++){const g=new Game(level,420,560,0,()=>{},()=>.5,b);g.start();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.angle)<=.1601);assert.ok(real.min.x>=-3&&real.max.x<=g.w+3&&real.max.y<=g.h+4);}g.destroy();}assert.ok(heights[1]>heights[0]*1.5,heights);assert.ok(heights[2]>heights[1]*1.5,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.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:3':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<3;bounce++){const g=new Game(l,320,590,speed,()=>{},()=>.5,bounce);g.start();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);}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.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.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();});