feat(levels): plan the second fifty as alternating crowded and deep boards

A crowded board and a deep lock cannot be the same level — at five turns with
eight pieces per orientation, 16 of 18 candidates fail the release gate, because
pieces packed in leave no room for a turn to open two of them. So the campaign
alternates the two instead of ramping one: crowded stretches for the pleasure of
clearing a full board, deep stretches as the variation between them, and the
last four are the fullest boards in the set.

24 crowded, 14 deep, 12 sparse-and-shallow to open on. Density climbs across the
crowded stretches (0.36 → 0.40 → 0.42 → 0.44) since that is the axis left once
depth is spoken for.

select-50 takes --id and --name so this edition is built beside the first rather
than over it; the fifty already played stay exactly as they are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 20:08:25 +08:00
co-authored by Claude Opus 5
parent 83796804d0
commit 8d766b6f06
2 changed files with 25 additions and 7 deletions
+11
View File
@@ -0,0 +1,11 @@
[
{ "name": "起步 · 小盘看清规则", "count": 6, "rotations": [3], "maxDensity": 0.28 },
{ "name": "清空的手感", "count": 6, "rotations": [3], "minDensity": 0.36 },
{ "name": "变奏 · 深一点", "count": 5, "rotations": [4, 5] },
{ "name": "再来一盘满的", "count": 7, "rotations": [3], "minDensity": 0.40 },
{ "name": "中段 · 稀而绕", "count": 6, "rotations": [3], "maxDensity": 0.30 },
{ "name": "变奏 · 深锁", "count": 5, "rotations": [4, 5] },
{ "name": "后段 · 满盘", "count": 7, "rotations": [3], "minDensity": 0.42 },
{ "name": "收束前的深锁", "count": 4, "rotations": [4, 5] },
{ "name": "终章 · 最满的一盘", "count": 4, "rotations": [3], "minDensity": 0.44 }
]
+14 -7
View File
@@ -12,7 +12,13 @@ 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');
// 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
@@ -163,7 +169,7 @@ const levels = picked.map((row, i) => {
openingBestMs: row.show?.openingBestMs ?? null,
cruises: row.show?.cruises ?? null,
};
level.id = `c50-${String(i + 1).padStart(2, '0')}-${row.family}`;
level.id = `${ID}-${String(i + 1).padStart(2, '0')}-${row.family}`;
return level;
});
@@ -175,14 +181,15 @@ writeFileSync(OUT, JSON.stringify({ generated: 'select-50', version: 1, weights:
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 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}`);
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} 个,缺口已由其他条件补上`);