feat(levels): gate the dead turn, and state the curve instead of hoping for it
Round 1 played all fifty levels and came back with the mechanics clean and one
complaint repeated across the reports: 短暂以为转错了方向 — a turn that changed
nothing. That one is mechanical rather than perceptual, so it can be gated.
The first attempt gated `doubleDead`, and it was wrong. That counts dead states,
which scales with the state space: three-turn levels have ~187 reachable stuck
states and five-turn ones ~739, so a raw count made deep levels look eight times
worse when the rate is 2% against 5%. It would have erased every level past
three turns — the opposite of the brief, where turning is the signature and is
not to be rationed. The gate is `maxDeadTurns`, the longest run of turns before
something opens, at 1: one beat of suspense, then a release. Eleven of the fifty
shipped at 2.
Selection could not express the curve either. Spreading picks evenly over the
ranked pool returns whatever the pool is made of, and the pool is 88%
three-turn, so no weighting moved the distribution off {3: 46}. A quota per
depth states the curve outright, deepest first so the scarce end picks before a
family cap is spent on levels the shallow end has hundreds of.
Same rule bot, both sets: blind rotations 0.24 → 0.12, worst blind streak 4 → 1,
decisions unchanged at 10.3. The same amount to do, half the wasted motion.
Rotation now runs 1.8 / 3.0 / 4.1 across the bands against 1.8 / 3.0 / 3.5.
Verdicts are keyed by the board rather than by its id, because a reselect
re-issues ids from the ordinal and `c50-01-v05` is not the same board before and
after — the review sheet was one reselect away from printing one level's verdict
under another's thumbnail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+49
-16
@@ -15,6 +15,13 @@ const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v]
|
||||
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;
|
||||
|
||||
// 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 };
|
||||
@@ -55,27 +62,52 @@ scored.sort((a, b) => a.score - b.score);
|
||||
const cap = Math.ceil(COUNT * FAMILY_CAP);
|
||||
const used = {};
|
||||
const picked = [];
|
||||
const takeable = i => {
|
||||
const row = scored[i];
|
||||
const takeable = (rows, i) => {
|
||||
const row = rows[i];
|
||||
if (!row || row.taken) return false;
|
||||
return (used[row.family] ?? 0) < cap;
|
||||
};
|
||||
for (let slot = 0; slot < COUNT; slot++) {
|
||||
const ideal = Math.round(slot * (scored.length - 1) / Math.max(1, COUNT - 1));
|
||||
let choice = null;
|
||||
for (let radius = 0; radius < scored.length; radius++) {
|
||||
for (const i of [ideal - radius, ideal + radius]) {
|
||||
if (i < 0 || i >= scored.length) continue;
|
||||
if (takeable(i)) { choice = i; break; }
|
||||
// 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) => {
|
||||
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;
|
||||
if (choice == null) break;
|
||||
rows[choice].taken = true;
|
||||
used[rows[choice].family] = (used[rows[choice].family] ?? 0) + 1;
|
||||
out.push(rows[choice]);
|
||||
}
|
||||
if (choice == null) break;
|
||||
scored[choice].taken = true;
|
||||
used[scored[choice].family] = (used[scored[choice].family] ?? 0) + 1;
|
||||
picked.push(scored[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.score - b.score);
|
||||
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));
|
||||
@@ -108,7 +140,8 @@ 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), {}))}`);
|
||||
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('|---|---|---:|---:|---:|---:|---:|---:|---:|');
|
||||
|
||||
Reference in New Issue
Block a user