Files
HexTrails/scripts/select-50.mjs
T
cabinwandClaude Opus 5 dcdd4a0ada feat(levels): select for 反差, the thing round 2 says separates hard from easy
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>
2026-09-14 17:18:42 +08:00

172 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 |`);
}