Files
HexTrails/tests/deep-lock.test.js
T
cabinwandClaude Opus 5 643cba0ed9 feat(levels): D5 three-rotation prototype
The shipped 1000 levels have a hole: 568 of them need no rotation at all, and
none needs more than two — the mechanic the game is built on is mostly
decoration. This fills the empty band with levels that need at least three.

createDeepLock builds a four-piece chain where each link only releases at a
later orientation, and growDeepLevel packs the rest of the board around it
without opening a shortcut. Candidate pools are built per orientation and left
untruncated; ranking by bend count first starved the packer and capped levels
at four to six pieces instead of twelve.

deadTurnAudit enumerates every reachable (orientation, remaining-set) state to
prove no dead ends exist, and counts how long a player can be left with
nothing to do — solvable and feeling solvable are different things, which is
what "level 2 is impossible" turned out to be about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:00:33 +08:00

79 lines
4.3 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { Game, rotateBoard, solveRotations, trace, validateLevel } from '../src/core.js';
import { createRouteBoard } from '../src/route-grammar.js';
import { createDeepLock, growDeepLevel, deepLevelStats, deadTurnAudit } from '../src/deep-lock.js';
// Seeds known to yield a chain lock; the sweep script records the full yield table.
const WITNESSES = [
{ family: 'orbits', radius: 3, seed: 0 },
{ family: 'weave', radius: 4, seed: 0 },
{ family: 'spiral', radius: 4, seed: 1 },
{ family: 'switchback', radius: 4, seed: 0 },
];
for (const { family, radius, seed } of WITNESSES) {
test(`${family} r${radius}: three-turn chain is certified stage by stage and by the solver`, () => {
const base = createRouteBoard(seed, { family, radius });
const snapshot = JSON.stringify(base);
const lock = createDeepLock(base, { seed });
assert.ok(lock, `${family}, radius ${radius}, seed ${seed}`);
assert.equal(JSON.stringify(base), snapshot, 'The helper must not alter its input route');
assert.equal(lock.minimumRotations, 3);
assert.ok(lock.heroRoute.bends >= 6);
const level = { ...base, tiles: lock.tiles, moveLimit: lock.tiles.length };
assert.deepEqual(validateLevel(level), []);
assert.equal(solveRotations(level).rotations, 3, 'No one- or two-turn plan may clear the chain');
const [hero, gate, second, tail] = lock.tiles;
const at = turn => rotateBoard(level, turn);
assert.ok(lock.tiles.every(tile => trace(level, tile).status === 'blocked'), 'Everything is locked at the opening');
assert.equal(trace(at(1), gate).status, 'exit', 'Only the gate leaves at +1');
assert.ok([hero, second, tail].every(tile => trace(at(1), tile, [hero, second, tail]).status === 'blocked'));
assert.equal(trace(at(2), second, [hero, second, tail]).status, 'exit', 'The second gate leaves at +2');
assert.equal(trace(at(2), hero, [hero, tail]).status, 'blocked', 'Hero and tail hold each other at +2');
assert.equal(trace(at(2), tail, [hero, tail]).status, 'blocked');
assert.equal(trace(at(1), hero, [hero, tail]).status, 'exit', 'Turning back frees the hero');
const game = new Game(level);
for (const action of lock.actions) {
if (action.type === 'rotate') game.rotate(action.direction);
else assert.equal(game.tap(action.id).status, 'exit');
}
assert.equal(game.tiles.length, 0, 'The authored three-turn witness must fully clear the board');
assert.equal(game.rotations, 3);
});
test(`${family} r${radius}: growth keeps three turns and locks most of the board`, () => {
const base = createRouteBoard(seed, { family, radius });
const lock = createDeepLock(base, { seed });
const target = radius === 3 ? 9 : 12;
const { level, solution, placements } = growDeepLevel(base, lock, { seed, target });
assert.deepEqual(validateLevel(level), []);
assert.ok(level.tiles.length >= target - 2, `grew to ${level.tiles.length} of ${target}`);
assert.equal(placements.length, level.tiles.length);
assert.ok(solution.solvable && !solution.exhausted);
assert.ok(solution.rotations >= 3, 'Inserted pieces must not open a shorter plan');
const stats = deepLevelStats(level);
assert.ok(stats.lockedShare >= .5, `locked share ${stats.lockedShare}`);
assert.equal(stats.releasePerTurn.length, stats.rotations);
// Every state a player can reach — any tap order, any turn sequence — must still clear.
const audit = deadTurnAudit(level);
assert.ok(!audit.exhausted, 'audit must enumerate every reachable state');
assert.equal(audit.unsolvable, 0, 'no reachable state may be a dead end');
const game = new Game(level);
for (const action of level.experience.actions) {
if (action.type === 'rotate') game.rotate(action.direction);
else assert.equal(game.tap(action.id).status, 'exit');
}
assert.ok(game.won, 'The solver reference must clear the grown level');
});
}
test('deep lock search is deterministic and an unmet constraint returns no lock', () => {
const base = createRouteBoard(0, { family: 'orbits', radius: 3 });
assert.deepEqual(createDeepLock(base, { seed: 5 }), createDeepLock(base, { seed: 5 }));
assert.equal(createDeepLock(base, { minHeroBends: 999 }), null);
assert.equal(createDeepLock(base, { maxChecks: 0 }), null);
});