Files
cabinw e92cccba82 feat: version campaigns and document original puzzle design
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.
2026-09-09 13:17:30 +08:00

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);
});