Files
HexTrails/scripts/build-board.mjs
T
cabinwandClaude Opus 5 acc374e276 feat(levels): two-sided endgame release, and a board to track the 50-level run
The chain built its own worst moment: hero and tail deadlock at orientations 0
and +2 and open only at +1, with +3/+4/+5 left entirely to chance. That is why
some levels released at exactly one of six orientations, and why every model
kept writing "I don't know which way to turn" — at +2 one direction was right
and the other five were dead ends.

createDeepLock now traces candidates at all six orientations and requires the
tail to also release at +3, certified with the real swept tracer. A player
stuck at +2 turns either way and something moves. Measured by the rule bot
over the regenerated set: blind rotations, wrong-way turns and extra rotations
all go to zero, from 1.17 / 0.44 / 1.22 per level. Endgame orientation counts
shift from {1:4, 2:10, 3:4} to {2:3, 3:13, 4:1}.

It costs yield: switchback r3 and crossing r3 now produce nothing, and the set
comes out at 17 levels rather than 18. The test witnesses moved with it — a
witness is whichever seed still satisfies the construction, so they are
re-derived from the sweep rather than pinned.

docs/level-board.html is the task board for getting to 50 levels. Task state is
in bench/board.json; every number on the page — candidate count, rotation
quota, blind turns, monkey actions-per-piece, the agent batches — is measured
from the level set and the run archive at build time.

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

256 lines
14 KiB
JavaScript
Raw 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 ----------
const SET = resolve('levels/prototypes/d5.json');
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.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 quota = board.targets.rotationQuota;
const quotaRows = Object.entries(quota).map(([r, want]) => ({ r, want, have: pool.rot[r] || 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 class="n">现有</th><th>缺口</th></tr></thead>
<tbody>
${quotaRows.map(q => `<tr><td>${q.r} 转</td><td class="n">${q.want}</td><td class="n">${q.have}</td><td>${q.have >= q.want ? '<span class="tag good">已满足</span>' : `<span class="tag ${q.have ? 'live' : 'idle'}">还差 ${q.want - q.have}</span>`}</td></tr>`).join('\n')}
</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.difficultyCurve.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} 关`);