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:
+36
-8
@@ -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
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/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} 关)`);
|
||||
Reference in New Issue
Block a user