Files
HexTrails/src/deep-lock.js
T
cabinwandClaude Opus 5 912bec29b3 feat(levels): reject one-orientation endgames, and measure the result
Every model that played the D5 set complained about the same moment: two
pieces left, interlocked, and nothing on the board saying which way to turn.
codex-spark took it to the end — it cycled all six orientations on L18 and
gave up on a level that was solvable, with clarity rated 5. Replaying the
position shows exactly one of the six orientations releases anything.

deadTurnAudit now reports endgameMinOpen: across reachable states with three
pieces or fewer left, the fewest orientations that release anything. It is
computed inside the walk that was already happening, so it costs nothing. The
generator requires at least two.

The gate picks out precisely the four levels every model stalled on, with no
false positives — the other fourteen already had two or three. Regenerating
drops the spiral family entirely: its structure produces single-exit endgames
by construction, not by unlucky seed.

Measured on the regenerated set, rule bot first and then grok-4.6 xhigh over
all 18:

  blind rotations   1.17 -> 0.29 per level
  extra rotations   1.22 -> 0.35
  dead taps         2 -> 0
  run time          55.5s -> 34.7s
  stuck rating      1.89 -> 1.41   (this metric repeats at r = 0.85)
  "which way do I turn" complaints   22% -> 12% of levels

The gate is a floor, not a difficulty dial: difficulty also fell, 2.39 -> 2.12,
because the levels it removes are the hard ones. And it treats a symptom — the
t0/t3 interlock is what the four-piece chain in createDeepLock builds by
construction, so the complaint is halved rather than gone.

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

239 lines
12 KiB
JavaScript

