c50b is sealed where it stands, as a reference sample rather than a candidate. Its packed construction was judged good — clearing a board from the rim inward is the experience it proved out — but at 32 of 50 levels it played as one level fifty times: 体验太单一了,因为基本都是满盘. The note on why it is kept lives at the top of its module, because the lesson is the useful part. It was monotonous on a second axis nobody asked about. 环绕 and 花瓣, the two route families the brief singles out as the good-looking ones, were absent from it entirely — not by choice but by a crossing threshold. At 1.0 crossings per cell, 0% of orbits boards pass and 4% of spirals; at 1.2, 84% and 94% do. The gate meant to answer "交叉点太多" had quietly deleted the families most worth looking at, so it moves to 1.2 — still half the 2.36 that drew the complaint. c50c alternates five kinds of level every three to five: packed boards down to 32%, long-route departures to watch, deep locks to solve, small quick boards, and segments given over to orbits, petals and spirals by name. All six families appear, no family exceeds 24%, and the plan is 13 segments rather than 9. Selection learned to ask for variety outright — by family, piece count and longest drawn route — instead of hoping it falls out of a difficulty score. Rule bot across all three: 50/50 cleared and zero dead taps everywhere; c50c sits between the other two on blind rotations and on how much there is to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
251 lines
14 KiB
JavaScript
251 lines
14 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 { 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 |`);
|
||
}
|