Files
HexTrails/scripts/build-review-50.mjs
T
cabinwandClaude Opus 5 ce7f6d30cf feat(levels): gate the dead turn, and state the curve instead of hoping for it
Round 1 played all fifty levels and came back with the mechanics clean and one
complaint repeated across the reports: 短暂以为转错了方向 — a turn that changed
nothing. That one is mechanical rather than perceptual, so it can be gated.

The first attempt gated `doubleDead`, and it was wrong. That counts dead states,
which scales with the state space: three-turn levels have ~187 reachable stuck
states and five-turn ones ~739, so a raw count made deep levels look eight times
worse when the rate is 2% against 5%. It would have erased every level past
three turns — the opposite of the brief, where turning is the signature and is
not to be rationed. The gate is `maxDeadTurns`, the longest run of turns before
something opens, at 1: one beat of suspense, then a release. Eleven of the fifty
shipped at 2.

Selection could not express the curve either. Spreading picks evenly over the
ranked pool returns whatever the pool is made of, and the pool is 88%
three-turn, so no weighting moved the distribution off {3: 46}. A quota per
depth states the curve outright, deepest first so the scarce end picks before a
family cap is spent on levels the shallow end has hundreds of.

Same rule bot, both sets: blind rotations 0.24 → 0.12, worst blind streak 4 → 1,
decisions unchanged at 10.3. The same amount to do, half the wasted motion.
Rotation now runs 1.8 / 3.0 / 4.1 across the bands against 1.8 / 3.0 / 3.5.

Verdicts are keyed by the board rather than by its id, because a reselect
re-issues ids from the ordinal and `c50-01-v05` is not the same board before and
after — the review sheet was one reselect away from printing one level's verdict
under another's thumbnail.

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

