Files
HexTrails/tests/deep-lock.test.js
T
cabinwandClaude Opus 5 acc374e276 feat(levels): two-sided endgame release, and a board to track the 50-level run
The chain built its own worst moment: hero and tail deadlock at orientations 0
and +2 and open only at +1, with +3/+4/+5 left entirely to chance. That is why
some levels released at exactly one of six orientations, and why every model
kept writing "I don't know which way to turn" — at +2 one direction was right
and the other five were dead ends.

createDeepLock now traces candidates at all six orientations and requires the
tail to also release at +3, certified with the real swept tracer. A player
stuck at +2 turns either way and something moves. Measured by the rule bot
over the regenerated set: blind rotations, wrong-way turns and extra rotations
all go to zero, from 1.17 / 0.44 / 1.22 per level. Endgame orientation counts
shift from {1:4, 2:10, 3:4} to {2:3, 3:13, 4:1}.

It costs yield: switchback r3 and crossing r3 now produce nothing, and the set
comes out at 17 levels rather than 18. The test witnesses moved with it — a
witness is whichever seed still satisfies the construction, so they are
re-derived from the sweep rather than pinned.

docs/level-board.html is the task board for getting to 50 levels. Task state is
in bench/board.json; every number on the page — candidate count, rotation
quota, blind turns, monkey actions-per-piece, the agent batches — is measured
from the level set and the run archive at build time.

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

82 lines
4.5 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. These moved when the tail gained its two-sided release requirement —
// a witness is whichever seed still satisfies the current construction, not a
// fixed number, so re-derive them from the sweep after changing the lock.
const WITNESSES = [
{ family: 'orbits', radius: 3, seed: 0 },
{ family: 'weave', radius: 4, seed: 0 },
{ family: 'spiral', radius: 4, seed: 2 },
{ family: 'switchback', radius: 4, seed: 6 },
];
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);
});