feat(levels): assemble the 50-level campaign and the sheet to review it
The deep-lock construction cannot go below three turns, so it cannot supply an opening band — every level it makes lands mid-difficulty and the curve comes out flat. harvest-easy pulls the shallow end from the shipped v0.5 set, but only the 3% of it that holds up: 568 of 1000 need no rotation at all and 51 more are clearable by tapping at random, which is what the monkey threshold is for. 33 levels survive, at one and two turns. select-50 scores candidates on mechanical features only, spreads the pick evenly across that range, and caps any one route grammar at 36% so the campaign cannot quietly become one template repeated — the shipped 1000 turned out to be thirty. Weights are a flag, so refitting them from a round of agent ratings is a re-run rather than an edit. The result is a curve rather than a plateau: difficulty score 3.85 / 6.24 / 7.60 across the three bands, rotations 1.8 / 3.0 / 3.5, and every level keeps three or more opening choices with a show of at least a second. The 50 register as a playable campaign edition, so review happens in the real game; docs/review-50.html is the handover sheet, one card per level with the opening position drawn from the solver rather than sketched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
#!/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);
|
||||
|
||||
// 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 = i => {
|
||||
const row = scored[i];
|
||||
if (!row || row.taken) return false;
|
||||
return (used[row.family] ?? 0) < cap;
|
||||
};
|
||||
for (let slot = 0; slot < COUNT; slot++) {
|
||||
const ideal = Math.round(slot * (scored.length - 1) / Math.max(1, COUNT - 1));
|
||||
let choice = null;
|
||||
for (let radius = 0; radius < scored.length; radius++) {
|
||||
for (const i of [ideal - radius, ideal + radius]) {
|
||||
if (i < 0 || i >= scored.length) continue;
|
||||
if (takeable(i)) { choice = i; break; }
|
||||
}
|
||||
if (choice != null) break;
|
||||
}
|
||||
if (choice == null) break;
|
||||
scored[choice].taken = true;
|
||||
used[scored[choice].family] = (used[scored[choice].family] ?? 0) + 1;
|
||||
picked.push(scored[choice]);
|
||||
}
|
||||
picked.sort((a, b) => 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), {}))}`);
|
||||
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 |`);
|
||||
}
|
||||
Reference in New Issue
Block a user