The game has never had a board you simply clear out, and the request for one turned out to need a construction of its own rather than a denser deep lock. Filling cells at random can never work: a packed board has nothing that can move — measured over forty random fills of a 37-cell board, zero pieces free in all six orientations, every time, because a piece steps to the next cell before it can leave and on a full board that cell is taken. So packBoard builds backwards. Start empty, add only a piece that could leave right now, and the placement order reversed is a removal order — solvability becomes a property of the construction. Which cell to fill next decides everything: at random it stalls near half, while filling the most constrained cell first takes a 19-cell interior to 96% and fills it outright. The rim is deliberately empty. Those pieces leave first and ask nothing, and clearing from the rim inward gives a packed board its own difficulty curve inside one level — the outer cells read easily, and it tightens as it opens. Two things were tried against the ceiling and failed, and are recorded as such: depth-first backtracking made it worse on both counts (65-78% filled against greedy's 89%, at 2000-3000 seconds a board) because recomputing every cell's legal directions per node spends the budget three levels deep. And the audit's dead-turn gate could not be trusted here at all — on a packed board its breadth search truncates, so it certified levels the rule bot then walked through three blind turns in a row. The pool is filtered by playing it instead: a full pass takes one second and does not lie. The campaign is 32 packed levels to 18 deep locks, alternating, with the closing six at a completely full interior. Same rule bot, both editions: worst blind streak 1 either way, dead taps zero, and half again as much to do per level. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
13 KiB
JavaScript
232 lines
13 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]; }));
|
||
// 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;
|
||
};
|
||
|
||
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;
|
||
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 |`);
|
||
}
|