feat(levels): boards whose interior is packed and whose rim is left empty

The game has never had a board you simply clear out, and the request for one
turned out to need a construction of its own rather than a denser deep lock.
Filling cells at random can never work: a packed board has nothing that can
move — measured over forty random fills of a 37-cell board, zero pieces free in
all six orientations, every time, because a piece steps to the next cell before
it can leave and on a full board that cell is taken.

So packBoard builds backwards. Start empty, add only a piece that could leave
right now, and the placement order reversed is a removal order — solvability
becomes a property of the construction. Which cell to fill next decides
everything: at random it stalls near half, while filling the most constrained
cell first takes a 19-cell interior to 96% and fills it outright.

The rim is deliberately empty. Those pieces leave first and ask nothing, and
clearing from the rim inward gives a packed board its own difficulty curve
inside one level — the outer cells read easily, and it tightens as it opens.

Two things were tried against the ceiling and failed, and are recorded as such:
depth-first backtracking made it worse on both counts (65-78% filled against
greedy's 89%, at 2000-3000 seconds a board) because recomputing every cell's
legal directions per node spends the budget three levels deep. And the audit's
dead-turn gate could not be trusted here at all — on a packed board its breadth
search truncates, so it certified levels the rule bot then walked through three
blind turns in a row. The pool is filtered by playing it instead: a full pass
takes one second and does not lie.

The campaign is 32 packed levels to 18 deep locks, alternating, with the closing
six at a completely full interior. Same rule bot, both editions: worst blind
streak 1 either way, dead taps zero, and half again as much to do per level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-15 00:23:18 +08:00
co-authored by Claude Opus 5
parent de485c0c34
commit 7607f48cb1
11 changed files with 1263242 additions and 36 deletions
+36 -8
View File
@@ -58,7 +58,14 @@ const pool = String(args.pool ?? 'bench/pool.json,bench/pool-easy.json').split('
return Array.isArray(raw) ? raw : raw.levels;
} catch { return []; }
});
const family = id => id.startsWith('trail-') ? 'v05' : id.replace('proto-d5-', '').replace(/-r\d+-s\d+-d\d+$/, '');
// The route grammar, dug out of the prototype id. It must survive every suffix
// the sweep adds: when placement density became a swept dimension the ids grew
// a -p8 and this stopped matching, so every level became its own family and the
// cap — the guard against one grammar filling the campaign — silently did
// nothing. Read the field when it is there and fall back to trimming.
const family = level => level.prototype?.family
?? (String(level.id).startsWith('trail-') ? 'v05'
: String(level.id).replace('proto-d5-', '').replace(/-r\d+-s\d+(-[a-z]\d+)*$/, ''));
const scored = [];
const seen = new Set();
@@ -69,12 +76,15 @@ for (const level of pool) {
const p = level.prototype ?? {};
const s = p.stats ?? {};
const show = p.show ?? showStats(level);
// Packed boards do not carry deep-lock statistics — lockedShare is 1 by
// definition and there is no per-turn release — so the score falls back to
// what they do have rather than reading a missing field as zero.
const score = WEIGHTS.rotations * (s.rotations ?? 3)
+ WEIGHTS.pieces * (s.pieces ?? level.tiles.length)
+ WEIGHTS.lockedShare * (s.lockedShare ?? 0)
+ WEIGHTS.monkeyAPT * (p.monkeyActionsPerPiece ?? 0)
+ WEIGHTS.cells * level.cells.length;
scored.push({ level, score, family: family(level.id), show, stats: s, contrast: p.contrast?.contrast ?? 0 });
scored.push({ level, score, family: family(level), show, stats: s, contrast: p.contrast?.contrast ?? 0 });
}
scored.sort((a, b) => a.score - b.score);
@@ -125,18 +135,36 @@ const spread = (rows, want) => {
const shortfall = [];
if (PLAN) {
const density = row => row.level.tiles.length / row.level.cells.length;
for (const seg of PLAN) {
const want = Number(seg.count);
const rows = scored.filter(r => {
const matches = seg => scored.filter(r => {
const p = r.level.prototype ?? {};
// Packed boards are a different construction, not a denser deep lock: the
// rim is left empty and the interior filled, so the level carries its own
// difficulty curve from the outside in. A segment asks for one kind or the
// other outright rather than inferring it from density.
if (seg.packed != null && Boolean(p.packed) !== seg.packed) return false;
if (seg.minFill != null && (p.fill ?? 0) < seg.minFill) return false;
if (seg.minRotations != null && (r.stats.rotations ?? 0) < seg.minRotations) return false;
if (seg.minCells != null && r.level.cells.length < seg.minCells) return false;
if (seg.maxCells != null && r.level.cells.length > seg.maxCells) return false;
if (seg.rotations && !seg.rotations.includes(r.stats.rotations)) return false;
if (seg.minDensity != null && density(r) < seg.minDensity) return false;
if (seg.maxDensity != null && density(r) > seg.maxDensity) return false;
return true;
});
const got = spread(rows, want);
picked.push(...got);
if (got.length < want) shortfall.push({ depth: seg.name ?? '段', want, got: got.length, pool: rows.length });
// Fill the scarcest segment first. Filling in reading order lets the roomy
// segments spend the family cap before a tight one is reached: the closing
// segment, which asks for a completely full interior, got 1 of its 6 that way
// while 44 candidates sat unused behind a cap two other segments had filled.
const order = PLAN.map((seg, index) => ({ seg, index, supply: matches(seg).length }))
.sort((a, b) => a.supply / a.seg.count - b.supply / b.seg.count);
const bySegment = new Map();
for (const { seg, index } of order) {
const got = spread(matches(seg), Number(seg.count));
bySegment.set(index, got);
if (got.length < seg.count) shortfall.push({ depth: seg.name ?? '段', want: seg.count, got: got.length, pool: matches(seg).length + got.length });
}
// Played in the order the plan was written, whatever order they were chosen in.
for (let i = 0; i < PLAN.length; i++) picked.push(...(bySegment.get(i) ?? []));
if (picked.length < COUNT) picked.push(...spread(scored, COUNT - picked.length));
} else if (CURVE) {
// Deepest first: the deep end is the scarce one, so it gets to pick before a