#!/usr/bin/env node // Rebuild the level set a bench round actually played, from the round's runs. // // node scripts/round-set.mjs --round=R2 [--out=…] // // Disk snapshots of "the set round N played" are not trustworthy: select-50 // writes levels/campaign-50.json in place, so any selection experiment run // between the round and the copy silently replaces it. One such copy kept only // 28 of the 50 boards its round had played, which would have made the round // comparison compare a set against somebody else's levels. // // Runs store the whole board and its campaign index, so the round is its own // record. That record cannot drift. import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { 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 ROUND = String(args.round ?? 'R2'); export function roundSet(round, dir = resolve('bench/runs')) { const byBoard = new Map(); for (const f of readdirSync(dir)) { if (!f.endsWith('.json') || f === 'index.json') continue; try { const d = JSON.parse(readFileSync(resolve(dir, f), 'utf8')); if (d.status === 'playing' || !d.level?.tiles) continue; if (!new RegExp(`^${round} · `).test(String(d.agent))) continue; const k = deepLevelKey(d.level); if (!byBoard.has(k)) byBoard.set(k, { level: d.level, runs: [] }); byBoard.get(k).runs.push(d); } catch { /* torn read */ } } return [...byBoard.values()].sort((a, b) => (a.level.campaign?.index ?? 0) - (b.level.campaign?.index ?? 0)); } // Ship a round that was already played. Re-running select-50 cannot reproduce // one: the pool it drew from has since been rebuilt, so the same flags now // return different levels. The runs are the only exact record. function emitCampaign(levels) { writeFileSync(resolve('levels/campaign-50.json'), JSON.stringify({ generated: `round-set ${ROUND}`, version: 1, count: levels.length, levels }, null, 1)); const chapterSize = 10; const chapters = Array.from({ length: Math.ceil(levels.length / chapterSize) }, (_, i) => ['起步', '转起来', '看清路', '深一点', '收束'][i] ?? `第 ${i + 1} 章`); writeFileSync(resolve('src/levels-c50.js'), `// Generated by scripts/round-set.mjs --round=${ROUND} --emitCampaign — do not edit by hand.\n` + `export const C50_CAMPAIGN = ${JSON.stringify({ id: 'c50', name: '五十关调优版', description: `按演出、反差与旋转深度选出的 50 关,难度递增。`, chapters, chapterSize }, null, 1)};\n` + `export const C50_LEVELS = ${JSON.stringify(levels)};\n`); console.log('已写入 levels/campaign-50.json 与 src/levels-c50.js'); } if (import.meta.url === `file://${process.argv[1]}`) { const rows = roundSet(ROUND); const levels = rows.map(r => r.level); console.log(`${ROUND}:${levels.length} 关,共 ${rows.reduce((s, r) => s + r.runs.length, 0)} 局`); console.log(`旋转深度 ${JSON.stringify(levels.reduce((m, l) => (m[l.campaign?.rotations ?? '?'] = (m[l.campaign?.rotations ?? '?'] || 0) + 1, m), {}))}`); console.log(`家族 ${JSON.stringify(levels.reduce((m, l) => (m[l.campaign?.family ?? '?'] = (m[l.campaign?.family ?? '?'] || 0) + 1, m), {}))}`); if (args.out) { writeFileSync(resolve(args.out), JSON.stringify({ generated: `round-set ${ROUND}`, count: levels.length, levels }, null, 1)); console.log(`已写入 ${args.out}`); } if (args.emitCampaign) { if (levels.length !== 50) { console.error(`只反建出 ${levels.length} 关,不足 50,拒绝写入`); process.exit(1); } emitCampaign(levels); } }