From 8d766b6f06da0e43c9be306112406dc548e56dce Mon Sep 17 00:00:00 2001 From: cabinw Date: Mon, 14 Sep 2026 20:08:25 +0800 Subject: [PATCH] feat(levels): plan the second fifty as alternating crowded and deep boards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- levels/plan-c50b.json | 11 +++++++++++ scripts/select-50.mjs | 21 ++++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 levels/plan-c50b.json diff --git a/levels/plan-c50b.json b/levels/plan-c50b.json new file mode 100644 index 0000000..47c94a3 --- /dev/null +++ b/levels/plan-c50b.json @@ -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 } +] diff --git a/scripts/select-50.mjs b/scripts/select-50.mjs index 6e2245e..c3cf575 100644 --- a/scripts/select-50.mjs +++ b/scripts/select-50.mjs @@ -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} 个,缺口已由其他条件补上`);