#!/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} 关)`);