Files
HexTrails/scripts/select-50.mjs
T
cabinwandClaude Opus 5 7607f48cb1 feat(levels): boards whose interior is packed and whose rim is left empty
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>
2026-09-15 00:23:18 +08:00

232 lines
13 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]; }));
// 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 |`);
}