Files
HexTrails/src/deep-lock.js
T
cabinwandClaude Opus 5 83796804d0 feat(levels): gate on crossing points, and lay the campaign out as segments
trackShape moves the two drawing measurements into src so the sweep, the pool
build, the selector and the gallery share one definition. They are one property
seen twice: a chord joining opposite ports is a straight line, and two of those
in a cell always cross, which is why the filler that paired leftovers straight
across produced a board that is both all-straight and all-crossings.

Crossings per cell is now a gate at 1.0. Bending the leftover ports alone takes
the median from 2.36 to 0.93, so the gate costs a third of the candidates rather
than most of them, and it is the difference the boards were described by.

Selection gains --plan, an ordered list of segments each naming how many levels
it wants and of what kind. Depth quotas cannot express the shape this campaign
needs, because the two good things pull against each other: a crowded board
clears beautifully and caps at three turns, while a deep lock needs room and so
stays sparse — at five turns and eight pieces per orientation, 16 of 18
candidates fail the release gate. Sorting by depth would file every crowded
level first and every deep one last; segments alternate them deliberately, and a
plan is left in the order it was written rather than re-sorted.

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

481 lines
25 KiB
JavaScript

import { Game, available, key, opposite, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js';
import { exitTiming } from './exit-animation.js';
import { MOTION } from './design-tokens.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 };
// All six orientations: the chain only constrains 0/1/2, but the endgame
// is played at 3/4/5 too, and leaving those to chance is what produced
// locks that open at exactly one of the six.
const routes = boards.map(board => trace(board, tile, [], true));
if (routes.slice(0, 3).some(route => route.status !== 'exit')) continue;
const bends = routes.map(r => r.status === 'exit' ? countBends(r) : Infinity);
if (bends[1] > maxBends) continue;
pool.push({
tile, position: positionKey(tile), bends, cells: routes.map(route => route.steps.length),
exits: routes.map(route => route.status === 'exit'),
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, 3, 4, 5].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.
// A and D deadlock at 0 and at +2 and open at +1 — that is the chain. The
// added requirement is that +3 opens too: a player stuck at +2 turns one
// way or the other and something moves either way, instead of one
// direction being right and the other five a dead end.
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)
&& ((a.exits[3] && !a.at[3].has(d.position)) || (d.exits[3] && !d.at[3].has(a.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;
// Certify the two-sided release with the real swept tracer, not the
// cell-occupancy approximation the filter above uses.
if (![hero, tail].some(t => trace(boards[3], t, afterSecond).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.
// Rotation is free and it is what the game is about, so depth is the thing to
// grow, not cap. Scheduling pieces at orientations 3/4/5 as well as 0/1/2 is
// what lets a level need five or six turns instead of always exactly three —
// and each of those extra turns pays out, because the piece scheduled there is
// waiting to leave.
export function growDeepLevel(base, lock, {
seed = 0, depth = 3, perTurn = 2, maxHeroBends = 70, minRotations = 3, target = null,
showcase = 2, showcaseMinDistance = 6, showcaseMaxDistance = 20,
} = {}) {
const rng = random(seed + 7919);
const boards = [0, 1, 2, 3, 4, 5].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];
// Depth comes from a quota per orientation, not from random shares. Shares
// left some orientations empty, and an orientation with nothing waiting is a
// turn that pays nothing — the one thing that reliably reads as boring.
const schedule = [];
for (let turn = 0; turn < depth; turn++) for (let i = 0; i < perTurn; i++) schedule.push(turn);
if (target != null) schedule.length = Math.max(0, Math.min(schedule.length, target - tiles.length));
let rejected = { orientationless: 0, shortcut: 0, unfilled: 0 };
for (const orientation of [...schedule].sort((a, b) => b - a)) {
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;
// One orientation running out of candidates must not abandon the rest.
if (!chosen) { rejected.unfilled++; continue; }
tiles.push(chosen); placements.push(orientation);
}
// The chain locks everything at the opening by construction, so a level built
// only from it hands a casual player two rim pieces and a third of a second
// of animation before the thinking starts. Reserve a couple of slots for
// pieces that are tappable immediately and whose route is worth watching:
// long enough to read as a journey, short enough not to hit the ceiling.
for (let i = 0; i < showcase; i++) {
const occupied = new Set(tiles.map(positionKey));
let best = null;
for (const c of shuffled(layerPool[0].filter(x => !occupied.has(x.position)), rng)) {
const tile = { ...c.tile, id: `t${tiles.length}`, color: tiles.length % 7 }, proposed = [...tiles, tile];
const route = trace(boards[0], tile, proposed);
if (route.status !== 'exit') continue;
const { distance } = exitTiming(route);
if (distance < showcaseMinDistance || distance > showcaseMaxDistance) continue;
// It must not shortcut the lock it is decorating.
const solution = solveRotations({ ...base, tiles: proposed, moveLimit: proposed.length });
if (!solution.solvable || solution.exhausted || solution.rotations < minRotations) continue;
if (!best || distance > best.distance) best = { tile, distance };
}
if (!best) break;
tiles.push(best.tile); placements.push(0);
}
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.
// How far a piece swings between "looks hopeless" and "walks straight out".
// Cost is what the eye judges from: cells travelled plus twice the bends. A
// piece that reads as a 128-cost tangle at one orientation and a 5-cost stroll
// at another is where the satisfaction comes from; families whose pieces all
// sit around the same cost no matter how the board turns feel mechanical.
export function contrastStats(level) {
const boards = [0, 1, 2, 3, 4, 5].map(k => rotateBoard(level, k));
const cost = route => route.status === 'exit' ? route.steps.length + 2 * countBends(route) : null;
const HOPELESS = 60;
const swings = [];
for (const tile of level.tiles) {
const costs = boards.map(board => cost(trace(board, tile, [], true)));
const reachable = costs.filter(c => c != null);
if (!reachable.length) continue;
const best = Math.min(...reachable);
const opening = trace(level, tile, level.tiles);
const seen = opening.status === 'exit' ? cost(opening) : (costs[0] ?? HOPELESS);
swings.push(seen - best);
}
if (!swings.length) return { contrast: 0, maxSwing: 0 };
return {
contrast: swings.reduce((a, b) => a + b, 0) / swings.length,
maxSwing: Math.max(...swings),
};
}
// What it costs to know what you may tap. A player verifies a piece by
// following its line until it leaves the board or meets somebody; length and
// bends are what that costs the eye. This is the difficulty the design brief
// asks for — 无法一下子判断 — and it is not board size: a wide board whose
// nearest free piece is three cells away reads instantly, while a small one
// where every free piece hides behind a thirty-cell switchback does not.
//
// Grazes are counted apart because they are not difficulty at all, they are a
// lie. A piece can have an empty line and still be held, by a neighbour the
// route never touches; following that line tells the player it is free, and
// the tap does nothing. That is the "many actions, no result" the brief rules
// out, so a level wants none of these at its opening.
export function legibility(level) {
const occupant = new Map(level.tiles.map(t => [`${t.q},${t.r}`, t.id]));
const costs = [];
let grazeTraps = 0;
for (const tile of level.tiles) {
const geo = trace(level, tile, [], true); // the drawn line, ignoring who sits on it
const real = trace(level, tile, level.tiles); // where the piece would actually stop
const onRoute = geo.steps.some(s => { const who = occupant.get(`${s.q},${s.r}`); return who && who !== tile.id; });
if (real.status !== 'exit' && !onRoute) grazeTraps++;
// Verification stops where the eye stops: at the blocker, or at the edge.
const seen = real.status === 'exit' ? geo : real;
costs.push({ id: tile.id, free: real.status === 'exit', cost: seen.steps.length + 2 * countBends(seen) });
}
const free = costs.filter(c => c.free).map(c => c.cost);
const all = costs.map(c => c.cost).sort((a, b) => a - b);
return {
glanceCost: free.length ? Math.min(...free) : null, // the cheapest correct move to find
medianVerify: all.length ? all[Math.floor(all.length / 2)] : 0,
grazeTraps,
};
}
// What the player actually watches. Rotation costs no steps precisely so that
// people try things; the payout is the animation of a piece threading a winding
// route and leaving. So the show is measurable: how long each departure plays,
// whether the level opens with one, and — the failure the design brief names
// outright — how many actions can pass with nothing happening at all.
// What counts as a cruise, and what counts as an opening worth watching, were
// both picked in milliseconds while a piece crossed one unit in 120ms. Speed is
// a tuning dial, so holding those milliseconds fixed while the dial moves does
// not hold the experience fixed — it silently demands longer routes. Raising
// the speed once made 20 of 50 shipped levels "fail" an opening gate they had
// passed, purely from the unscaled number. They scale with the dial instead.
const SHOW_CALIBRATED_AT = 120;
const showScale = () => MOTION.exitMsPerUnit / SHOW_CALIBRATED_AT;
export const CRUISE_MS_AT_120 = 900;
const CRUISE_MS = CRUISE_MS_AT_120 * showScale();
export const openingShowFloor = (msAt120 = 800) => msAt120 * showScale();
export function showStats(level) {
const solution = solveRotations(level);
if (!solution.solvable || !solution.actions) return null;
const game = new Game(level);
const shows = [];
let dry = 0, maxDry = 0, firstShow = null, sinceStart = 0;
for (const action of solution.actions) {
if (action.type === 'rotate') { game.rotate(action.direction); dry++; sinceStart++; maxDry = Math.max(maxDry, dry); continue; }
const result = game.tap(action.id);
sinceStart++;
if (result.status === 'exit') {
const ms = exitTiming(result).duration;
shows.push(ms);
if (firstShow === null) firstShow = { ms, afterActions: sinceStart };
dry = 0;
} else { dry++; maxDry = Math.max(maxDry, dry); }
}
if (!shows.length) return null;
const sorted = [...shows].sort((a, b) => a - b);
// What the player can see straight away if they pick well, rather than what
// the optimal script happens to tap first. An opening whose only departure is
// a piece already touching the rim plays for 0.3s and shows nothing.
const opening = new Game(level);
const openingShows = available(level, opening.tiles)
.map(t => exitTiming(trace(level, t, opening.tiles)).duration);
return {
exits: shows.length,
openingChoices: openingShows.length,
openingBestMs: openingShows.length ? Math.round(Math.max(...openingShows)) : 0,
totalMs: Math.round(shows.reduce((a, b) => a + b, 0)),
medianMs: Math.round(sorted[Math.floor(sorted.length / 2)]),
longestMs: Math.round(sorted[sorted.length - 1]),
// A cruise: long enough that watching it is the point, not a blink.
cruises: shows.filter(ms => ms >= CRUISE_MS).length,
firstShowMs: firstShow ? Math.round(firstShow.ms) : null,
firstShowAfter: firstShow ? firstShow.afterActions : null,
maxDryActions: maxDry,
};
}
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;
// How busy the drawing is, as two numbers taken from the same data. A cell's
// six ports are paired into three chords; a chord joining opposite ports is a
// diameter, drawn as a straight line, and two diameters in one cell always
// cross. So "the board is all straight lines" and "there are too many crossing
// points" are one property seen twice — which is why the filler that paired
// leftover ports straight across produced both at once.
export function trackShape(level) {
let pairs = 0, straight = 0, crossings = 0, busiest = 0;
for (const cell of level.cells) {
const seen = new Set(), chords = [];
for (let port = 0; port < 6; port++) {
if (seen.has(port)) continue;
const other = cell.tracks[port];
seen.add(port); seen.add(other);
chords.push([port, other]);
}
const between = (x, a, b) => { for (let k = (a + 1) % 6; k !== b; k = (k + 1) % 6) if (k === x) return true; return false; };
let here = 0;
for (let i = 0; i < chords.length; i++) {
pairs++;
if ((chords[i][0] + 3) % 6 === chords[i][1]) straight++;
for (let j = i + 1; j < chords.length; j++) {
const [a, b] = chords[i], [c, d] = chords[j];
if (between(c, a, b) !== between(d, a, b)) here++;
}
}
crossings += here;
busiest = Math.max(busiest, here);
}
return {
straightShare: pairs ? straight / pairs : 0,
crossPerCell: level.cells.length ? crossings / level.cells.length : 0,
crossings, busiestCell: busiest,
};
}
// The endgame question — with a handful of pieces left, how many of the six
// orientations release anything — used to be answered from the same breadth
// search that counts dead turns. On a dense board that search hits its state
// budget long before it reaches any endgame, so it reported `null`, and the
// gate `endgameMinOpen >= 3` read null as a failure. Every level past about
// eleven pieces was rejected for an endgame nobody had measured. That is why
// no level in this project has ever been more than a third pieces.
//
// Enumerating the endgames directly costs nothing by comparison — C(26,3) is
// 2600 — and it finishes, so a dense board gets a real answer. Reachability is
// not checked: the enumeration is a superset of the endgames a player can
// arrive at, which errs toward caution rather than toward a silent pass.
//
// The statistic is the SHARE of endgames that are tight, not the minimum over
// them. A minimum taken over more subsets falls for arithmetic reasons alone —
// the same confound that made `doubleDead` look eight times worse for deep
// levels when the rate was 2% against 5%. By share, 26-piece boards come in at
// 0.0% against 0.1% for 11-piece ones: they are not worse, they were unmeasured.
export function endgameScan(level, k = ENDGAME_PIECES) {
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
const ids = level.tiles.map(t => t.id);
const counts = {};
let subsets = 0, minOpen = 6, worst = null;
const visit = (from, pick) => {
if (pick.length) {
const tiles = level.tiles.filter(t => pick.includes(t.id));
let open = 0;
for (let o = 0; o < 6; o++) if (available(boards[o], tiles).length) open++;
counts[open] = (counts[open] || 0) + 1;
subsets++;
if (open < minOpen) { minOpen = open; worst = [...pick].sort().join(','); }
}
if (pick.length === k) return;
for (let i = from; i < ids.length; i++) visit(i + 1, [...pick, ids[i]]);
};
visit(0, []);
const tight = (counts[0] || 0) + (counts[1] || 0);
return { subsets, minOpen, worst, tightStates: tight, tightShare: subsets ? tight / subsets : 0 };
}
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); }
}
}
const endgame = endgameScan(level);
return { states: seen.size, stuckStates, doubleDead, maxDeadTurns, unsolvable,
// Endgame numbers come from the complete enumeration, never from this
// search — it may not have reached an endgame at all.
endgameMinOpen: endgame.minOpen, endgameTightStates: endgame.tightStates,
endgameTightShare: endgame.tightShare, endgameSubsets: endgame.subsets,
endgameWorst: endgame.worst, exhausted: seen.size >= maxStates };
}
export const deepLevelKey = level => level.tiles.map(t => `${t.q},${t.r},${t.dir}`).sort().join('|');
export { key as cellKey };