216 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/review-50.html — the sheet the 50 levels are handed over on.
//
// node scripts/build-review-50.mjs [--agent="R1 · grok xhigh"]
//
// One card per level: the board as it opens, what the construction guarantees,
// and what the agent that played it reported. Every card links into the real
// game at that level, because a level is judged by playing it.
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { trace, rotateBoard } from '../src/core.js';
import { deepLevelKey, legibility } from '../src/deep-lock.js';
import { layout } from '../src/geometry.js';
import { metrics } from '../src/bench.js';
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
const AGENT = args.agent ?? 'R1 · grok xhigh';
const OUT = resolve(args.out ?? 'docs/review-50.html');
const raw = JSON.parse(readFileSync(resolve('levels/campaign-50.json'), 'utf8'));
const levels = raw.levels;
// Agent verdicts, keyed by the board itself rather than by its id. Re-running
// the selection re-issues ids from the ordinal, so `c50-01-v05` after a reselect
// is a different board than `c50-01-v05` before it — matching on the id would
// quietly print one level's verdict under another level's thumbnail. Runs store
// the whole level, so the pieces and their positions are the key.
const boardKey = new Map(levels.map(l => [deepLevelKey(l), l.id]));
const verdicts = {};
const RUNS = resolve('bench/runs');
if (existsSync(RUNS)) {
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 (d.agent !== AGENT || !d.level?.tiles || d.status === 'playing') continue;
const id = boardKey.get(deepLevelKey(d.level));
if (!id) continue; // a level this edition no longer ships
// Two runs of one level (a retest) both count; the later one wins the card.
const m = metrics(d);
if (!verdicts[id] || String(d.updatedAt) > String(verdicts[id].updatedAt)) verdicts[id] = { ...m, updatedAt: d.updatedAt };
} catch { /* torn read */ }
}
}
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const mean = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null;
const f1 = x => x == null || Number.isNaN(x) ? '—' : (Math.round(x * 10) / 10).toFixed(1);
// A flat SVG of the opening position: tracks in grey, pieces as arrows. Small
// enough to read fifty of them on one page.
const ARROWS = ['↑', '↗', '↘', '↓', '↙', '↖'];
function thumbnail(level, size = 168) {
const view = layout(level.cells, size, size);
const { size: r, ox, oy } = view;
const px = (q, rr) => ({ x: ox + r * 1.5 * q, y: oy + r * Math.sqrt(3) * (rr + q / 2) });
const cells = level.cells.map(c => {
const { x, y } = px(c.q, c.r);
const pts = Array.from({ length: 6 }, (_, i) => {
const a = Math.PI / 180 * (60 * i);
return `${(x + r * Math.cos(a)).toFixed(1)},${(y + r * Math.sin(a)).toFixed(1)}`;
}).join(' ');
return `<polygon points="${pts}"/>`;
}).join('');
const open = new Set(level.tiles.filter(t => trace(level, t, level.tiles).status === 'exit').map(t => t.id));
const pieces = level.tiles.map(t => {
const { x, y } = px(t.q, t.r);
return `<g class="${open.has(t.id) ? 'free' : 'held'}"><circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${(r * .62).toFixed(1)}"/>`
+ `<text x="${x.toFixed(1)}" y="${(y + r * .26).toFixed(1)}" font-size="${(r * .9).toFixed(1)}">${ARROWS[t.dir]}</text></g>`;
}).join('');
return `<svg viewBox="0 0 ${size} ${size}" class="thumb" role="img" aria-label="${esc(level.id)} 开局盘面">`
+ `<g class="cells">${cells}</g><g class="pieces">${pieces}</g></svg>`;
}
// The two things the agent complained about in all 33 reports it filed, and the
// two the text bench is least able to judge for us: how long the longest line
// on the board is, and how many pieces are held by somebody their line never
// touches. Both are perceptual, so they are printed rather than gated on.
const perceptual = Object.fromEntries(levels.map(l => [l.id, {
longest: Math.max(...l.tiles.map(t => trace(l, t, [], true).steps.length)),
...legibility(l),
}]));
const rated = levels.map(l => verdicts[l.id]).filter(Boolean);
const band = (a, b) => levels.slice(a, b).map(l => verdicts[l.id]).filter(Boolean);
const bandRow = (label, a, b) => {
const rows = band(a, b);
const g = k => mean(rows.map(m => Number(m.rating?.[k])).filter(x => !Number.isNaN(x)));
return `<tr><td>${label}</td><td class="n">${rows.length}</td><td class="n">${f1(g('difficulty'))}</td><td class="n">${f1(g('stuck'))}</td><td class="n">${f1(mean(rows.map(m => m.blindRotations)))}</td><td class="n">${f1(mean(rows.map(m => m.deadTaps)))}</td></tr>`;
};
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>五十关 · 人工审核</title>
<style>
:root{
--ground:#EEF1F4; --surface:#FFF; --inset:#E3E8ED; --panel:#F7F9FA;
--ink:#141E28; --ink2:#48596A; --ink3:#6B7A88;
--rule:#D3DAE1; --rail:#435F78; --good:#236E64; --bad:#B23A26; --warn:#8A5C0F;
--cell:#E7E1D3; --cell-line:#CFC7B4; --held:#9AA9B6; --free:#2A7F74;
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; --rail:#8CB0CC; --good:#4FC7B7; --bad:#E4836C; --warn:#E0AE5E;
--cell:#232D36; --cell-line:#31404C; --held:#5B6D7C; --free:#4FC7B7;
color-scheme:dark;
}}
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--ink);
font-family:"PingFang SC","Hiragino Sans GB",ui-sans-serif,system-ui,-apple-system,sans-serif;line-height:1.65}
.wrap{max-width:1180px;margin:0 auto;padding:40px 22px 90px}
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums}
h1{font-size:30px;margin:0 0 10px;letter-spacing:-.02em}
.lede{color:var(--ink2);font-size:15px;max-width:70ch;margin:0 0 8px}
.meta{font-size:12.5px;color:var(--ink3);margin:0 0 26px}
h2{font-size:19px;margin:40px 0 10px}
table{width:100%;border-collapse:collapse;font-size:13.5px;margin:12px 0 26px}
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}
td.n,th.n{text-align:right;font-family:ui-monospace,monospace;white-space:nowrap}
.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(212px,1fr))}
.card{background:var(--surface);border:1px solid var(--rule);border-radius:12px;padding:12px;display:flex;flex-direction:column;gap:8px}
.card h3{margin:0;font-size:13.5px;display:flex;align-items:baseline;gap:7px}
.card h3 .no{font-family:ui-monospace,monospace;font-size:15px;font-weight:700;color:var(--rail)}
.card h3 .fam{font-size:11px;color:var(--ink3);margin-left:auto}
.thumb{width:100%;height:auto;background:var(--inset);border-radius:8px;display:block}
.thumb .cells polygon{fill:var(--cell);stroke:var(--cell-line);stroke-width:.6}
.thumb .pieces circle{fill:var(--held)}
.thumb .pieces .free circle{fill:var(--free)}
.thumb text{fill:#fff;text-anchor:middle;font-family:system-ui}
.kv{display:grid;grid-template-columns:auto auto;gap:1px 8px;font-size:11.5px;color:var(--ink2)}
.kv b{font-family:ui-monospace,monospace;color:var(--ink);font-weight:600;text-align:right}
.kv b.hot{color:var(--warn)}
.verdict{font-size:11.5px;border-top:1px dashed var(--rule);padding-top:7px;color:var(--ink2)}
.verdict .row{display:flex;gap:9px;flex-wrap:wrap;font-family:ui-monospace,monospace}
.verdict .worst{margin-top:4px;color:var(--ink3);line-height:1.5}
.play{display:block;text-align:center;background:var(--rail);color:#fff;text-decoration:none;border-radius:8px;padding:7px;font-size:12.5px;font-weight:600;margin-top:auto}
.pill{display:inline-block;font-size:10.5px;padding:1px 7px;border-radius:20px;font-weight:600}
.pill.bad{background:var(--bad);color:#fff}
.pill.warn{background:var(--warn);color:#fff}
.note{background:var(--panel);border:1px solid var(--rule);border-left:3px solid var(--rail);border-radius:10px;padding:4px 18px;margin:12px 0 8px}
.note p{font-size:13.5px;color:var(--ink2);max-width:78ch}
.note b{color:var(--ink)}
footer{margin-top:50px;padding-top:18px;border-top:1px solid var(--rule);color:var(--ink3);font-size:12px}
</style>
</head>
<body>
<div class="wrap">
<h1>五十关 · 人工审核</h1>
<p class="lede">缩略图是<b>开局盘面</b>:绿色的棋子开局就能点,灰色的被挡着。点「试玩」用真游戏打开这一关。</p>
<p class="meta mono">关卡集 levels/campaign-50.json · agent 实测批次 ${esc(AGENT)}(${rated.length}/${levels.length} 关已评)· 生成于 ${new Date().toLocaleString('sv-SE').slice(0, 16)}</p>
<h2>难度曲线(agent 实测)</h2>
<table>
<thead><tr><th>档</th><th class="n">已评关数</th><th class="n">难度</th><th class="n">无解感</th><th class="n">盲转/关</th><th class="n">空点/关</th></tr></thead>
<tbody>
${bandRow('前档 L1–L12', 0, 12)}
${bandRow('中档 L13–L32', 12, 32)}
${bandRow('后档 L33–L50', 32, 50)}
</tbody>
</table>
<p class="meta">难度 3 = 刚刚好;无解感越低越好。这两项是评分里唯二复测可信的(r = 0.93 / 0.85),其余几项是模型噪声,不列。</p>
<h2>这批数字里,哪些能信</h2>
<div class="note">
<p><b>机械面是干净的,可以信。</b>agent 跑完 ${rated.length} 关:空点 ${f1(mean(rated.map(m => m.deadTaps)))} 次/关、盲转 ${f1(mean(rated.map(m => m.blindRotations)))} 次/关、反向转 ${f1(mean(rated.map(m => m.wrongWayRotations)))} 次/关,${rated.filter(m => m.cleared).length}/${rated.length} 关通关。「动了半天没结果」这类结构缺陷已经被生成期的闸门挡掉了。</p>
<p><b>难度评分不能信,它区分不了这批关卡。</b>${rated.length} 关里有 ${rated.filter(m => Number(m.rating?.difficulty) === 2).length} 关被打成 2 分,而这些关卡机械上差着三倍(决策数 0–8)。原因不在关卡,在 bench:文字盘面会把每颗棋子的路线、挡路者、紧贴者<b>直接列成清单</b>,agent 从来不需要用眼睛顺着线走一遍——而那正是这个游戏的难点所在。agent 自己在报告里写过这句:「挡路名单已经写全,不用顺着格子数」。</p>
<p><b>所以难度顺序是机械假设,等你的判定。</b>L1 到 L50 是按旋转深度、棋子数、互锁程度排的,不是按谁测出来更难。评分唯一能用的是它的<b>总体读数</b>:整批偏易一档(2 分 vs 刚刚好的 3 分)。</p>
<p><b>两件事我没有擅自改,留给你定。</b>agent 的 33 条抱怨里反复出现两个主题:路线太长看不完(「68 格 27 弯」「42 格 42 弯」),以及紧贴容易看漏(「看着能出界却会擦到」)。两项都是感知问题,而<b>已上线的 v0.5 在这两项上更严重</b>(最长路线 88 格 vs 这里 ${Math.max(...Object.values(perceptual).map(p => p.longest))} 格)——它是这个游戏本来的性格,不是这次构造引入的。卡片上「最长路线」超 45 格、「紧贴棋子」超 1 枚会标黄,你试玩时留意。</p>
</div>
<h2>逐关</h2>
<div class="grid">
${levels.map((l, i) => {
const c = l.campaign ?? {};
const v = verdicts[l.id];
const flags = [];
if (v && !v.cleared) flags.push('<span class="pill bad">未通关</span>');
if (v && Number(v.rating?.stuck) >= 4) flags.push('<span class="pill warn">觉得无解</span>');
if (v && v.blindRotations >= 2) flags.push('<span class="pill warn">盲转多</span>');
return `<div class="card">
<h3><span class="no">${String(i + 1).padStart(2, '0')}</span>${flags.join('')}<span class="fam">${esc(c.family ?? '')}</span></h3>
${thumbnail(l)}
<div class="kv">
<span>旋转 / 棋子</span><b>${c.rotations ?? '?'} 转 · ${c.pieces ?? l.tiles.length} 枚</b>
<span>开局可点</span><b>${c.openingChoices ?? '?'} 枚</b>
<span>开局演出</span><b>${c.openingBestMs != null ? (c.openingBestMs / 1000).toFixed(1) + 's' : '?'}</b>
<span>长巡游 / 反差</span><b>${c.cruises ?? '?'} · ${c.contrast ?? '?'}</b>
<span>最长路线</span><b class="${perceptual[l.id].longest > 45 ? 'hot' : ''}">${perceptual[l.id].longest} 格</b>
<span>紧贴棋子</span><b class="${perceptual[l.id].grazeTraps > 1 ? 'hot' : ''}">${perceptual[l.id].grazeTraps} 枚</b>
</div>
${v ? `<div class="verdict">
<div class="row"><span>难度 ${v.rating?.difficulty ?? '—'}</span><span>无解感 ${v.rating?.stuck ?? '—'}</span><span>${v.steps} 步</span><span>${v.decisions} 决策</span></div>
${v.rating?.worst ? `<div class="worst">${esc(String(v.rating.worst).slice(0, 90))}</div>` : ''}
</div>` : '<div class="verdict">尚未实测</div>'}
<a class="play" href="../index.html?campaign=c50&level=${i + 1}">试玩 →</a>
</div>`;
}).join('\n')}
</div>
<footer>
<p class="mono">生成:node scripts/build-review-50.mjs · 选关:node scripts/select-50.mjs · 看板:docs/level-board.html</p>
<p>缩略图与「开局可点」同源,都取自求解器对开局盘面的判定,不是示意图。</p>
</footer>
</div>
</body>
</html>`;
writeFileSync(OUT, html);
console.log(`审核页已生成:${OUT}(${levels.length} 关,${rated.length} 关已有 agent 实测)`);