#!/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 quota = board.targets.rotationQuota; const quotaRows = Object.entries(quota).map(([r, want]) => ({ r, want, have: pool.rot[r] || 0 })); const html = ` 50 关任务看板
HEX TRAILS · 关卡调优

${esc(board.goal)}

第 ${board.round} 轮 · 更新于 ${esc(board.updated)} · 本页每 60 秒自动刷新

${doneN} / ${TASKS.length} 项完成${pct}%

A现在的状态

候选关卡
${pool.n} / ${board.targets.levels}
当前关卡集
盲转 / 关
${f2(pool.blind)}
目标 0
转错方向 / 关
${f2(pool.wrong)}
目标 0
猴子 动作÷棋子
${f2(pool.apt)}
目标 ≥3.0(不无脑)
${quotaRows.map(q => ``).join('\n')}
旋转数目标关数现有缺口
${q.r} 转${q.want}${q.have}${q.have >= q.want ? '已满足' : `还差 ${q.want - q.have}`}

路线家族分布:${Object.entries(pool.families).map(([k, v]) => `${esc(k)} ×${v}`).join(' · ') || '—'} 终局可走朝向分布:${JSON.stringify(pool.endgame)}

B什么叫「难度合适且好玩」

${esc(board.definition.why)}

${board.targets.difficultyCurve.map(b => ``).join('\n')}
关号区间旋转数目标 difficulty 评分
${esc(b.band)}${esc(b.rot)}${esc(b.difficulty)}

C任务

${board.phases.map(p => `

${esc(p.id)}${esc(p.title)}

`).join('\n')} ${board.rounds.length ? `

D迭代记录

${board.rounds.map(r => ``).join('\n')}
轮改了什么测出什么
${r.n}${esc(r.change)}${esc(r.result)}
` : ''} ${Object.keys(agentBatches).length ? `

E已有的 agent 实测

${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 ``; }).join('\n')}
批次关数difficultystuck盲转/关多转/关
${esc(tag)}${ms.length}${f2(g('difficulty'))}${f2(g('stuck'))}${f2(mean(ms.map(m => m.blindRotations)))}${f2(mean(ms.map(m => m.rotations - m.minimumRotations)))}

difficulty 3 = 刚刚好;stuck 越低越好。这两项是评分里唯二复测可信的(r = 0.93 / 0.85)。

` : ''}

F日志

`; writeFileSync(OUT, html); console.log(`看板已生成:${OUT}`); console.log(` ${doneN}/${TASKS.length} 项完成(${pct}%)· 候选 ${pool.n}/${board.targets.levels} 关`);