feat: add v2 queued taps and free board rotation

This commit is contained in:
cabinw
2026-09-09 12:37:18 +08:00
parent 9f8990b579
commit 4ea5949cfb
9 changed files with 91 additions and 23 deletions
+5 -5
View File
@@ -28,9 +28,8 @@ 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('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);}
@@ -62,7 +61,7 @@ test('six board rotations restore tracks; tokens and arrows stay fixed',()=>{
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.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',()=>{
@@ -83,8 +82,9 @@ 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',()=>{
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',()=>{
+22
View File
@@ -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);
});