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 { 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}); const blockedFixture=()=>level(hexagon(2),[tile('moving',1,1,0),tile('beside',0,0,0)]); const rotatingLoopFixture=()=>{ const bends=[{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]}]; return level(hexagon(1).map(c=>bends.find(b=>b.q===c.q&&b.r===c.r)??c),[tile('a',0,0,1)],1); }; 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,10);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('a solver plan remains executable with exactly one move per successful removal',()=>{ const l=rotatingLoopFixture(),a=analyze(l),g=new Game(l); assert.equal(a.solvable,true);assert.equal(a.rotations,1);assert.equal(a.minimumMoves,1);assert.equal(a.minimumActions,2); 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.equal(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=rotatingLoopFixture();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=12,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);} for(let color=0;color<12;color++){const l=clone(good);l.tiles[0].color=color;assert.deepEqual(validateLevel(l),[]);} 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=blockedFixture(),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 ignores spare moves while custom removal shortages still fail',()=>{ const l=blockedFixture(),before=analyze(l);l.moveLimit=1;const a=analyze(l); assert.equal(a.solvable,false);assert.equal(a.reachable,true);assert.equal(a.score,before.score);assert.deepEqual(a.parts,before.parts);assert.ok(a.score>=0&&a.score<=100); assert.equal(a.sweptOnlyBlockers,1);assert.equal(a.parts.sweptBlockers,6); }); test('board area and piece count add workload without adding intrinsic difficulty',()=>{ const small=analyze(level(hexagon(1),[tile('a',0,0)])),large=analyze(level(hexagon(3),[tile('a',0,0)])); assert.equal(small.score,0);assert.equal(large.score,small.score);assert.ok(large.workload.routeCells>small.workload.routeCells); const outward=[tile('a',0,-1,0),tile('b',1,-1,1),tile('c',1,0,2),tile('d',0,1,3),tile('e',-1,1,4),tile('f',-1,0,5)]; const many=analyze(level(hexagon(1),outward)); assert.equal(many.score,small.score);assert.equal(many.freeRatio,1);assert.equal(many.workload.minimumActions,6);assert.ok(many.workload.estimatedSeconds>small.workload.estimatedSeconds); }); test('six board rotations restore tracks; tokens and arrows stay fixed',()=>{ const l=rotatingLoopFixture();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.equal(g.rotations,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=rotatingLoopFixture(),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{ 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('unlimited clockwise and counterclockwise observation preserves the removal budget',()=>{ const l=rotatingLoopFixture(),g=new Game(l); for(let i=0;i<120;i++)assert.equal(g.rotate(i%2?1:-1).status,'rotated'); assert.equal(g.moves,1);assert.equal(g.rotations,120);assert.equal(g.orientation,0);assert.equal(g.lost,false); assert.equal(g.rotate(0).status,'invalid-direction');assert.equal(g.history.length,120); assert.ok(g.undo());assert.equal(g.moves,1);assert.equal(g.rotations,119);assert.equal(g.orientation,5); }); 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('loop probes are free and undo restores probes, rotations, and successful removals',()=>{ const g=new Game(rotatingLoopFixture()),initial=clone(g.level); for(let i=0;i<4;i++)assert.equal(g.tap('a').status,'loop'); assert.equal(g.moves,1);assert.equal(g.mistakes,4);assert.equal(g.lost,false); assert.ok(g.undo());assert.equal(g.mistakes,3);assert.equal(g.moves,1); assert.equal(g.rotate(1).status,'rotated');assert.equal(g.tap('a').status,'exit');assert.ok(g.won);assert.equal(g.moves,0); assert.ok(g.undo());assert.equal(g.won,false);assert.equal(g.moves,1);assert.equal(g.orientation,1);assert.equal(g.rotations,1);assert.equal(g.mistakes,3); assert.ok(g.undo());assert.equal(g.orientation,0);assert.equal(g.rotations,0);assert.deepEqual(g.level,initial);assert.equal(g.mistakes,3); }); 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(blockedFixture()),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=blockedFixture(),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); });