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>
This commit is contained in:
cabinw
2026-09-14 16:22:08 +08:00
co-authored by Claude Opus 5
parent 8a736766fd
commit acc374e276
6 changed files with 5112 additions and 7747 deletions
+255
View File
@@ -0,0 +1,255 @@
#!/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} 关`);