Restore v0.1 and v0.2 archives with separate progress, publish the curved v0.3 campaign, and document the original 24-level design with reproducible evidence. Refine board presentation and movement, and keep video lab tooling within its own directory.
171 lines
14 KiB
JavaScript
171 lines
14 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 { 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('path usage counts successful bends and ignores decorative tracks outside the traveled route',()=>{
|
|
const bent=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 p=analyze(bent).pathUsage;assert.equal(p.curvedRemovals,1);assert.equal(p.singleBendRemovals,1);assert.equal(p.totalBends,1);assert.equal(p.curvedRatio,1);
|
|
const decoration=level([{q:0,r:0},{q:1,r:0,tracks:[1,0,3,2,5,4]}],[tile('a',0,0)]);
|
|
const unused=analyze(decoration).pathUsage;assert.equal(unused.curvedRemovals,0);assert.equal(unused.straightRemovals,1);assert.equal(unused.curvedRatio,0);
|
|
});
|
|
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<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);
|
|
});
|
|
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('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);
|
|
});
|