#!/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 { trace } from '../src/core.js'; 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]; })); // The edition being built. It defaults to c50 so the existing call is unchanged, // but a second edition must be able to exist beside the first rather than // overwrite it — the fifty already played are kept. const ID = String(args.id ?? 'c50'); const NAME = String(args.name ?? '五十关调优版'); const OUT = resolve(args.out ?? `levels/campaign-${ID}.json`); const MODULE = resolve(args.module ?? `src/levels-${ID}.js`); 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; // A plan lays the campaign out as segments played in order, each naming how // many levels it wants and what kind. Depth alone cannot express the shape the // brief asks for, because the two things that make a level good pull against // each other: a crowded board clears beautifully but caps at three turns, while // a deep lock needs room to breathe and so stays sparse. Sorting by depth would // file every crowded level first and every deep one last. Segments alternate // them on purpose. const PLAN = args.plan ? JSON.parse(readFileSync(resolve(args.plan), 'utf8')) : null; // Within one depth, what to take. Spreading evenly across the ranked slice was // the default, and it quietly dilutes the thing the design brief calls the // point of the game: levels the agent rated hardest carried 59% more 反差 than // the rest (32.6 against 20.5), far and away the biggest difference between // them — bigger than rotation depth, while board size and route length ran the // other way. The depth quota already supplies the range, so inside a depth // there is nothing to spread for. const PICK = String(args.pickBy ?? 'contrast'); // 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 []; } }); // The route grammar, dug out of the prototype id. It must survive every suffix // the sweep adds: when placement density became a swept dimension the ids grew // a -p8 and this stopped matching, so every level became its own family and the // cap — the guard against one grammar filling the campaign — silently did // nothing. Read the field when it is there and fall back to trimming. const family = level => level.prototype?.family ?? (String(level.id).startsWith('trail-') ? 'v05' : String(level.id).replace('proto-d5-', '').replace(/-r\d+-s\d+(-[a-z]\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); // Packed boards do not carry deep-lock statistics — lockedShare is 1 by // definition and there is no per-turn release — so the score falls back to // what they do have rather than reading a missing field as zero. 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), 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) => { if (PICK === 'contrast') { const out = []; for (const row of [...rows].sort((a, b) => b.contrast - a.contrast)) { if (out.length >= want) break; if (!takeable([row], 0)) continue; row.taken = true; used[row.family] = (used[row.family] ?? 0) + 1; out.push(row); } return out; } 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; }; // The longest line drawn on the board, which is what a level with few pieces and // a winding route is selected for — the departure you watch rather than solve. const routeCache = new WeakMap(); const longestRoute = level => { if (routeCache.has(level)) return routeCache.get(level); const n = Math.max(...level.tiles.map(t => trace(level, t, [], true).steps.length)); routeCache.set(level, n); return n; }; const shortfall = []; if (PLAN) { const density = row => row.level.tiles.length / row.level.cells.length; const matches = seg => scored.filter(r => { const p = r.level.prototype ?? {}; // Packed boards are a different construction, not a denser deep lock: the // rim is left empty and the interior filled, so the level carries its own // difficulty curve from the outside in. A segment asks for one kind or the // other outright rather than inferring it from density. if (seg.packed != null && Boolean(p.packed) !== seg.packed) return false; // Variety is a thing to ask for outright. An all-packed campaign reads as // one level fifty times, and the families the brief singles out as the // good-looking ones — 环绕 and 花瓣 — were absent from it entirely, // filtered out upstream by a crossing threshold they sit just above. if (seg.families && !seg.families.includes(p.family)) return false; if (seg.maxPieces != null && r.level.tiles.length > seg.maxPieces) return false; if (seg.minPieces != null && r.level.tiles.length < seg.minPieces) return false; if (seg.minLongestRoute != null && longestRoute(r.level) < seg.minLongestRoute) return false; if (seg.minFill != null && (p.fill ?? 0) < seg.minFill) return false; if (seg.minRotations != null && (r.stats.rotations ?? 0) < seg.minRotations) return false; if (seg.minCells != null && r.level.cells.length < seg.minCells) return false; if (seg.maxCells != null && r.level.cells.length > seg.maxCells) return false; if (seg.rotations && !seg.rotations.includes(r.stats.rotations)) return false; if (seg.minDensity != null && density(r) < seg.minDensity) return false; if (seg.maxDensity != null && density(r) > seg.maxDensity) return false; return true; }); // Fill the scarcest segment first. Filling in reading order lets the roomy // segments spend the family cap before a tight one is reached: the closing // segment, which asks for a completely full interior, got 1 of its 6 that way // while 44 candidates sat unused behind a cap two other segments had filled. const order = PLAN.map((seg, index) => ({ seg, index, supply: matches(seg).length })) .sort((a, b) => a.supply / a.seg.count - b.supply / b.seg.count); const bySegment = new Map(); for (const { seg, index } of order) { const got = spread(matches(seg), Number(seg.count)); bySegment.set(index, got); if (got.length < seg.count) shortfall.push({ depth: seg.name ?? '段', want: seg.count, got: got.length, pool: matches(seg).length + got.length }); } // Played in the order the plan was written, whatever order they were chosen in. for (let i = 0; i < PLAN.length; i++) picked.push(...(bySegment.get(i) ?? [])); if (picked.length < COUNT) picked.push(...spread(scored, COUNT - picked.length)); } else 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)); } // A plan is an arrangement, not a bag: re-sorting it by depth would undo the // alternation it exists to create. if (!PLAN) 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 = `${ID}-${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} 章`); const CONST = ID.toUpperCase().replace(/[^A-Z0-9]/g, '_'); writeFileSync(MODULE, `// Generated by scripts/select-50.mjs --id=${ID} — do not edit by hand.\n` + `export const ${CONST}_CAMPAIGN = ${JSON.stringify({ id: ID, name: NAME, description: args.description ?? `从 ${pool.length} 个候选里选出的 50 关。`, chapters, chapterSize }, null, 1)};\n` + `export const ${CONST}_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} 与 ${MODULE}(版本 ${ID})`); 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.level.tiles.length / r.level.cells.length) * 100).toFixed(0)}% | ${(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 |`); }