143 lines
12 KiB
JavaScript
143 lines
12 KiB
JavaScript
import { THEME } from '../src/design-tokens.js';
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { Game, analyze, available, clone, generateLevel, hexagon, rotateTracks, rotateBoard, solveRotations, canRotate, solve, trace, validateLevel } from '../src/core.js';
|
||
import { pathPoints, segmentHexHit } from '../src/path-math.js';
|
||
import { LEVELS } from '../src/levels.js';
|
||
import { center, hitCell, layout, pointAlong, routePoints } from '../src/geometry.js';
|
||
const straight=[3,4,5,0,1,2];
|
||
const level=(cells,tiles,moveLimit=10)=>({version:1,name:'test',cells:cells.map(c=>({tracks:[...straight],...c})),tiles,moveLimit});
|
||
const tile=(id,q,r,dir=0)=>({id,q,r,dir,color:0});
|
||
test('bends follow the paired exit, never a crossing branch',()=>{
|
||
const l=level([{q:0,r:0},{q:0,r:-1,tracks:[2,3,0,1,5,4]},{q:1,r:-2}],[tile('a',0,0)]);
|
||
const path=trace(l,l.tiles[0]);assert.equal(path.status,'exit');assert.equal(path.steps[1].enter,3);assert.equal(path.steps[1].exit,1);assert.deepEqual(path.steps[2],{q:1,r:-2,enter:4,exit:1});
|
||
});
|
||
test('occupied cell blocks even when its own arrow points elsewhere',()=>{
|
||
const l=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0),tile('b',0,-1,1)]);
|
||
assert.equal(trace(l,l.tiles[0]).blocker,'b');assert.deepEqual(available(l).map(t=>t.id),['b']);
|
||
const g=new Game(l);const original=clone(g.tiles);assert.equal(g.tap('a').status,'blocked');assert.equal(g.moves,9);assert.deepEqual(g.tiles,original);assert.equal(g.mistakes,1);assert.ok(g.undo());assert.equal(g.moves,10);assert.equal(g.mistakes,0);
|
||
assert.equal(g.tap('b').status,'exit');assert.equal(g.tap('a').status,'exit');assert.ok(g.won);
|
||
});
|
||
test('closed loop returns to the starting occupied tile and is unsolvable',()=>{
|
||
const l=level([{q:0,r:0,tracks:[3,2,1,0,5,4]},{q:1,r:-1,tracks:[1,0,5,4,3,2]},{q:1,r:0,tracks:[5,2,1,4,3,0]}],[tile('a',0,0,1)]);
|
||
assert.equal(trace(l,l.tiles[0]).status,'loop');assert.equal(trace(l,l.tiles[0]).blocker,undefined);assert.equal(solve(l).solvable,false);
|
||
});
|
||
test('mutual blocking reaches a fixed point; move shortage is separate',()=>{
|
||
const l=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0),tile('b',0,-1,3)]);
|
||
assert.equal(solve(l).solvable,false);assert.equal(solve(l).stuck.length,2);
|
||
const easy=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0),tile('b',0,-1)],1);
|
||
assert.equal(solve(easy).stuck.length,0);assert.equal(solve(easy).solvable,false);
|
||
});
|
||
test('all 24 campaign levels retain their order, validate and solve with free rotations',()=>{
|
||
for(const l of LEVELS){assert.deepEqual(validateLevel(l),[]);const a=analyze(l);assert.ok(a.solvable,l.name);assert.ok(a.rotations>=1);assert.equal(solve(l).solvable,false);assert.ok(a.score>=0&&a.score<=100);const g=new Game(l);for(const action of a.actions){if(action.type==='rotate')assert.equal(g.rotate(action.direction).status,'rotated');else assert.equal(g.tap(action.id).status,'exit');}assert.ok(g.won);assert.ok(g.moves>=0);}
|
||
});
|
||
test('100 generated seeds are deterministic and solvable',()=>{
|
||
for(let seed=0;seed<100;seed++){const l=generateLevel(seed,seed%2?2:3,seed%2?15:32,.8);assert.deepEqual(validateLevel(l),[]);assert.ok(solve(l).solvable,`seed ${seed}`);assert.ok(l.tiles.length>0);}
|
||
assert.deepEqual(generateLevel(1234),generateLevel(1234));
|
||
});
|
||
test('imports reject duplicate positions, invalid matching, bad IDs and directions',()=>{
|
||
const good=clone(LEVELS[0]);assert.deepEqual(validateLevel(good),[]);
|
||
for(const mutate of [l=>l.cells.push(l.cells[0]),l=>l.cells[0].tracks=[1,2,3,4,5,0],l=>l.tiles.push({...l.tiles[0],id:'other'}),l=>l.tiles[0].dir=6,l=>l.tiles[0].color=4,l=>l.moveLimit=0,l=>l.version=2,l=>l.tiles[0].q=99,l=>l.tiles[0].id=null]){const l=clone(good);mutate(l);assert.ok(validateLevel(l).length);}
|
||
assert.ok(validateLevel(null).length);assert.ok(validateLevel({version:1,cells:[null],tiles:[null]}).length);
|
||
});
|
||
test('empty boards are not publishable wins and report a finite score',()=>{
|
||
const l=level([{q:0,r:0}],[]);const a=analyze(l);assert.equal(a.solvable,false);assert.equal(a.score,0);
|
||
});
|
||
test('six rotations preserve track connectivity',()=>{let tracks=[1,0,4,5,2,3];for(let n=0;n<6;n++)tracks=rotateTracks(tracks);assert.deepEqual(tracks,[1,0,4,5,2,3]);});
|
||
test('geometry hit testing selects cell centers and rejects outside points',()=>{
|
||
const cells=hexagon(3),v=layout(cells,390,390);for(const c of cells){const p=center(c,v);assert.equal(hitCell(cells,v,p.x,p.y),c);}assert.equal(hitCell(cells,v,-100,-100),undefined);
|
||
const l=LEVELS[0],t=available(l)[0],route=routePoints(trace(l,t),layout(l.cells,390,390),true);for(const p of [0,.5,1]){const point=pointAlong(route,p);assert.ok(Number.isFinite(point.x)&&Number.isFinite(point.angle));}
|
||
});
|
||
test('undo also restores a completed board; finished games ignore taps',()=>{
|
||
const g=new Game(level([{q:0,r:0}],[tile('a',0,0)],1));g.tap('a');assert.ok(g.won);assert.equal(g.tap('a').status,'finished');g.undo();assert.equal(g.won,false);assert.equal(g.moves,1);
|
||
});
|
||
test('difficulty stays within 0–100 even with insufficient moves',()=>{
|
||
const l=clone(LEVELS[23]);l.moveLimit=1;const a=analyze(l);assert.equal(a.solvable,false);assert.ok(a.score<=100);assert.ok(a.parts.precision<=10);
|
||
});
|
||
|
||
test('six board rotations restore tracks; tokens and arrows stay fixed',()=>{
|
||
const l=clone(LEVELS[0]);assert.ok(canRotate(l));
|
||
assert.deepEqual(rotateBoard(l,6),l);
|
||
let board=l;for(let i=0;i<6;i++)board=rotateBoard(board);
|
||
assert.deepEqual(board,l);
|
||
const g=new Game(l),original=clone(g.tiles),before=clone(g.level.cells);
|
||
assert.equal(g.rotate(1).status,'rotated');assert.deepEqual(g.tiles,original);assert.notDeepEqual(g.level.cells,before);assert.equal(g.orientation,1);assert.equal(g.moves,l.moveLimit);
|
||
assert.ok(g.undo());assert.equal(g.orientation,0);assert.deepEqual(g.level.cells,before);assert.equal(g.moves,l.moveLimit);
|
||
});
|
||
test('a stuck orientation can be rescued by rotating and must not immediately lose',()=>{
|
||
const l=LEVELS[0],g=new Game(l);
|
||
while(available(g.level,g.tiles).length)g.tap(available(g.level,g.tiles)[0].id);
|
||
assert.ok(g.tiles.length);assert.equal(g.lost,false);
|
||
const result=solveRotations({...g.level,tiles:g.tiles,moveLimit:g.moves});assert.ok(result.solvable);assert.equal(result.actions[0].type,'rotate');
|
||
const before=available(g.level,g.tiles).length;g.rotate(result.actions[0].direction);assert.ok(available(g.level,g.tiles).length>before);
|
||
});
|
||
test('swept collision blocks a neighboring piece even when the center-line visits only empty cells',()=>{
|
||
const l=level(hexagon(2),[tile('moving',1,1,0),tile('beside',0,0,0)]);
|
||
const centerLine=trace(l,l.tiles[0],l.tiles,true);assert.equal(centerLine.status,'exit');assert.deepEqual(centerLine.blockers,[]);
|
||
const route=trace(l,l.tiles[0]);assert.equal(route.status,'blocked');assert.equal(route.blocker,'beside');assert.ok(route.collision);assert.ok(route.travelPoints.length<pathPoints(centerLine).length);
|
||
const clear=trace(l,l.tiles[0],[l.tiles[0]]);assert.equal(clear.status,'exit');
|
||
const g=new Game(l),before=clone(g.tiles);g.tap('moving');assert.deepEqual(g.tiles,before);assert.equal(g.moves,l.moveLimit-1);
|
||
});
|
||
test('swept segment detection cannot tunnel through a collider between frames',()=>{
|
||
const hit=segmentHexHit({x:-10,y:0},{x:10,y:0},{x:0,y:0});assert.ok(hit>0&&hit<1);
|
||
assert.equal(segmentHexHit({x:-10,y:3},{x:10,y:3},{x:0,y:0}),null);
|
||
});
|
||
test('free rotations are excluded from the minimum move budget',()=>{
|
||
const l=clone(LEVELS[0]),a=solveRotations(l);assert.ok(a.rotations>0);
|
||
assert.equal(a.minimumMoves,l.tiles.length);l.moveLimit=l.tiles.length;assert.ok(solveRotations(l).solvable);const g=new Game(l);for(const action of a.actions){if(action.type==='rotate'){const before=g.moves;g.rotate(action.direction);assert.equal(g.moves,before);}else g.tap(action.id);}assert.ok(g.won);assert.equal(g.moves,0);
|
||
l.moveLimit=a.minimumMoves-1;const tooShort=solveRotations(l);assert.equal(tooShort.reachable,true);assert.equal(tooShort.solvable,false);
|
||
});
|
||
test('irregular boards retain classic play and do not rotate tiles off the base',()=>{
|
||
const l=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0)]);const g=new Game(l);assert.equal(canRotate(l),false);assert.equal(g.rotate().status,'rotation-disabled');assert.equal(g.moves,l.moveLimit);
|
||
});
|
||
test('exit animation cannot pass through another piece beyond an irregular board gap',()=>{
|
||
const l=level([{q:0,r:0},{q:0,r:-2}],[tile('a',0,0,0),tile('b',0,-2,1)]);
|
||
assert.equal(trace(l,l.tiles[0],l.tiles,true).status,'exit');
|
||
const route=trace(l,l.tiles[0]);assert.equal(route.status,'blocked');assert.equal(route.blocker,'b');assert.ok(route.collision);
|
||
});
|
||
|
||
test('level 1 last piece reports a loop and wins after one clockwise turn',()=>{
|
||
const g=new Game(LEVELS[0]);while(available(g.level,g.tiles).length)g.tap(available(g.level,g.tiles)[0].id);
|
||
assert.equal(g.tiles.length,1);const last=g.tiles[0].id;
|
||
assert.equal(trace(g.level,g.tiles[0],g.tiles).status,'loop');
|
||
assert.equal(g.rotate(1).status,'rotated');assert.equal(g.tap(last).status,'exit');assert.ok(g.won);
|
||
});
|
||
test('moving tile does not collide with its vacated starting cell',()=>{
|
||
const l=level([{q:0,r:0,tracks:[3,4,5,0,1,2]},{q:1,r:-1,tracks:[1,0,5,4,3,2]},{q:1,r:0,tracks:[5,2,1,4,3,0]}],[tile('a',0,0,1)]);
|
||
const result=trace(l,l.tiles[0]);assert.equal(result.status,'exit');assert.equal(result.blocker,undefined);
|
||
});
|
||
|
||
test('collision animation pauses at contact, recoils, and returns without moving either logical tile',async()=>{
|
||
const {collisionPhase,contactPath}=await import('../src/collision-animation.js');
|
||
assert.equal(collisionPhase(0).distance,0);assert.equal(collisionPhase(.5).distance,1);assert.equal(collisionPhase(.6).distance,1);assert.equal(collisionPhase(1).distance,0);
|
||
assert.ok(collisionPhase(.5).impact>0);assert.equal(collisionPhase(.5,true).kick,0);
|
||
const p=contactPath([{x:-5,y:0},{x:0,y:0}],{x:0,y:0},1);assert.equal(p.at(-1).x,-2);
|
||
const g=new Game(LEVELS[0]),before=clone(g.tiles),blocked=g.tiles.find(t=>trace(g.level,t,g.tiles).status==='blocked');
|
||
assert.ok(blocked);g.tap(blocked.id);assert.deepEqual(g.tiles,before);
|
||
});
|
||
|
||
test('rendered collision moves the blocker only during impact and returns both sprites',async()=>{
|
||
const {drawScene}=await import('../src/renderer.js');const l=LEVELS[0],g=new Game(l),t=g.tiles.find(t=>trace(l,t).status==='blocked'),route=g.tap(t.id),v=layout(l.cells,390,390);
|
||
const capture=progress=>{const sprites=[];const p={sprite:(index,x,y,r)=>{sprites.push({index,x,y,r});return true;},polygon(){},line(){},circle(){}};drawScene(p,l,g.tiles,v,{animation:{route,progress}});return sprites;};
|
||
const a=capture(0),impact=capture(.48),end=capture(1);
|
||
assert.deepEqual(end,a);assert.ok(impact.some((s,i)=>Math.abs(s.x-a[i].x)+Math.abs(s.y-a[i].y)>.1));
|
||
const blocker=g.tiles.find(t=>t.id===route.blocker),b=center(blocker,v);
|
||
assert.ok(a.some(s=>s.x===b.x&&s.y===b.y));assert.ok(!impact.some(s=>s.x===b.x&&s.y===b.y));
|
||
});
|
||
|
||
test('exposed track strokes and their round caps stay inside the board edge',async()=>{
|
||
const {drawScene}=await import('../src/renderer.js');const l=level([{q:0,r:0}],[]),v=layout(l.cells,300,300),lines=[];
|
||
drawScene({polygon(){},circle(){},line(points,color,width){lines.push({points,width});}},l,[],v);
|
||
const normals=[{x:0,y:1},{x:Math.sqrt(3)/2,y:.5},{x:Math.sqrt(3)/2,y:-.5}],limit=v.size*.965*Math.sqrt(3)/2;
|
||
for(const {points,width} of lines.filter(l=>l.width>=2))for(const p of points)for(const n of normals)assert.ok(Math.abs((p.x-v.ox)*n.x+(p.y-v.oy)*n.y)+width/2<=limit+1e-8);
|
||
});
|
||
|
||
test('all board faces render before tracks and neighboring rail endpoints coincide',async()=>{
|
||
const {drawScene}=await import('../src/renderer.js');const l=level([{q:0,r:0},{q:0,r:-1}],[]),v=layout(l.cells,300,300),commands=[];
|
||
drawScene({polygon(){commands.push({kind:'face'});},circle(){},line(points,color){commands.push({kind:'rail',points,color});}},l,[],v);
|
||
assert.deepEqual(commands.slice(0,2).map(c=>c.kind),['face','face']);assert.ok(commands.slice(2).every(c=>c.kind==='rail'));
|
||
const a=center(l.cells[0],v),b=center(l.cells[1],v),joint={x:(a.x+b.x)/2,y:(a.y+b.y)/2};
|
||
const cores=commands.filter(c=>c.color===THEME.track);
|
||
assert.equal(cores.filter(c=>[c.points[0],c.points.at(-1)].some(p=>Math.hypot(p.x-joint.x,p.y-joint.y)<1e-6)).length,2);
|
||
});
|