feat(levels): assemble the 50-level campaign and the sheet to review it

The deep-lock construction cannot go below three turns, so it cannot supply an
opening band — every level it makes lands mid-difficulty and the curve comes
out flat. harvest-easy pulls the shallow end from the shipped v0.5 set, but
only the 3% of it that holds up: 568 of 1000 need no rotation at all and 51
more are clearable by tapping at random, which is what the monkey threshold is
for. 33 levels survive, at one and two turns.

select-50 scores candidates on mechanical features only, spreads the pick
evenly across that range, and caps any one route grammar at 36% so the campaign
cannot quietly become one template repeated — the shipped 1000 turned out to be
thirty. Weights are a flag, so refitting them from a round of agent ratings is
a re-run rather than an edit.

The result is a curve rather than a plateau: difficulty score 3.85 / 6.24 /
7.60 across the three bands, rotations 1.8 / 3.0 / 3.5, and every level keeps
three or more opening choices with a show of at least a second.

The 50 register as a playable campaign edition, so review happens in the real
game; docs/review-50.html is the handover sheet, one card per level with the
opening position drawn from the solver rather than sketched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 16:53:57 +08:00
co-authored by Claude Opus 5
parent 0aa080c7c3
commit 96e5e4c21f
15 changed files with 87896 additions and 47 deletions
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env node
// Build docs/review-50.html — the sheet the 50 levels are handed over on.
//
// node scripts/build-review-50.mjs [--agent="R1 · grok xhigh"]
//
// One card per level: the board as it opens, what the construction guarantees,
// and what the agent that played it reported. Every card links into the real
// game at that level, because a level is judged by playing it.
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { trace, rotateBoard } from '../src/core.js';
import { layout } from '../src/geometry.js';
import { metrics } from '../src/bench.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 AGENT = args.agent ?? 'R1 · grok xhigh';
const OUT = resolve(args.out ?? 'docs/review-50.html');
const raw = JSON.parse(readFileSync(resolve('levels/campaign-50.json'), 'utf8'));
const levels = raw.levels;
// Agent verdicts, keyed by level id.
const verdicts = {};
const RUNS = resolve('bench/runs');
if (existsSync(RUNS)) {
for (const f of readdirSync(RUNS)) {
if (!f.endsWith('.json') || f === 'index.json') continue;
try {
const d = JSON.parse(readFileSync(resolve(RUNS, f), 'utf8'));
if (d.agent !== AGENT || !d.level?.id) continue;
verdicts[d.level.id] = metrics(d);
} catch { /* torn read */ }
}
}
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const mean = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null;
const f1 = x => x == null || Number.isNaN(x) ? '—' : (Math.round(x * 10) / 10).toFixed(1);
// A flat SVG of the opening position: tracks in grey, pieces as arrows. Small
// enough to read fifty of them on one page.
const ARROWS = ['↑', '↗', '↘', '↓', '↙', '↖'];
function thumbnail(level, size = 168) {
const view = layout(level.cells, size, size);
const { size: r, ox, oy } = view;
const px = (q, rr) => ({ x: ox + r * 1.5 * q, y: oy + r * Math.sqrt(3) * (rr + q / 2) });
const cells = level.cells.map(c => {
const { x, y } = px(c.q, c.r);
const pts = Array.from({ length: 6 }, (_, i) => {
const a = Math.PI / 180 * (60 * i);
return `${(x + r * Math.cos(a)).toFixed(1)},${(y + r * Math.sin(a)).toFixed(1)}`;
}).join(' ');
return `<polygon points="${pts}"/>`;
}).join('');
const open = new Set(level.tiles.filter(t => trace(level, t, level.tiles).status === 'exit').map(t => t.id));
const pieces = level.tiles.map(t => {
const { x, y } = px(t.q, t.r);
return `<g class="${open.has(t.id) ? 'free' : 'held'}"><circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${(r * .62).toFixed(1)}"/>`
+ `<text x="${x.toFixed(1)}" y="${(y + r * .26).toFixed(1)}" font-size="${(r * .9).toFixed(1)}">${ARROWS[t.dir]}</text></g>`;
}).join('');
return `<svg viewBox="0 0 ${size} ${size}" class="thumb" role="img" aria-label="${esc(level.id)} 开局盘面">`
+ `<g class="cells">${cells}</g><g class="pieces">${pieces}</g></svg>`;
}
const rated = levels.map(l => verdicts[l.id]).filter(Boolean);
const band = (a, b) => levels.slice(a, b).map(l => verdicts[l.id]).filter(Boolean);
const bandRow = (label, a, b) => {
const rows = band(a, b);
const g = k => mean(rows.map(m => Number(m.rating?.[k])).filter(x => !Number.isNaN(x)));
return `<tr><td>${label}</td><td class="n">${rows.length}</td><td class="n">${f1(g('difficulty'))}</td><td class="n">${f1(g('stuck'))}</td><td class="n">${f1(mean(rows.map(m => m.blindRotations)))}</td><td class="n">${f1(mean(rows.map(m => m.deadTaps)))}</td></tr>`;
};
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>五十关 · 人工审核</title>
<style>
:root{
--ground:#EEF1F4; --surface:#FFF; --inset:#E3E8ED; --panel:#F7F9FA;
--ink:#141E28; --ink2:#48596A; --ink3:#6B7A88;
--rule:#D3DAE1; --rail:#435F78; --good:#236E64; --bad:#B23A26; --warn:#8A5C0F;
--cell:#E7E1D3; --cell-line:#CFC7B4; --held:#9AA9B6; --free:#2A7F74;
color-scheme:light;
}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
--ground:#0F161C; --surface:#141D25; --inset:#0A1015; --panel:#141D25;
--ink:#E4EAEF; --ink2:#AEBDC9; --ink3:#8494A2;
--rule:#28343F; --rail:#8CB0CC; --good:#4FC7B7; --bad:#E4836C; --warn:#E0AE5E;
--cell:#232D36; --cell-line:#31404C; --held:#5B6D7C; --free:#4FC7B7;
color-scheme:dark;
}}
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--ink);
font-family:"PingFang SC","Hiragino Sans GB",ui-sans-serif,system-ui,-apple-system,sans-serif;line-height:1.65}
.wrap{max-width:1180px;margin:0 auto;padding:40px 22px 90px}
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums}
h1{font-size:30px;margin:0 0 10px;letter-spacing:-.02em}
.lede{color:var(--ink2);font-size:15px;max-width:70ch;margin:0 0 8px}
.meta{font-size:12.5px;color:var(--ink3);margin:0 0 26px}
h2{font-size:19px;margin:40px 0 10px}
table{width:100%;border-collapse:collapse;font-size:13.5px;margin:12px 0 26px}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--rule)}
th{font-size:11px;letter-spacing:.06em;color:var(--ink3);text-transform:uppercase;font-weight:600}
td.n,th.n{text-align:right;font-family:ui-monospace,monospace;white-space:nowrap}
.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(212px,1fr))}
.card{background:var(--surface);border:1px solid var(--rule);border-radius:12px;padding:12px;display:flex;flex-direction:column;gap:8px}
.card h3{margin:0;font-size:13.5px;display:flex;align-items:baseline;gap:7px}
.card h3 .no{font-family:ui-monospace,monospace;font-size:15px;font-weight:700;color:var(--rail)}
.card h3 .fam{font-size:11px;color:var(--ink3);margin-left:auto}
.thumb{width:100%;height:auto;background:var(--inset);border-radius:8px;display:block}
.thumb .cells polygon{fill:var(--cell);stroke:var(--cell-line);stroke-width:.6}
.thumb .pieces circle{fill:var(--held)}
.thumb .pieces .free circle{fill:var(--free)}
.thumb text{fill:#fff;text-anchor:middle;font-family:system-ui}
.kv{display:grid;grid-template-columns:auto auto;gap:1px 8px;font-size:11.5px;color:var(--ink2)}
.kv b{font-family:ui-monospace,monospace;color:var(--ink);font-weight:600;text-align:right}
.verdict{font-size:11.5px;border-top:1px dashed var(--rule);padding-top:7px;color:var(--ink2)}
.verdict .row{display:flex;gap:9px;flex-wrap:wrap;font-family:ui-monospace,monospace}
.verdict .worst{margin-top:4px;color:var(--ink3);line-height:1.5}
.play{display:block;text-align:center;background:var(--rail);color:#fff;text-decoration:none;border-radius:8px;padding:7px;font-size:12.5px;font-weight:600;margin-top:auto}
.pill{display:inline-block;font-size:10.5px;padding:1px 7px;border-radius:20px;font-weight:600}
.pill.bad{background:var(--bad);color:#fff}
.pill.warn{background:var(--warn);color:#fff}
footer{margin-top:50px;padding-top:18px;border-top:1px solid var(--rule);color:var(--ink3);font-size:12px}
</style>
</head>
<body>
<div class="wrap">
<h1>五十关 · 人工审核</h1>
<p class="lede">缩略图是<b>开局盘面</b>:绿色的棋子开局就能点,灰色的被挡着。点「试玩」用真游戏打开这一关。</p>
<p class="meta mono">关卡集 levels/campaign-50.json · agent 实测批次 ${esc(AGENT)}(${rated.length}/${levels.length} 关已评)· 生成于 ${new Date().toLocaleString('sv-SE').slice(0, 16)}</p>
<h2>难度曲线(agent 实测)</h2>
<table>
<thead><tr><th>档</th><th class="n">已评关数</th><th class="n">难度</th><th class="n">无解感</th><th class="n">盲转/关</th><th class="n">空点/关</th></tr></thead>
<tbody>
${bandRow('前档 L1–L12', 0, 12)}
${bandRow('中档 L13–L32', 12, 32)}
${bandRow('后档 L33–L50', 32, 50)}
</tbody>
</table>
<p class="meta">难度 3 = 刚刚好;无解感越低越好。这两项是评分里唯二复测可信的(r = 0.93 / 0.85),其余几项是模型噪声,不列。</p>
<h2>逐关</h2>
<div class="grid">
${levels.map((l, i) => {
const c = l.campaign ?? {};
const v = verdicts[l.id];
const flags = [];
if (v && !v.cleared) flags.push('<span class="pill bad">未通关</span>');
if (v && Number(v.rating?.stuck) >= 4) flags.push('<span class="pill warn">觉得无解</span>');
if (v && v.blindRotations >= 2) flags.push('<span class="pill warn">盲转多</span>');
return `<div class="card">
<h3><span class="no">${String(i + 1).padStart(2, '0')}</span>${flags.join('')}<span class="fam">${esc(c.family ?? '')}</span></h3>
${thumbnail(l)}
<div class="kv">
<span>旋转 / 棋子</span><b>${c.rotations ?? '?'} 转 · ${c.pieces ?? l.tiles.length} 枚</b>
<span>开局可点</span><b>${c.openingChoices ?? '?'} 枚</b>
<span>开局演出</span><b>${c.openingBestMs != null ? (c.openingBestMs / 1000).toFixed(1) + 's' : '?'}</b>
<span>长巡游 / 反差</span><b>${c.cruises ?? '?'} · ${c.contrast ?? '?'}</b>
</div>
${v ? `<div class="verdict">
<div class="row"><span>难度 ${v.rating?.difficulty ?? '—'}</span><span>无解感 ${v.rating?.stuck ?? '—'}</span><span>${v.steps} 步</span><span>${v.decisions} 决策</span></div>
${v.rating?.worst ? `<div class="worst">${esc(String(v.rating.worst).slice(0, 90))}</div>` : ''}
</div>` : '<div class="verdict">尚未实测</div>'}
<a class="play" href="../index.html?campaign=c50&level=${i + 1}">试玩 →</a>
</div>`;
}).join('\n')}
</div>
<footer>
<p class="mono">生成:node scripts/build-review-50.mjs · 选关:node scripts/select-50.mjs · 看板:docs/level-board.html</p>
<p>缩略图与「开局可点」同源,都取自求解器对开局盘面的判定,不是示意图。</p>
</footer>
</div>
</body>
</html>`;
writeFileSync(OUT, html);
console.log(`审核页已生成:${OUT}(${levels.length} 关,${rated.length} 关已有 agent 实测)`);
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
// Harvest the shallow end of the campaign from the shipped v0.5 set.
//
// node scripts/harvest-easy.mjs [--set=levels/v0.5.json] [--out=bench/pool-easy.json]
//
// The deep-lock construction always needs three turns, so it cannot supply the
// opening dozen levels. v0.5 can — but only the small fraction of it that holds
// up: most of that set is clearable by tapping at random, which is the failure
// the monkey threshold exists to catch.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { Game, random, solveRotations } from '../src/core.js';
import { showStats, contrastStats } from '../src/deep-lock.js';
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; }));
const SET = resolve(args.set ?? 'levels/v0.5.json');
const OUT = resolve(args.out ?? 'bench/pool-easy.json');
const MIN_APT = Number(args.minAPT ?? 3), MIN_CONTRAST = Number(args.minContrast ?? 10);
const MIN_OPENING_SHOW = Number(args.minOpeningShow ?? 900), MIN_OPENING_CHOICES = Number(args.minOpeningChoices ?? 3);
const MIN_CRUISES = Number(args.minCruises ?? 3);
function monkeyActionsPerPiece(level, runs = 4) {
let total = 0;
for (let run = 0; run < runs; run++) {
const rng = random(4242 + run * 31), game = new Game(level);
let actions = 0, streak = 0;
while (!game.won && !game.lost && actions < 800) {
if (streak >= 3) { game.rotate(rng() < .5 ? 1 : -1); actions++; streak = 0; continue; }
const tile = game.tiles[Math.floor(rng() * game.tiles.length)];
actions++;
if (game.tap(tile.id).status === 'exit') streak = 0; else streak++;
}
total += game.won ? actions / level.tiles.length : 999;
}
return total / runs;
}
const raw = JSON.parse(readFileSync(SET, 'utf8'));
const levels = Array.isArray(raw) ? raw : raw.levels;
const kept = [];
const rejected = { unsolvable: 0, zeroRotation: 0, show: 0, contrast: 0, monkey: 0 };
for (const level of levels) {
const solution = solveRotations(level);
if (!solution.solvable) { rejected.unsolvable++; continue; }
if (solution.rotations < 1) { rejected.zeroRotation++; continue; }
const show = showStats(level);
if (!show || show.openingChoices < MIN_OPENING_CHOICES || show.openingBestMs < MIN_OPENING_SHOW || show.cruises < MIN_CRUISES) { rejected.show++; continue; }
const contrast = contrastStats(level);
if (contrast.contrast < MIN_CONTRAST) { rejected.contrast++; continue; }
const apt = monkeyActionsPerPiece(level);
if (apt < MIN_APT) { rejected.monkey++; continue; }
const copy = JSON.parse(JSON.stringify(level));
copy.prototype = {
family: 'v05', radius: null, source: 'v0.5',
stats: { rotations: solution.rotations, pieces: level.tiles.length, lockedShare: 0, minRelease: 1 },
contrast, show, monkeyActionsPerPiece: apt,
};
kept.push(copy);
}
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify({ generated: 'harvest-easy', source: SET, count: kept.length, levels: kept }, null, 1));
console.log(`采集 ${kept.length} / ${levels.length} 关 → ${OUT}`);
console.log(` 刷掉:0 转 ${rejected.zeroRotation} · 演出 ${rejected.show} · 反差 ${rejected.contrast} · 乱点即过 ${rejected.monkey}`);
console.log(` 旋转分布 ${JSON.stringify(kept.reduce((m, l) => (m[l.prototype.stats.rotations] = (m[l.prototype.stats.rotations] || 0) + 1, m), {}))}`);
+119
View File
@@ -0,0 +1,119 @@
#!/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]; }));
const OUT = resolve(args.out ?? 'levels/campaign-50.json');
const COUNT = Number(args.count ?? 50);
const FAMILY_CAP = Number(args.familyCap ?? 0.36);
// 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 []; }
});
const family = id => id.startsWith('trail-') ? 'v05' : id.replace('proto-d5-', '').replace(/-r\d+-s\d+-d\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);
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.id), 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 = i => {
const row = scored[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; }
}
if (choice != null) break;
}
if (choice == null) break;
scored[choice].taken = true;
used[scored[choice].family] = (used[scored[choice].family] ?? 0) + 1;
picked.push(scored[choice]);
}
picked.sort((a, b) => 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 = `c50-${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} 章`);
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 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('');
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.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 |`);
}