#!/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=
[--out=bench/pool.json]
// [--endgameMinOpen=3] [--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 MIN_OPEN = Number(args.endgameMinOpen ?? 3);
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.endgameMinOpen ?? 0) >= MIN_OPEN);
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(` 终局可放行朝向 ≥${MIN_OPEN} → ${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: { endgameMinOpen: MIN_OPEN, maxDeadTurns: MAX_DEAD }, levels: kept }, null, 1));
console.log(`已写入 ${OUT}`);