feat: expand campaign and refine puzzle visuals and controls
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BoardViewport } from '../src/board-viewport.js';
|
||||
import { generateLaneLevel } from '../src/campaign-generator.js';
|
||||
import { Game, validateLevel, solveRotations } from '../src/core.js';
|
||||
import { layout, center, hitCell } from '../src/geometry.js';
|
||||
test('91 and 127 cell boards validate and their complete move sequences win',()=>{
|
||||
for(const [radius,count,target] of [[5,91,42],[6,127,60]]){
|
||||
const level=generateLaneLevel(606,{radius,target,axis:0});assert.equal(level.cells.length,count);assert.equal(level.tiles.length,target);assert.deepEqual(validateLevel(level),[]);
|
||||
const plan=solveRotations(level);assert.ok(plan.solvable);const game=new Game(level);
|
||||
for(const action of plan.actions){if(action.type==='tap')assert.equal(game.tap(action.id).status,'exit');else game.rotate(action.direction);}
|
||||
assert.ok(game.won);
|
||||
}
|
||||
});
|
||||
test('pinch anchoring, panning, and hit testing use the same transformed coordinates',()=>{
|
||||
const level=generateLaneLevel(606,{radius:5,target:42}),camera=new BoardViewport(),base=layout(level.cells,390,390);
|
||||
camera.zoom(2,120,150,390,390);assert.equal(camera.x,-120);assert.equal(camera.y,-150);
|
||||
camera.pan(30,-20,390,390);
|
||||
const view=camera.apply(base);
|
||||
for(const cell of level.cells){const p=center(cell,view);assert.equal(hitCell(level.cells,view,p.x,p.y),cell);}
|
||||
camera.zoom(99,195,195,390,390);assert.equal(camera.scale,4);
|
||||
camera.zoom(.001,195,195,390,390);assert.equal(camera.scale,1);assert.equal(camera.x,0);assert.equal(camera.y,0);
|
||||
});
|
||||
test('board zoom and vertical dragging keep the complete board visible after resizing',()=>{
|
||||
const level=generateLaneLevel(606,{radius:5,target:42}),camera=new BoardViewport();
|
||||
for(const [width,height] of [[334,620],[390,300],[334,720]]){
|
||||
const base=layout(level.cells,width,height);
|
||||
camera.fit(base,width,height);
|
||||
camera.zoom(99,40,30,width,height);
|
||||
assert.ok(camera.scale<=height/(base.bounds.bottom-base.bounds.top)+1e-9);
|
||||
for(const dy of [-10000,10000]){
|
||||
camera.pan(0,dy,width,height);
|
||||
assert.ok(base.bounds.top*camera.scale+camera.y>=-1e-9);
|
||||
assert.ok(base.bounds.bottom*camera.scale+camera.y<=height+1e-9);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { CAMPAIGN, LEVELS } from '../src/levels.js';
|
||||
import { Game, available, rotateBoard, trace, random, validateLevel } from '../src/core.js';
|
||||
import { inspectFlowLevel } from '../src/campaign-generator.js';
|
||||
|
||||
const reviewed=LEVELS.map(inspectFlowLevel);
|
||||
|
||||
test('48 authored levels share one campaign in the browser, JSON export and audit report',async()=>{
|
||||
assert.equal(LEVELS.length,48);assert.equal(CAMPAIGN.chapterSize,6);assert.equal(CAMPAIGN.chapters.length,8);
|
||||
assert.equal(new Set(LEVELS.map(l=>l.id)).size,48);
|
||||
assert.deepEqual(JSON.parse(await readFile(new URL('../levels/campaign.json',import.meta.url),'utf8')),LEVELS);
|
||||
const report=JSON.parse(await readFile(new URL('../levels/campaign-analysis.json',import.meta.url),'utf8'));
|
||||
assert.deepEqual(report.campaign,CAMPAIGN);assert.equal(report.levels.length,48);
|
||||
LEVELS.forEach((l,i)=>{
|
||||
assert.deepEqual(validateLevel(l),[],l.name);
|
||||
assert.equal(l.chapter,Math.floor(i/6)+1);assert.equal(l.chapterName,CAMPAIGN.chapters[Math.floor(i/6)]);
|
||||
assert.equal(l.moveLimit,l.tiles.length);
|
||||
assert.equal(l.pacing.cognitiveDifficulty,reviewed[i].analysis.score);
|
||||
assert.equal(report.levels[i].difficulty,reviewed[i].analysis.score);
|
||||
assert.equal(report.levels[i].tiles,l.tiles.length);
|
||||
});
|
||||
});
|
||||
|
||||
test('every campaign solution finishes, avoids hidden blockers, and needs at most one turn between removals',()=>{
|
||||
reviewed.forEach((q,i)=>{
|
||||
const label=LEVELS[i].name;
|
||||
assert.ok(q.analysis.solvable&&!q.analysis.exhausted&&q.won,label);
|
||||
assert.equal(q.independentLoops,0,`${label}: solitary piece must always have an exit in all six orientations`);
|
||||
assert.equal(q.hiddenBlockerStates,0,`${label}: blocked routes must identify a piece on that route`);
|
||||
assert.ok(q.maxDryRotations<=1&&q.maxUnlockDistance<=1,label);
|
||||
assert.ok(q.analysis.free>=2,`${label}: at least two immediate opening choices`);
|
||||
});
|
||||
});
|
||||
|
||||
test('first level is spacious and every remaining tile can leave after arbitrary free rotations',()=>{
|
||||
const l=LEVELS[0];assert.ok(l.cells.length>=37);assert.ok(l.tiles.length>=12);
|
||||
for(let orientation=0;orientation<6;orientation++)for(const reverse of [false,true]){
|
||||
const g=new Game(rotateBoard(l,orientation)),order=g.tiles.map(t=>t.id);
|
||||
if(reverse)order.reverse();
|
||||
for(const id of order){
|
||||
for(let n=0;n<5;n++)assert.equal(g.rotate(reverse?-1:1).status,'rotated');
|
||||
assert.equal(available(g.level,g.tiles).length,g.tiles.length,'No mysterious final-piece lock or forced removal order');
|
||||
assert.equal(g.moves,g.tiles.length);assert.equal(g.tap(id).status,'exit');
|
||||
}
|
||||
assert.ok(g.won);assert.equal(g.moves,0);
|
||||
}
|
||||
});
|
||||
|
||||
test('exploring orientations and alternative safe removal orders cannot soft-lock the campaign',()=>{
|
||||
for(const l of LEVELS)for(let run=0;run<4;run++){
|
||||
const rng=random(l.seed+run*997),g=new Game(l);
|
||||
while(g.tiles.length){
|
||||
const budget=g.moves;
|
||||
for(let i=0,n=Math.floor(rng()*6);i<n;i++)assert.equal(g.rotate(rng()<.5?-1:1).status,'rotated');
|
||||
assert.equal(g.moves,budget);
|
||||
const blocked=g.tiles.find(t=>trace(g.level,t,g.tiles).status!=='exit');
|
||||
if(blocked){g.tap(blocked.id);assert.equal(g.moves,budget);assert.equal(g.lost,false);}
|
||||
let choices=available(g.level,g.tiles);
|
||||
for(let turns=0;!choices.length&&turns<5;turns++){g.rotate(1);choices=available(g.level,g.tiles);}
|
||||
assert.ok(choices.length,`${l.name}: exploration must retain a reachable exit`);
|
||||
assert.equal(g.tap(choices[Math.floor(rng()*choices.length)].id).status,'exit');
|
||||
assert.equal(g.moves,g.tiles.length);
|
||||
}
|
||||
assert.ok(g.won,l.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('each chapter releases cognitive pressure after its small challenge while the larger finale lasts longer',()=>{
|
||||
for(let chapter=0;chapter<8;chapter++){
|
||||
const block=LEVELS.slice(chapter*6,chapter*6+6),q=reviewed.slice(chapter*6,chapter*6+6).map(r=>r.analysis);
|
||||
assert.deepEqual(block.map(l=>l.beat),['进入','换路','展开','小挑战','释放','长局回报']);
|
||||
assert.ok(q[1].rotations>0&&q[3].rotations>0,'small boards retain meaningful route changes');
|
||||
assert.ok(q[4].score<=q[3].score-12&&q[5].score<=q[3].score-10,'challenge is followed by a clear release');
|
||||
assert.ok(block[5].cells.length>block[3].cells.length&&block[5].tiles.length>block[3].tiles.length);
|
||||
assert.ok(q[5].workload.estimatedSeconds>q[3].workload.estimatedSeconds,'length is a separate structural estimate');
|
||||
assert.equal(q[5].rotations,0,'large finales allow a continuous sequence of removals');
|
||||
}
|
||||
});
|
||||
+47
-25
@@ -3,11 +3,15 @@ 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});
|
||||
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});
|
||||
@@ -15,7 +19,7 @@ test('bends follow the paired exit, never a crossing branch',()=>{
|
||||
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);
|
||||
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',()=>{
|
||||
@@ -28,17 +32,20 @@ test('mutual blocking reaches a fixed point; move shortage is separate',()=>{
|
||||
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 validate, solve and increase in score',()=>{
|
||||
let previous=-1;
|
||||
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>=previous);previous=a.score;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('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=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);}
|
||||
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',()=>{
|
||||
@@ -47,26 +54,35 @@ test('empty boards are not publishable wins and report a finite score',()=>{
|
||||
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));}
|
||||
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 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('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=clone(LEVELS[0]);assert.ok(canRotate(l));
|
||||
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-1);
|
||||
assert.ok(g.undo());assert.equal(g.orientation,0);assert.deepEqual(g.level.cells,before);assert.equal(g.moves,l.moveLimit);
|
||||
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=LEVELS[0],g=new Game(l);
|
||||
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');
|
||||
@@ -77,15 +93,18 @@ test('swept collision blocks a neighboring piece even when the center-line visit
|
||||
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);
|
||||
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('rotation step cost is included in the minimum move budget',()=>{
|
||||
const l=clone(LEVELS[0]),a=solveRotations(l);assert.ok(a.rotations>0);
|
||||
l.moveLimit=a.minimumMoves-1;const tooShort=solveRotations(l);assert.equal(tooShort.reachable,true);assert.equal(tooShort.solvable,false);
|
||||
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);
|
||||
@@ -96,11 +115,14 @@ test('exit animation cannot pass through another piece beyond an irregular board
|
||||
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('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)]);
|
||||
@@ -112,12 +134,12 @@ test('collision animation pauses at contact, recoils, and returns without moving
|
||||
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');
|
||||
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=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 {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));
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Game, solve, validateLevel } from '../src/core.js';
|
||||
import { makeLabLevel } from '../labs/hex-video/fixture.js';
|
||||
|
||||
test('video lab fixture validates and can complete through real game actions', () => {
|
||||
const level = makeLabLevel();
|
||||
assert.deepEqual(validateLevel(level), []);
|
||||
assert.equal(level.tiles.length, 18);
|
||||
const solution = solve(level), game = new Game(level);
|
||||
assert.equal(solution.solvable, true);
|
||||
const blocked = game.tap('lab-0');
|
||||
assert.equal(blocked.status, 'blocked');
|
||||
assert.equal(game.moves, level.moveLimit);
|
||||
for (const id of solution.order) assert.equal(game.tap(id).status, 'exit');
|
||||
assert.equal(game.won, true);
|
||||
assert.equal(game.moves, 0);
|
||||
assert.equal(game.undo(), true);
|
||||
assert.equal(game.won, false);
|
||||
assert.equal(game.tiles.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { TapQueue } from '../src/tap-queue.js';
|
||||
import { Game, available } from '../src/core.js';
|
||||
import { LEVELS } from '../src/levels.js';
|
||||
test('rapid taps retain order, ignore duplicate/active pieces, and discard stale IDs',()=>{
|
||||
const queue=new TapQueue();
|
||||
assert.equal(queue.add('a','a'),false);
|
||||
queue.add('b');queue.add('gone');queue.add('c');assert.equal(queue.add('b'),false);
|
||||
const tiles=[{id:'c'},{id:'b'}];
|
||||
assert.equal(queue.take(tiles).id,'b');assert.equal(queue.take(tiles).id,'c');assert.equal(queue.take(tiles),null);
|
||||
queue.add('b');queue.clear();assert.equal(queue.take(tiles),null);
|
||||
});
|
||||
test('buffered removals are evaluated in order against the updated game, with undo intact',()=>{
|
||||
const game=new Game(LEVELS[0]),queue=new TapQueue();
|
||||
const free=available(game.level,game.tiles).slice(0,3);assert.ok(free.length>=2);
|
||||
for(const tile of free)queue.add(tile.id);
|
||||
const start=game.moves;
|
||||
while(queue.ids.length){const tile=queue.take(game.tiles);assert.equal(game.tap(tile.id).status,'exit');}
|
||||
assert.equal(game.moves,start-free.length);
|
||||
assert.ok(game.undo());assert.equal(game.moves,start-free.length+1);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PIGMENTS, LEVEL_THEMES, contrast, themeForScore } from '../src/design-tokens.js';
|
||||
import { Game, clone, validateLevel, available } from '../src/core.js';
|
||||
import { LEVELS } from '../src/levels.js';
|
||||
import { drawExit, drawScene, CanvasPainter, exitGeometry } from '../src/renderer.js';
|
||||
import { layout } from '../src/geometry.js';
|
||||
test('new pigments import without changing route solutions; out-of-range values are rejected',()=>{
|
||||
for(let color=0;color<12;color++){const level=clone(LEVELS[0]);level.tiles[0].color=color;assert.deepEqual(validateLevel(level),[]);assert.deepEqual(available(level).map(t=>t.id),available(LEVELS[0]).map(t=>t.id));}
|
||||
for(const color of [-1,12,1.5]){const level=clone(LEVELS[0]);level.tiles[0].color=color;assert.ok(validateLevel(level).length);}
|
||||
});
|
||||
test('scene text and all directional arrows remain readable across curated themes',()=>{
|
||||
for(const p of PIGMENTS)assert.ok(contrast(p.arrow,p.color)>=3,p.name);
|
||||
for(const t of LEVEL_THEMES){assert.ok(contrast(t.ink,t.background)>=4.5,t.name);assert.ok(t.palette.length>=3&&t.palette.length<=4);for(const i of t.palette)assert.ok(PIGMENTS[i]);}
|
||||
assert.deepEqual([29,30,49,50,69,70].map(n=>themeForScore(n).id),['lagoon','apricot','apricot','lavender','lavender','rose']);
|
||||
});
|
||||
test('theme assignments are stable after removal and undo; all new colors fall back from the four-frame atlas',()=>{
|
||||
const game=new Game(LEVELS[0]),view=layout(game.level.cells,390,390),theme=LEVEL_THEMES[1];
|
||||
const capture=()=>{const sprites=[];drawScene({polygon(){},line(){},circle(){},sprite(i,x,y){sprites.push([i,x,y]);return true;}},game.level,game.tiles,view,{theme});return sprites;};
|
||||
const before=capture();game.tap(available(game.level,game.tiles)[0].id);const after=capture();for(const entry of after)assert.ok(before.some(b=>JSON.stringify(b)===JSON.stringify(entry)));game.undo();assert.deepEqual(capture(),before);
|
||||
const painter=new CanvasPainter({});painter.atlas={complete:true,naturalWidth:1293};for(let i=4;i<12;i++)assert.equal(painter.sprite(i,0,0,10),false);
|
||||
});
|
||||
test('exit fragments stay near the actual board port, finish cleanly, and respect reduced motion',()=>{
|
||||
const game=new Game(LEVELS[0]),route=game.tap(available(game.level,game.tiles)[0].id),view=layout(game.level.cells,390,390);
|
||||
const shapes=[],p={polygon(pts,c){shapes.push({pts,c});},line(){},circle(){}};
|
||||
const edge=exitGeometry(route,view).edge;
|
||||
drawExit(p,route,view,.95,{...route.tile,color:10});assert.equal(shapes.length,6);
|
||||
for(const s of shapes){assert.equal(s.c,PIGMENTS[10].color);for(const point of s.pts)assert.ok(Math.hypot(point.x-edge.x,point.y-edge.y)<view.size);}
|
||||
shapes.length=0;drawExit(p,route,view,1,route.tile);assert.equal(shapes.length,0);
|
||||
drawExit(p,route,view,.95,route.tile,true);assert.equal(shapes.length,0);
|
||||
});
|
||||
|
||||
test('edge exits are brisk and longer routes preserve a bounded readable travel phase',async()=>{
|
||||
const {exitTiming}=await import('../src/exit-animation.js');
|
||||
const edge={status:'exit',steps:[{q:0,r:0,enter:null,exit:0}],end:{q:0,r:-1}};
|
||||
const short=exitTiming(edge);
|
||||
const long=exitTiming({...edge,steps:[edge.steps[0],{q:0,r:-1,enter:3,exit:0},{q:0,r:-2,enter:3,exit:0}]});
|
||||
assert.equal(short.duration,320);
|
||||
assert.ok(long.duration>short.duration&&long.duration<=980);
|
||||
for(const timing of [short,long])assert.ok(Math.abs(timing.duration*(1-timing.travelFraction)-180)<.001);
|
||||
});
|
||||
|
||||
|
||||
test('motion ramps gently at both ends, never reverses or overshoots',async()=>{
|
||||
const {smoothMotion}=await import('../src/exit-animation.js');
|
||||
const positions=Array.from({length:101},(_,i)=>smoothMotion(i/100));
|
||||
const steps=positions.slice(1).map((x,i)=>x-positions[i]);
|
||||
assert.equal(positions[0],0);assert.equal(positions.at(-1),1);
|
||||
assert.ok(steps.every(d=>d>=0));
|
||||
assert.ok(steps[0]<steps[49]/10&&steps.at(-1)<steps[49]/10);
|
||||
assert.equal(smoothMotion(-1),0);assert.equal(smoothMotion(2),1);
|
||||
});
|
||||
|
||||
|
||||
test('long routes regain original pacing while edge exits and the finish stay brisk',async()=>{
|
||||
const {exitTiming}=await import('../src/exit-animation.js');
|
||||
const route=n=>({status:'exit',steps:Array.from({length:n},(_,i)=>({q:0,r:-i,enter:i?3:null,exit:0}))});
|
||||
assert.equal(exitTiming(route(1)).duration,320);
|
||||
for(const [cells,expected] of [[5,975],[10,1400],[17,1700]]){
|
||||
const timing=exitTiming(route(cells));
|
||||
assert.equal(timing.duration,expected);
|
||||
assert.ok(Math.abs(timing.duration*(1-timing.travelFraction)-180)<.001);
|
||||
}
|
||||
const durations=Array.from({length:17},(_,i)=>exitTiming(route(i+1)).duration);
|
||||
assert.ok(durations.every((t,i)=>i===0||t>=durations[i-1]));
|
||||
});
|
||||
Reference in New Issue
Block a user