The chain locks all four of its pieces at the opening by construction, and the pieces grown around it are mostly locked too, so a level built from it hands a player two rim pieces and a third of a second of animation before any thinking starts. Measured across the 235-level pool: median two tappable pieces at the opening, and the best departure available plays for 0.57s. Growth now reserves a couple of slots for showcase pieces — tappable at the opening, with a route long enough to read as a journey and short enough to stay under the animation ceiling. Median opening choices go 2 to 3 and the best opening show 0.72s to 1.12s, without shortcutting the lock: each candidate is still put through the solver. Rotation quota is gone from the board. Turning is free and it is what the game is about, so depth is grown rather than capped; what is gated is whether each turn pays out, whether pieces swing between hopeless and trivial, and whether there is a show up front. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
14 KiB
JavaScript
256 lines
14 KiB
JavaScript
#!/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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 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} 关`);
|