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>
77 lines
4.0 KiB
JavaScript
77 lines
4.0 KiB
JavaScript
#!/usr/bin/env node
|
||
// Merge a sharded prototype sweep into one candidate pool, and apply the gate
|
||
// that only a wide sweep can afford.
|
||
//
|
||
// node scripts/build-pool.mjs --in=<dir> [--out=bench/pool.json]
|
||
// [--maxTightShare=0.005] [--maxDeadTurns=1]
|
||
//
|
||
// prototype-d5 gates what a single sweep can pay for. Two more are left to this
|
||
// step because together they cost nearly 60% of the yield.
|
||
//
|
||
// `maxDeadTurns` is the longest run of turns a player can make, from a stuck
|
||
// state, before anything opens — the design brief's 无效转动就会很无聊, counted.
|
||
// At 1 the worst case is one turn of suspense and then a release. Round 1 shipped
|
||
// eleven levels at 2, and those are the ones the agent kept writing
|
||
// 短暂以为转错了方向 about.
|
||
//
|
||
// It is deliberately NOT `doubleDead`, which was the first thing tried here.
|
||
// That counts dead states rather than consecutive dead turns, so it scales with
|
||
// the size of the state space: depth-3 levels have ~187 reachable stuck states
|
||
// and depth-5 ones ~739, which made the raw count look eight times worse for
|
||
// deep levels when the *rate* is 2% against 5%. Gating on it wiped out every
|
||
// level past three turns — the opposite of what the brief asks for, since
|
||
// turning is the game's signature and should not be rationed.
|
||
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import { deadTurnAudit, deepLevelKey } 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 OUT = resolve(args.out ?? 'bench/pool.json');
|
||
const MAX_TIGHT = Number(args.maxTightShare ?? 0.005);
|
||
const MAX_DEAD = Number(args.maxDeadTurns ?? 1);
|
||
|
||
// Shards from a sweep, or level files listed directly.
|
||
const sources = String(args.in ?? 'bench/shards').split(',').map(s => resolve(s.trim()));
|
||
const files = sources.flatMap(src => {
|
||
try { return readdirSync(src).filter(f => f.endsWith('.json')).map(f => resolve(src, f)); }
|
||
catch { return [src]; } // not a directory: a file path
|
||
});
|
||
const all = files.flatMap(f => {
|
||
try { const raw = JSON.parse(readFileSync(f, 'utf8')); return Array.isArray(raw) ? raw : raw.levels ?? []; }
|
||
catch { return []; }
|
||
});
|
||
|
||
const seen = new Set();
|
||
const unique = [];
|
||
for (const level of all) {
|
||
const key = deepLevelKey(level);
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
unique.push(level);
|
||
}
|
||
|
||
// Levels harvested from the shipped set arrive without an audit. Being shipped
|
||
// is not evidence of passing a gate that did not exist when they shipped.
|
||
let audited = 0;
|
||
for (const level of unique) {
|
||
if (level.prototype?.audit) continue;
|
||
level.prototype = { ...(level.prototype ?? {}), audit: deadTurnAudit(level) };
|
||
audited++;
|
||
}
|
||
|
||
const open = unique.filter(l => (l.prototype.audit.endgameTightShare ?? 1) <= MAX_TIGHT);
|
||
const kept = open.filter(l => (l.prototype.audit.maxDeadTurns ?? Infinity) <= MAX_DEAD);
|
||
|
||
const tally = (rows, f) => rows.reduce((m, l) => { const k = f(l); m[k] = (m[k] || 0) + 1; return m; }, {});
|
||
const rot = l => l.prototype?.stats?.rotations ?? '?';
|
||
const fam = l => l.prototype?.family ?? (String(l.id).startsWith('trail-') ? 'v05' : '?');
|
||
console.log(`${files.length} 个来源合计 ${all.length} → 去重 ${unique.length}${audited ? `(其中 ${audited} 关现场补算了 audit)` : ''}`);
|
||
console.log(` 终局紧局 ≤${(MAX_TIGHT*100).toFixed(1)}% → ${open.length}(${Math.round(100 * open.length / unique.length)}%)`);
|
||
console.log(` 连续空转 ≤${MAX_DEAD} → ${kept.length}(${Math.round(100 * kept.length / unique.length)}%)`);
|
||
console.log(`旋转深度 ${JSON.stringify(tally(kept, rot))}`);
|
||
console.log(`家族 ${JSON.stringify(tally(kept, fam))}`);
|
||
|
||
writeFileSync(OUT, JSON.stringify({ generated: 'build-pool', sources: files.length, swept: all.length, count: kept.length,
|
||
gates: { maxTightShare: MAX_TIGHT, maxDeadTurns: MAX_DEAD }, levels: kept }, null, 1));
|
||
console.log(`已写入 ${OUT}`);
|