Files
cabinwandClaude Opus 5 b35ea201db fix(campaign): register c50 with the fields the app actually reads
Opening the c50 edition threw `campaign.legacyStorageKeys is not iterable`
and left the board blank — the header still drew, so the level set looked
loaded. Every other edition carries the array; the hand-written c50 entry
did not, and 197 passing tests had nothing to say about it, because nothing
tested the registration itself.

The test now walks every edition and asserts the shape app.js relies on,
including that the chapter list can actually hold the levels. It fails on the
entry as it shipped.

The task board was reading levels/prototypes/d5.json while the campaign sat in
levels/campaign-50.json, so it showed "候选关卡 18 / 50" with fifty levels
built. It reads the shipping set when there is one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 17:03:37 +08:00

259 lines
14 KiB
JavaScript
Raw Permalink 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
// Build docs/level-board.html — the task board for "50 good levels".
//
// node scripts/build-board.mjs
//
// Task state lives in bench/board.json and is edited by hand as work moves.
// Everything else on the page is measured live from the level set and the run
// archive, so the board cannot claim progress the artifacts do not show.
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { solveRotations } from '../src/core.js';
import { metrics } from '../src/bench.js';
import { deadTurnAudit } from '../src/deep-lock.js';
const board = JSON.parse(readFileSync(resolve('bench/board.json'), 'utf8'));
const OUT = resolve('docs/level-board.html');
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const f1 = x => x == null || Number.isNaN(x) ? '—' : (Math.round(x * 10) / 10).toFixed(1);
const f2 = x => x == null || Number.isNaN(x) ? '—' : (Math.round(x * 100) / 100).toFixed(2);
const mean = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null;
// ---------- live measurement ----------
// The shipping set once it exists, the prototype output before that. Reading
// the prototype after the campaign was assembled showed the tile as "18 / 50"
// while fifty levels were sitting in levels/campaign-50.json.
const SET = [resolve('levels/campaign-50.json'), resolve('levels/prototypes/d5.json')].find(existsSync);
let pool = { n: 0, rot: {}, apt: null, families: {}, blind: null, wrong: null, endgame: {} };
if (existsSync(SET)) {
const raw = JSON.parse(readFileSync(SET, 'utf8'));
const levels = Array.isArray(raw) ? raw : raw.levels;
pool.n = levels.length;
for (const l of levels) {
const r = l.prototype?.stats?.rotations ?? solveRotations(l).rotations;
pool.rot[r] = (pool.rot[r] || 0) + 1;
const fam = l.campaign?.family ?? l.id.replace('proto-d5-', '').replace(/-r\d+-s\d+$/, '');
pool.families[fam] = (pool.families[fam] || 0) + 1;
const a = l.prototype?.audit ?? deadTurnAudit(l);
pool.endgame[a.endgameMinOpen] = (pool.endgame[a.endgameMinOpen] || 0) + 1;
}
pool.apt = mean(levels.map(l => l.prototype?.monkeyActionsPerPiece).filter(x => typeof x === 'number'));
}
// Latest rule-bot sweep over whatever the current set is.
const RUNS = resolve('bench/runs');
const runsBy = tagTest => {
if (!existsSync(RUNS)) return [];
const out = [];
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 (tagTest(d, f)) out.push(metrics(d)); } catch { /* torn read */ }
}
return out;
};
const bot = runsBy((d, f) => f.startsWith('restruct-'));
if (bot.length) {
pool.blind = mean(bot.map(m => m.blindRotations));
pool.wrong = mean(bot.map(m => m.wrongWayRotations));
}
// Agent batches, newest label wins.
const agentBatches = {};
for (const f of (existsSync(RUNS) ? readdirSync(RUNS) : [])) {
if (!f.endsWith('.json') || f === 'index.json') continue;
try {
const d = JSON.parse(readFileSync(resolve(RUNS, f), 'utf8'));
if (!d.agent || !d.rating) continue;
(agentBatches[d.agent] ||= []).push(metrics(d));
} catch { /* torn read */ }
}
const TASKS = board.phases.flatMap(p => p.tasks);
const counts = TASKS.reduce((m, t) => (m[t.status] = (m[t.status] || 0) + 1, m), {});
const doneN = counts.done || 0;
const pct = Math.round(doneN / TASKS.length * 100);
const STATUS = {
done: ['完成', 'good'], doing: ['进行中', 'live'], todo: ['待办', 'idle'], blocked: ['受阻', 'bad'],
};
const depthRows = Object.entries(pool.rot).sort((a, b) => Number(a[0]) - Number(b[0]));
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="refresh" content="60">
<title>50 关任务看板</title>
<style>
:root{
--ground:#EEF1F4; --surface:#FFFFFF; --inset:#E3E8ED; --panel:#F7F9FA;
--ink:#141E28; --ink2:#48596A; --ink3:#6B7A88;
--rule:#D3DAE1; --rule2:#BFC9D2;
--rail:#435F78; --rail-soft:#8FA6BA;
--bad:#B23A26; --bad-soft:#F2DDD8;
--warn:#8A5C0F; --warn-soft:#F4E9D4;
--good:#236E64; --good-soft:#D9EDEA;
--live:#1F6FB2; --live-soft:#DCE9F4;
--shadow:0 1px 2px rgba(20,30,40,.06),0 6px 18px rgba(20,30,40,.05);
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; --rule2:#35434F;
--rail:#8CB0CC; --rail-soft:#4E6577;
--bad:#E4836C; --bad-soft:#3A211B;
--warn:#E0AE5E; --warn-soft:#33280F;
--good:#4FC7B7; --good-soft:#11302C;
--live:#6BB3EE; --live-soft:#12283A;
--shadow:0 1px 2px rgba(0,0,0,.3),0 6px 18px rgba(0,0,0,.28);
color-scheme:dark;
}}
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--ink);
font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",ui-sans-serif,system-ui,-apple-system,sans-serif;
line-height:1.7;-webkit-font-smoothing:antialiased}
.wrap{max-width:1080px;margin:0 auto;padding:0 24px 80px}
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums}
header.head{padding:48px 0 26px;border-bottom:1px solid var(--rule);margin-bottom:30px}
.eyebrow{font-size:11.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink3);margin-bottom:12px}
h1{font-size:clamp(26px,4vw,38px);line-height:1.2;margin:0 0 14px;letter-spacing:-.02em;text-wrap:balance}
.meta{font-size:12.5px;color:var(--ink3)}
.progress{margin:22px 0 0}
.progress .bar{height:10px;background:var(--inset);border-radius:6px;overflow:hidden}
.progress .bar i{display:block;height:100%;background:var(--good);border-radius:6px;transition:width .4s}
.progress .lbl{display:flex;justify-content:space-between;font-size:12.5px;color:var(--ink2);margin-top:7px}
h2{font-size:21px;margin:44px 0 6px;letter-spacing:-.01em}
h2 .num{color:var(--rail-soft);font-family:ui-monospace,monospace;font-size:14px;margin-right:9px}
p{margin:0 0 12px;max-width:74ch}
.sub{color:var(--ink2);font-size:14px}
.card{background:var(--surface);border:1px solid var(--rule);border-radius:12px;padding:18px 20px;box-shadow:var(--shadow);margin:16px 0}
.callout{border-left:3px solid var(--rail);background:var(--panel);border-radius:0 10px 10px 0;padding:14px 18px;margin:16px 0}
.callout p:last-child{margin-bottom:0}
.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(176px,1fr));margin:16px 0}
.tile{background:var(--surface);border:1px solid var(--rule);border-radius:12px;padding:14px 16px}
.tile .k{font-size:11px;letter-spacing:.06em;color:var(--ink3);text-transform:uppercase}
.tile .v{font-family:ui-monospace,monospace;font-size:25px;font-weight:600;line-height:1.25;margin-top:3px}
.tile .n{font-size:12px;color:var(--ink2);margin-top:2px}
.tile.ok .v{color:var(--good)} .tile.miss .v{color:var(--bad)} .tile.warn .v{color:var(--warn)}
table{width:100%;border-collapse:collapse;font-size:13.5px;margin:14px 0}
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;white-space:nowrap}
td.n,th.n{text-align:right;font-family:ui-monospace,monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
.scroll{overflow-x:auto}
.tag{display:inline-block;font-size:11px;padding:2px 9px;border-radius:20px;font-weight:600;white-space:nowrap}
.tag.good{background:var(--good-soft);color:var(--good)}
.tag.bad{background:var(--bad-soft);color:var(--bad)}
.tag.live{background:var(--live-soft);color:var(--live)}
.tag.idle{background:var(--inset);color:var(--ink3)}
.phase{margin:24px 0}
.phase h3{font-size:15.5px;margin:0 0 4px;display:flex;align-items:center;gap:10px}
.phase h3 .pid{font-family:ui-monospace,monospace;font-size:11.5px;color:var(--surface);background:var(--rail);border-radius:5px;padding:2px 7px}
.tasks{list-style:none;margin:10px 0 0;padding:0;border-left:2px solid var(--rule);margin-left:6px}
.tasks li{position:relative;padding:9px 0 9px 22px}
.tasks li::before{content:'';position:absolute;left:-6px;top:17px;width:10px;height:10px;border-radius:50%;
background:var(--ground);border:2px solid var(--rule2)}
.tasks li.done::before{background:var(--good);border-color:var(--good)}
.tasks li.doing::before{background:var(--live);border-color:var(--live);box-shadow:0 0 0 4px var(--live-soft)}
.tasks li.blocked::before{background:var(--bad);border-color:var(--bad)}
.tasks .t{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
.tasks .tid{font-family:ui-monospace,monospace;font-size:11.5px;color:var(--ink3)}
.tasks .tt{font-size:14.5px;font-weight:500}
.tasks li.done .tt{color:var(--ink2)}
.tasks .note{font-size:12.5px;color:var(--ink3);margin-top:1px}
.rules li{margin-bottom:8px}
.rules b{font-weight:650}
.rules .how{font-size:12px;color:var(--ink3);margin-left:6px}
.log{list-style:none;padding:0;margin:12px 0}
.log li{padding:8px 0 8px 16px;border-left:2px solid var(--rule);font-size:13.5px;color:var(--ink2)}
.log li b{color:var(--ink);font-weight:600;font-family:ui-monospace,monospace;font-size:12px;margin-right:8px}
footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--rule);color:var(--ink3);font-size:12px}
@media(max-width:640px){.wrap{padding:0 16px 60px}}
</style>
</head>
<body>
<div class="wrap">
<header class="head">
<div class="eyebrow">HEX TRAILS · 关卡调优</div>
<h1>${esc(board.goal)}</h1>
<p class="meta mono">第 ${board.round} 轮 · 更新于 ${esc(board.updated)} · 本页每 60 秒自动刷新</p>
<div class="progress">
<div class="bar"><i style="width:${pct}%"></i></div>
<div class="lbl"><span>${doneN} / ${TASKS.length} 项完成</span><span class="mono">${pct}%</span></div>
</div>
</header>
<h2><span class="num">A</span>现在的状态</h2>
<div class="grid">
<div class="tile ${pool.n >= board.targets.levels ? 'ok' : ''}"><div class="k">候选关卡</div><div class="v">${pool.n} <span style="font-size:14px;color:var(--ink3)">/ ${board.targets.levels}</span></div><div class="n">当前关卡集</div></div>
<div class="tile ${pool.blind === 0 ? 'ok' : pool.blind == null ? '' : 'miss'}"><div class="k">盲转 / 关</div><div class="v">${f2(pool.blind)}</div><div class="n">目标 0</div></div>
<div class="tile ${pool.wrong === 0 ? 'ok' : pool.wrong == null ? '' : 'miss'}"><div class="k">转错方向 / 关</div><div class="v">${f2(pool.wrong)}</div><div class="n">目标 0</div></div>
<div class="tile ${pool.apt >= 3 ? 'ok' : 'warn'}"><div class="k">猴子 动作÷棋子</div><div class="v">${f2(pool.apt)}</div><div class="n">目标 ≥3.0(不无脑)</div></div>
</div>
<div class="scroll"><table>
<thead><tr><th>最少旋转数</th><th class="n">候选关数</th><th>说明</th></tr></thead>
<tbody>
${depthRows.map(([r, n]) => `<tr><td>${r} 转</td><td class="n">${n}</td><td class="sub">${Number(r) <= 3 ? '浅' : Number(r) <= 5 ? '中' : '深'}</td></tr>`).join('\n')}
${depthRows.length <= 1 ? '<tr><td colspan="3" class="sub">旋转深度没有梯度——生成器把最少旋转写死了,这正是「太简单」的根因</td></tr>' : ''}
</tbody></table></div>
<p class="sub">路线家族分布:${Object.entries(pool.families).map(([k, v]) => `${esc(k)} ×${v}`).join(' · ') || '—'} 终局可走朝向分布:${JSON.stringify(pool.endgame)}</p>
<h2><span class="num">B</span>什么叫「难度合适且好玩」</h2>
<div class="callout"><p class="sub">${esc(board.definition.why)}</p></div>
<ul class="rules sub">
${board.definition.rules.map(r => `<li><b>${esc(r.k)}</b>:${esc(r.v)}<span class="how">(${esc(r.how)})</span></li>`).join('\n')}
</ul>
<div class="scroll"><table>
<thead><tr><th>关号区间</th><th>旋转数</th><th>目标 difficulty 评分</th></tr></thead>
<tbody>
${board.targets.rotationDepth.map(b => `<tr><td>${esc(b.band)}</td><td>${esc(b.rot)}</td><td class="mono">${esc(b.difficulty)}</td></tr>`).join('\n')}
</tbody></table></div>
<h2><span class="num">C</span>任务</h2>
${board.phases.map(p => `
<div class="phase">
<h3><span class="pid">${esc(p.id)}</span>${esc(p.title)}</h3>
<ul class="tasks">
${p.tasks.map(t => `<li class="${esc(t.status)}"><div class="t"><span class="tid">${esc(t.id)}</span><span class="tt">${esc(t.title)}</span><span class="tag ${STATUS[t.status][1]}">${STATUS[t.status][0]}</span></div>${t.note ? `<div class="note">${esc(t.note)}</div>` : ''}</li>`).join('\n')}
</ul>
</div>`).join('\n')}
${board.rounds.length ? `
<h2><span class="num">D</span>迭代记录</h2>
<div class="scroll"><table>
<thead><tr><th class="n">轮</th><th>改了什么</th><th>测出什么</th></tr></thead>
<tbody>
${board.rounds.map(r => `<tr><td class="n">${r.n}</td><td>${esc(r.change)}</td><td class="sub">${esc(r.result)}</td></tr>`).join('\n')}
</tbody></table></div>` : ''}
${Object.keys(agentBatches).length ? `
<h2><span class="num">E</span>已有的 agent 实测</h2>
<div class="scroll"><table>
<thead><tr><th>批次</th><th class="n">关数</th><th class="n">difficulty</th><th class="n">stuck</th><th class="n">盲转/关</th><th class="n">多转/关</th></tr></thead>
<tbody>
${Object.entries(agentBatches).sort((a, b) => b[1].length - a[1].length).map(([tag, ms]) => {
const g = k => mean(ms.map(m => Number(m.rating?.[k])).filter(x => !Number.isNaN(x)));
return `<tr><td>${esc(tag)}</td><td class="n">${ms.length}</td><td class="n">${f2(g('difficulty'))}</td><td class="n">${f2(g('stuck'))}</td><td class="n">${f2(mean(ms.map(m => m.blindRotations)))}</td><td class="n">${f2(mean(ms.map(m => m.rotations - m.minimumRotations)))}</td></tr>`;
}).join('\n')}
</tbody></table></div>
<p class="sub">difficulty 3 = 刚刚好;stuck 越低越好。这两项是评分里唯二复测可信的(r = 0.93 / 0.85)。</p>` : ''}
<h2><span class="num">F</span>日志</h2>
<ul class="log">
${[...board.log].reverse().map(l => `<li><b>${esc(l.at)}</b>${esc(l.text)}</li>`).join('\n')}
</ul>
<footer>
<p class="mono">看板状态:bench/board.json · 生成:node scripts/build-board.mjs · 页面数字(候选数、盲转、猴子 APT、agent 实测)由脚本从关卡集与 bench/runs 直接测得,不是手写</p>
</footer>
</div>
</body>
</html>`;
writeFileSync(OUT, html);
console.log(`看板已生成:${OUT}`);
console.log(` ${doneN}/${TASKS.length} 项完成(${pct}%)· 候选 ${pool.n}/${board.targets.levels} 关`);