Files
cabinwandClaude Opus 5 7607f48cb1 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>
2026-09-15 00:23:18 +08:00

88 lines
5.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Harvest boards with a piece on every cell.
//
// node scripts/sweep-packed.mjs [--seeds=40] [--seedFrom=0] [--radii=3,4]
// [--minRotations=2] [--minFill=0.85] [--out=bench/pool-packed.json]
//
// A packed board is the plainest form of what this game is, and until now the
// project had none: the deep-lock construction tops out near a fifth pieces.
// See src/packed-board.js for why filling at random cannot work.
//
// The outermost ring is left empty on purpose. Those pieces leave first and ask
// nothing, and clearing from the rim inward is what gives a packed board its own
// difficulty curve inside a single level — the outer cells are easy to read, and
// the board tightens as it opens. The empty ring is also the runway everything
// departs along, and it makes the fill reach further.
//
// The gates here are deliberately not the deep-lock ones. A packed board pays
// out as a cascade — peel the rim, watch the middle open — so it is judged on
// whether the board is actually full, whether clearing it needs the dial at all,
// and whether the drawing is quiet enough to read. Rotation depth is not asked
// for: removal in this game is monotonic, so a board empties into more and more
// freedom and the requirement collapses on its own. These are the levels that
// decompress, not the ones that puzzle.
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { createRouteBoard, ROUTE_FAMILIES } from '../src/route-grammar.js';
import { packBoard } from '../src/packed-board.js';
import { deadTurnAudit, showStats, trackShape } from '../src/deep-lock.js';
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
const SEEDS = Number(args.seeds ?? 40);
const SEED_FROM = Number(args.seedFrom ?? 0);
const RADII = String(args.radii ?? '3,4').split(',').map(Number);
const MIN_ROTATIONS = Number(args.minRotations ?? 2);
const MAX_CROSS = Number(args.maxCrossPerCell ?? 1.0);
const MIN_FILL = Number(args.minFill ?? 0.85);
const MAX_DEAD_TURNS = Number(args.maxDeadTurns ?? 1);
const OUT = resolve(args.out ?? 'bench/pool-packed.json');
const rows = [];
const tally = { tried: 0, filled: 0, turned: 0, quiet: 0, lively: 0 };
for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
for (let seed = SEED_FROM; seed < SEED_FROM + SEEDS; seed++) {
tally.tried++;
const base = createRouteBoard(seed, { family, radius, fill: 'curve' });
const packed = packBoard(base, seed, { turnEvery: 3, restarts: 30, minRotations: MIN_ROTATIONS, skipRing: true });
if (packed.filled < MIN_FILL || !packed.solvable) continue;
tally.filled++;
if (packed.rotations < MIN_ROTATIONS) continue; // clearable by tapping alone
tally.turned++;
const shape = trackShape(packed.level);
if (shape.crossPerCell > MAX_CROSS) continue;
tally.quiet++;
// The one deep-lock gate that does carry over: a turn that opens nothing is
// boring on any board. Skipping it cost the first packed campaign its
// dead-turn record — the rule bot's worst blind streak went from 1 to 3.
const audit = deadTurnAudit(packed.level);
if (audit.maxDeadTurns > MAX_DEAD_TURNS) continue;
tally.lively++;
const level = packed.level;
level.id = `packed-${family}-r${radius}-s${seed}`;
level.name = `满盘 · ${family}`;
const show = showStats(level);
level.prototype = {
family, radius, seed, packed: true,
stats: { rotations: packed.rotations, pieces: level.tiles.length, lockedShare: 1, minRelease: null },
audit, shape, show,
fill: packed.filled, density: packed.density, buildTurns: packed.turns,
};
rows.push(level);
}
}
const q = (a, p) => [...a].sort((x, y) => x - y)[Math.floor(a.length * p)];
console.log(`试了 ${tally.tried} 个盘面 → 内部填充≥${Math.round(MIN_FILL*100)}% 的 ${tally.filled} → 需要转≥${MIN_ROTATIONS} 的 ${tally.turned} → 交叉/格≤${MAX_CROSS} 的 ${tally.quiet} → 连续空转≤${MAX_DEAD_TURNS} 的 ${tally.lively}`);
if (rows.length) {
console.log(`旋转次数 ${JSON.stringify(rows.reduce((m, l) => (m[l.prototype.stats.rotations] = (m[l.prototype.stats.rotations] || 0) + 1, m), {}))}`);
console.log(`家族 ${JSON.stringify(rows.reduce((m, l) => (m[l.prototype.family] = (m[l.prototype.family] || 0) + 1, m), {}))}`);
console.log(`格子数 ${JSON.stringify(rows.reduce((m, l) => (m[l.cells.length] = (m[l.cells.length] || 0) + 1, m), {}))}`);
console.log(`内部填满 100% 的 ${rows.filter(l => l.prototype.fill >= 1).length} 关 · 棋子数中位 ${q(rows.map(l => l.tiles.length), .5)}`);
console.log(`交叉/格 中位 ${q(rows.map(l => l.prototype.shape.crossPerCell), .5).toFixed(2)} · 直线占比 中位 ${(q(rows.map(l => l.prototype.shape.straightShare), .5) * 100).toFixed(0)}%`);
const runs = rows.map(l => l.prototype.show?.openingRunMs ?? 0);
console.log(`开局连锁 中位 ${(q(runs, .5) / 1000).toFixed(1)}s · 开局可点 中位 ${q(rows.map(l => l.prototype.show?.openingChoices ?? 0), .5)} 枚`);
}
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify({ generated: 'sweep-packed', count: rows.length, levels: rows }, null, 1));
console.log(`已写入 ${OUT}(${rows.length} 关)`);