#!/usr/bin/env node // Pick the shipping 50 out of the candidate pool and order them into a curve. // // node scripts/select-50.mjs [--pool=bench/pool.json] [--out=levels/campaign-50.json] // [--count=50] [--familyCap=0.36] [--weights=…] // // Difficulty is scored from mechanical features only. Which features and how // much each counts is meant to be refitted from agent ratings once a round has // been played — --weights takes the refit, so a round is one flag, not an edit. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { deepLevelKey, showStats } 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 ?? 'levels/campaign-50.json'); const COUNT = Number(args.count ?? 50); const FAMILY_CAP = Number(args.familyCap ?? 0.36); // Rotation depth is the campaign's difficulty axis, because the design brief // says turning is the game's signature and should be frequent. Spreading picks // evenly over the ranked pool cannot express that: the pool is 88% three-turn // levels, so an even spread returns a campaign that is 88% three-turn levels // no matter how the score is weighted. A quota per depth states the curve // instead of hoping it falls out of the ranking. const CURVE = args.curve ? JSON.parse(args.curve) : null; // Starting weights, replaced by the fit after a round with agents. const DEFAULT_WEIGHTS = { rotations: 1.0, pieces: 0.10, lockedShare: 1.6, monkeyAPT: 0.14, cells: 0.004 }; const WEIGHTS = args.weights ? { ...DEFAULT_WEIGHTS, ...JSON.parse(args.weights) } : DEFAULT_WEIGHTS; // Several pools feed one campaign: the deep-lock construction cannot go below // three turns, so the opening band comes from elsewhere. const pool = String(args.pool ?? 'bench/pool.json,bench/pool-easy.json').split(',') .flatMap(path => { try { const raw = JSON.parse(readFileSync(resolve(path.trim()), 'utf8')); 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+$/, ''); const scored = []; const seen = new Set(); for (const level of pool) { const key = deepLevelKey(level); if (seen.has(key)) continue; // identical board + piece layout seen.add(key); const p = level.prototype ?? {}; const s = p.stats ?? {}; const show = p.show ?? showStats(level); 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.sort((a, b) => a.score - b.score); // Even spread across the difficulty range, then a family cap so one route // grammar cannot supply half the campaign — the shipped 1000 levels turned out // to be thirty templates, which is the failure this guards against. const cap = Math.ceil(COUNT * FAMILY_CAP); const used = {}; const picked = []; const takeable = (rows, i) => { const row = rows[i]; if (!row || row.taken) return false; return (used[row.family] ?? 0) < cap; }; // Even spread across a ranked list, taking `want` of it. Used on the whole pool // when there is no curve, and on one depth's slice when there is. const spread = (rows, want) => { const out = []; for (let slot = 0; slot < want; slot++) { const ideal = Math.round(slot * (rows.length - 1) / Math.max(1, want - 1)); let choice = null; for (let radius = 0; radius < rows.length; radius++) { for (const i of [ideal - radius, ideal + radius]) { if (i < 0 || i >= rows.length) continue; if (takeable(rows, i)) { choice = i; break; } } if (choice != null) break; } if (choice == null) break; rows[choice].taken = true; used[rows[choice].family] = (used[rows[choice].family] ?? 0) + 1; out.push(rows[choice]); } return out; }; const shortfall = []; if (CURVE) { // Deepest first: the deep end is the scarce one, so it gets to pick before a // family cap can be spent on levels the shallow end has hundreds of. const depths = Object.keys(CURVE).map(Number).sort((a, b) => b - a); for (const depth of depths) { const want = CURVE[depth]; const rows = scored.filter(r => (r.stats.rotations ?? 0) === depth); const got = spread(rows, want); picked.push(...got); if (got.length < want) shortfall.push({ depth, want, got: got.length, pool: rows.length }); } // Whatever the quotas could not fill comes from the rest of the pool, so a // thin depth costs the campaign levels rather than a curve that lies. if (picked.length < COUNT) picked.push(...spread(scored, COUNT - picked.length)); } else { picked.push(...spread(scored, COUNT)); } picked.sort((a, b) => (a.stats.rotations ?? 0) - (b.stats.rotations ?? 0) || a.score - b.score); const levels = picked.map((row, i) => { const level = JSON.parse(JSON.stringify(row.level)); level.campaign = { index: i + 1, score: Number(row.score.toFixed(3)), family: row.family, rotations: row.stats.rotations, pieces: row.stats.pieces, lockedShare: row.stats.lockedShare, contrast: Number(row.contrast.toFixed(1)), openingChoices: row.show?.openingChoices ?? null, openingBestMs: row.show?.openingBestMs ?? null, cruises: row.show?.cruises ?? null, }; level.id = `c50-${String(i + 1).padStart(2, '0')}-${row.family}`; return level; }); mkdirSync(dirname(OUT), { recursive: true }); writeFileSync(OUT, JSON.stringify({ generated: 'select-50', version: 1, weights: WEIGHTS, count: levels.length, levels }, null, 1)); // Also emit the campaign module the game itself loads, so review happens in the // real game rather than in a viewer written for the occasion. 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/select-50.mjs — do not edit by hand.\n` + `export const C50_CAMPAIGN = ${JSON.stringify({ id: 'c50', name: '五十关调优版', description: `从 ${pool.length} 个候选里按演出、反差与旋转深度选出的 50 关,难度递增。`, chapters, chapterSize }, null, 1)};\n` + `export const C50_LEVELS = ${JSON.stringify(levels)};\n`); const band = (a, b) => picked.slice(a, b); const avg = (rows, f) => rows.reduce((s, r) => s + f(r), 0) / Math.max(1, rows.length); console.log(`已选 ${levels.length} 关 → ${OUT}`); console.log(`家族分布 ${JSON.stringify(used)}(单家族上限 ${cap})`); console.log(`旋转深度 ${JSON.stringify(picked.reduce((m, r) => (m[r.stats.rotations] = (m[r.stats.rotations] || 0) + 1, m), {}))}${CURVE ? `(目标 ${JSON.stringify(CURVE)})` : ''}`); for (const s of shortfall) console.log(` !! ${s.depth} 转只凑到 ${s.got}/${s.want} 关 —— 池子里总共才 ${s.pool} 关,缺口已由其他深度补上`); console.log(''); console.log('| 档 | 关号 | 难度分 | 旋转 | 棋子 | 锁住% | 反差 | 开局可点 | 开局演出 |'); console.log('|---|---|---:|---:|---:|---:|---:|---:|---:|'); for (const [label, a, b] of [['前档', 0, 12], ['中档', 12, 32], ['后档', 32, levels.length]]) { const rows = band(a, b); if (!rows.length) continue; console.log(`| ${label} | L${a + 1}–L${b} | ${avg(rows, r => r.score).toFixed(2)} | ${avg(rows, r => r.stats.rotations).toFixed(1)} | ${avg(rows, r => r.stats.pieces).toFixed(1)} | ${(avg(rows, r => r.stats.lockedShare) * 100).toFixed(0)}% | ${avg(rows, r => r.contrast).toFixed(1)} | ${avg(rows, r => r.show?.openingChoices ?? 0).toFixed(1)} | ${(avg(rows, r => r.show?.openingBestMs ?? 0) / 1000).toFixed(2)}s |`); }