fix(levels): stop rejecting crowded boards for an endgame nobody measured
No level in this project is more than a third pieces — 13% to 28% across the shipping fifty, and the thousand-level set that preceded it peaks at 32% with exactly one level above 30%. Clearing a full board is the satisfying part of a clearing game, and it has never been on offer. It was not a design choice. deadTurnAudit walks the reachable state space with a 20000-state budget, and the endgame gate read its answer. Past about eleven pieces that budget is gone long before the walk reaches any state with three pieces left, so the audit returned `endgameMinOpen: null` — and the gate, written `endgameMinOpen >= 2`, compared null and got false. Every denser level was thrown out for a bad endgame that was never looked at. `exhausted` was right there in the audit's return value and nothing consulted it. Endgames are now enumerated outright rather than walked to: C(26,3) is 2600 subsets, so it finishes on any board this game will ever have. The statistic is the SHARE of endgames that are tight, not the minimum over them, because a minimum over more subsets falls for arithmetic alone — the same confound that made doubleDead look eight times worse for deep levels when the rate was 2% against 5%. By share, crowded boards are the better ones: worst 0.33% against 1.73% for sparse. With the gate measuring something, density inverts. At eight pieces per orientation: 44% density, 18 of 23 candidates passing the whole chain, and not one failing the dead-turn gate. At two: 19% density, 9 of 23, with eleven failing on dead turns. perTurn becomes a swept dial, and the hard piece ceiling that truncated the placement schedule regardless of it is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+46
-2
@@ -344,6 +344,46 @@ export function deepLevelStats(level) {
|
||||
// A level is judged on its endgame with this many pieces or fewer left.
|
||||
const ENDGAME_PIECES = 3;
|
||||
|
||||
// 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(',');
|
||||
@@ -391,9 +431,13 @@ export function deadTurnAudit(level, maxStates = 20000) {
|
||||
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,
|
||||
endgameMinOpen: endgameMinOpen === 6 && !openCache.size ? null : endgameMinOpen,
|
||||
endgameTightStates, endgameWorst, exhausted: seen.size >= maxStates };
|
||||
// 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('|');
|
||||
|
||||
Reference in New Issue
Block a user