import { available, key, opposite, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js';
const positionKey = tile => `${tile.q},${tile.r}`;
const countBends = route => route.steps.filter(step => step.enter !== null && opposite(step.enter) !== step.exit).length;
function shuffled(items, rng) {
const result = [...items];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
// Every candidate carries its independent route at orientations 0, 1 and 2.
// Grid membership only proposes a lock; the swept tracer certifies it below.
function candidatePool(base, boards, maxBends) {
const pool = [];
for (const cell of base.cells) for (let dir = 0; dir < 6; dir++) {
const tile = { q: cell.q, r: cell.r, dir };
const routes = [0, 1, 2].map(turn => trace(boards[turn], tile, [], true));
if (routes.some(route => route.status !== 'exit')) continue;
const bends = routes.map(countBends);
if (bends[1] > maxBends) continue;
pool.push({
tile, position: positionKey(tile), bends, cells: routes.map(route => route.steps.length),
at: routes.map(route => new Set(route.steps.slice(1).map(positionKey))),
});
}
return pool;
}
// A/B/C/D is a three-turn chain, not three independent long routes.
// At 0 all four are locked. At +1 only B can leave. At +2 only C can leave;
// A and D still hold each other. Turning back to +1 frees A, then D.
// The solver certifies that no one- or two-turn plan clears the board.
export function createDeepLock(base, {
seed = 0, minHeroBends = 6, maxHeroBends = 70, maxChecks = 2000000,
} = {}) {
const rng = random(seed);
const boards = [0, 1, 2].map(turn => rotateBoard(base, turn));
const pool = candidatePool(base, boards, maxHeroBends);
const candidates = shuffled(pool, rng);
const heroes = candidates.filter(c => c.bends[1] >= minHeroBends);
let checks = 0, formalChecks = 0;
for (const a of heroes) {
// D holds A at 0 and at +2, and never stands on A's +1 departure.
const tails = candidates.filter(d => d.position !== a.position
&& a.at[0].has(d.position) && d.at[0].has(a.position)
&& a.at[2].has(d.position) && d.at[2].has(a.position)
&& !a.at[1].has(d.position));
for (const d of tails) {
// C holds A at +1 and leaves alone at +2 once B is gone.
const seconds = candidates.filter(c => c.position !== a.position && c.position !== d.position
&& a.at[1].has(c.position) && !c.at[2].has(a.position) && !c.at[2].has(d.position));
for (const c of seconds) {
// B is A's blocker at 0 and is the only piece that leaves at +1.
const gates = candidates.filter(b => b.position !== a.position && b.position !== c.position && b.position !== d.position
&& a.at[0].has(b.position) && !b.at[1].has(a.position) && !b.at[1].has(c.position) && !b.at[1].has(d.position)
&& (c.at[1].has(a.position) || c.at[1].has(d.position) || c.at[1].has(b.position)));
for (const b of gates) {
if (++checks > maxChecks) return null;
const tiles = [a, b, c, d].map((candidate, i) => ({ ...candidate.tile, id: `t${i}`, color: i }));
const [hero, gate, second, tail] = tiles;
formalChecks++;
// Formal certification of every stage with the production swept tracer.
if (tiles.some(tile => trace(boards[0], tile, tiles).status === 'exit')) continue;
if (trace(boards[1], gate, tiles).status !== 'exit') continue;
const afterGate = [hero, second, tail];
if (afterGate.some(tile => trace(boards[1], tile, afterGate).status === 'exit')) continue;
if (trace(boards[2], second, afterGate).status !== 'exit') continue;
const afterSecond = [hero, tail];
if (afterSecond.some(tile => trace(boards[2], tile, afterSecond).status === 'exit')) continue;
if (trace(boards[1], hero, afterSecond).status !== 'exit') continue;
if (trace(boards[1], tail, [tail]).status !== 'exit') continue;
const level = { ...base, tiles, moveLimit: tiles.length };
const solution = solveRotations(level);
if (!solution.solvable || solution.exhausted || solution.rotations !== 3) continue;
return {
tiles, placements: [1, 1, 2, 1], hero,
heroRoute: { bends: a.bends[1], cells: a.cells[1] },
actions: [
{ type: 'rotate', direction: 1 }, { type: 'tap', id: gate.id },
{ type: 'rotate', direction: 1 }, { type: 'tap', id: second.id },
{ type: 'rotate', direction: -1 }, { type: 'tap', id: hero.id }, { type: 'tap', id: tail.id },
],
minimumRotations: solution.rotations, solution,
search: { checks, formalChecks, heroCandidates: heroes.length, poolSize: pool.length },
};
}
}
}
}
return null;
}
// Reverse insertion keeps the chain intact while raising how much of the
// board depends on turning. Each inserted piece must leave at its scheduled
// orientation, and the solver must still need exactly three turns afterwards.
export function growDeepLevel(base, lock, {
seed = 0, target = 12, layerShares = { 0: .35, 1: .35, 2: .3 }, maxHeroBends = 70, minRotations = 3,
} = {}) {
const rng = random(seed + 7919);
const boards = [0, 1, 2].map(turn => rotateBoard(base, turn));
// A layer piece only needs a departure at its own orientation. Requiring
// exits at every orientation would leave almost nothing to insert.
const layerPool = boards.map(board => {
const pool = [];
for (const cell of base.cells) for (let dir = 0; dir < 6; dir++) {
const tile = { q: cell.q, r: cell.r, dir }, route = trace(board, tile, [], true);
if (route.status === 'exit' && countBends(route) <= maxHeroBends) pool.push({ tile, position: positionKey(tile), bends: countBends(route) });
}
return pool;
});
const tiles = [...lock.tiles], placements = [...lock.placements];
const extras = Math.max(0, target - tiles.length);
const schedule = [];
for (const [turn, share] of Object.entries(layerShares)) for (let i = 0; i < Math.round(extras * share); i++) schedule.push(Number(turn));
while (schedule.length < extras) schedule.push(0);
schedule.length = extras;
let rejected = { orientationless: 0, shortcut: 0 };
for (const orientation of shuffled(schedule, rng)) {
const occupied = new Set(tiles.map(positionKey));
const choices = shuffled(layerPool[orientation].filter(c => !occupied.has(c.position)), rng);
let chosen = null, fallback = null;
for (const c of choices) {
const tile = { ...c.tile, id: `t${tiles.length}`, color: tiles.length % 7 }, proposed = [...tiles, tile];
if (trace(boards[orientation], tile, proposed).status !== 'exit') { rejected.orientationless++; continue; }
// Prefer pieces that genuinely wait for this turn: blocked at every earlier orientation.
const waits = [...Array(orientation).keys()].every(earlier => trace(boards[earlier], tile, proposed).status !== 'exit');
if (!waits && fallback) continue;
const solution = solveRotations({ ...base, tiles: proposed, moveLimit: proposed.length });
if (!solution.solvable || solution.exhausted || solution.rotations < minRotations) { rejected.shortcut++; continue; }
if (waits) { chosen = tile; break; }
fallback = tile;
}
chosen = chosen || fallback;
if (!chosen) break;
tiles.push(chosen); placements.push(orientation);
}
const level = {
...base, tiles, moveLimit: tiles.length,
name: `${(base.name || 'route').replace(' · 路线草图', '')} · 深旋转`,
experience: { kind: 'deep', feature: { id: lock.hero.id } },
};
const solution = solveRotations(level);
level.experience.actions = solution.actions;
const errors = validateLevel(level);
if (errors.length) throw new Error(errors.join('; '));
return { level, placements, solution, rejected };
}
// Structural evidence for the difficulty bands: how much of the board waits
// for a turn, how many pieces each turn releases, and whether the direction
// of the first turn is a real choice.
export function deepLevelStats(level) {
const n = level.tiles.length, solution = solveRotations(level);
let board = level, remaining = [...level.tiles];
const drain = () => { for (;;) { const free = available(board, remaining); if (!free.length) return; const ids = new Set(free.map(t => t.id)); remaining = remaining.filter(t => !ids.has(t.id)); } };
drain();
const lockedAtStart = remaining.length;
const openings = [1, -1].filter(direction => available(rotateBoard(board, direction), remaining).length > 0).length;
const releases = [];
for (const action of solution.actions) {
if (action.type !== 'rotate') continue;
const before = remaining.length;
board = rotateBoard(board, action.direction);
drain();
releases.push(before - remaining.length);
}
return {
pieces: n, rotations: solution.rotations, lockedShare: lockedAtStart / n, lockedAtStart,
firstTurnDirections: openings, releasePerTurn: releases, minRelease: releases.length ? Math.min(...releases) : 0,
waves: solution.waves.map(wave => wave.length), states: solution.states,
};
}
// Player-side audit over every reachable state (any tap order, any turn
// sequence). Removal only reduces occupancy and turns are reversible, so no
// reachable state can be unsolvable — the audit proves it rather than assuming
// it — and it measures how many blind turns a player can run into.
// A level is judged on its endgame with this many pieces or fewer left.
const ENDGAME_PIECES = 3;
export function deadTurnAudit(level, maxStates = 20000) {
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
const keyOf = (o, rem) => o + ':' + [...rem].sort().join(',');
const start = { o: 0, rem: new Set(level.tiles.map(t => t.id)) };
const seen = new Map([[keyOf(0, start.rem), start]]), queue = [start];
let stuckStates = 0, doubleDead = 0, maxDeadTurns = 0, unsolvable = 0;
// The endgame is where every model complained: two pieces left, nothing on
// the board saying which way to turn. Count how many of the six orientations
// release anything at all — one is a 1-in-6 guess with a dead end behind
// every other door, which is what made an agent give up on a solvable level.
const openCache = new Map();
let endgameMinOpen = 6, endgameTightStates = 0, endgameWorst = null;
const openOrientations = rem => {
const k = [...rem].sort().join(',');
if (openCache.has(k)) return openCache.get(k);
const tiles = level.tiles.filter(t => rem.has(t.id));
let n = 0;
for (let o = 0; o < 6; o++) if (available(boards[o], tiles).length) n++;
openCache.set(k, n);
return n;
};
while (queue.length && seen.size < maxStates) {
const state = queue.shift();
if (!state.rem.size) continue;
if (state.rem.size <= ENDGAME_PIECES) {
const open = openOrientations(state.rem);
if (open <= 1) endgameTightStates++;
if (open < endgameMinOpen) { endgameMinOpen = open; endgameWorst = [...state.rem].sort().join(','); }
}
const tiles = level.tiles.filter(t => state.rem.has(t.id)), free = available(boards[state.o], tiles);
if (!free.length) {
stuckStates++;
if (![1, -1].some(d => available(boards[(state.o + d + 6) % 6], tiles).length)) doubleDead++;
let nearest = 6;
for (const d of [1, -1]) for (let k = 1; k < 6; k++) if (available(boards[(state.o + d * k + 6) % 6], tiles).length) { nearest = Math.min(nearest, k); break; }
if (nearest === 6) unsolvable++; else maxDeadTurns = Math.max(maxDeadTurns, nearest - 1);
}
for (const t of free) {
const rem = new Set(state.rem); rem.delete(t.id);
const k = keyOf(state.o, rem);
if (!seen.has(k)) { const next = { o: state.o, rem }; seen.set(k, next); queue.push(next); }
}
for (const d of [1, -1]) {
const o = (state.o + d + 6) % 6, k = keyOf(o, state.rem);
if (!seen.has(k)) { const next = { o, rem: state.rem }; seen.set(k, next); queue.push(next); }
}
}
return { states: seen.size, stuckStates, doubleDead, maxDeadTurns, unsolvable,
endgameMinOpen: endgameMinOpen === 6 && !openCache.size ? null : endgameMinOpen,
endgameTightStates, endgameWorst, exhausted: seen.size >= maxStates };
}
export const deepLevelKey = level => level.tiles.map(t => `${t.q},${t.r},${t.dir}`).sort().join('|');
export { key as cellKey };