Round 2 rated 16 runs a 3 and 84 a 2, which is too little spread to rank levels
by — but enough to ask what the 3s had in common. Contrast: 32.6 against 20.5,
a 59% gap and by far the largest of any feature. Rotation depth came second at
+1.2 turns. Board size and longest route ran the *other* way: the levels rated
harder were slightly smaller with shorter routes, which is what the design brief
has said all along — 难是难在用户无法一下子判断, not in how much board there is.
So within a depth, picking evenly across the ranked slice was diluting the one
thing the brief calls the point of the game. The depth quota already supplies
the difficulty range; inside a depth there is nothing to spread for, so it takes
the highest-contrast levels instead.
Taken alone that collapses the route grammars — two families filled 36 of the
50 — so the family cap comes down to 0.26. The pair beats the shipped round on
both axes rather than trading one for the other: contrast 14.8 / 22.0 / 27.9 →
15.2 / 24.9 / 28.6, families {18,12,8,2,10} → {13,13,5,9,10}. Same rule bot,
mechanics unchanged: worst blind streak still 1, dead taps still 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
8.8 KiB
JavaScript
172 lines
8.8 KiB
JavaScript
#!/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;
|
||
// 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 []; }
|
||
});
|
||
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) => {
|
||
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;
|
||
};
|
||
|
||
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 |`);
|
||
}
|