Round 3 tested one hypothesis from round 2's data: levels the agent rated 3 carried 59% more 反差 than the rest, so select for it. With both rounds now at ~100 runs, the answer is in and it is no. The back band went from 2.39 ± 0.10 to 2.19 ± 0.08, roughly two standard errors apart, and the levels averaging 2.5 or better fell from 11 to 6. Contrast is real and it does correlate with being rated harder; selecting on it does not produce harder levels. The low retest reliability does not rescue the change either — r = 0.55 limits ranking one level against another, not the mean of eighteen levels measured a hundred times. So round 2's levels are the ones handed over, rebuilt from its runs via round-set --emitCampaign. select-50 cannot reproduce them: the pool it drew from has been rebuilt since, so the same flags now return different levels. The runs are the record. Rule bot on the shipped set: 50/50 cleared, dead taps 0.00, blind rotations 0.12, worst blind streak 1. 198 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
254 lines
16 KiB
JavaScript
254 lines
16 KiB
JavaScript
#!/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 ?? 'R2 · 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
const mean = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null;
|
||
const f2 = x => x == null || Number.isNaN(x) ? '—' : (Math.round(x * 100) / 100).toFixed(2);
|
||
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),
|
||
}]));
|
||
|
||
// This round played most levels twice, so reliability is measured here rather
|
||
// than quoted: an older, smaller sample had difficulty at r = 0.93, and on this
|
||
// set it is not that.
|
||
const runsByBoard = new Map();
|
||
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.status === 'playing' || !d.level?.tiles) continue;
|
||
const k = deepLevelKey(d.level);
|
||
if (!boardKey.has(k)) continue;
|
||
if (!runsByBoard.has(k)) runsByBoard.set(k, []);
|
||
runsByBoard.get(k).push(metrics(d));
|
||
} catch { /* torn read */ }
|
||
}
|
||
}
|
||
const retestPairs = [...runsByBoard.values()].filter(v => v.length >= 2).map(v => [v[0], v[1]]);
|
||
// Quote the round's own complaints rather than the previous round's, which were
|
||
// written about levels this edition no longer contains.
|
||
const complaints = [...runsByBoard.values()].flat().map(m => String(m.rating?.worst ?? '').trim()).filter(Boolean);
|
||
const pickQuotes = re => [...new Set(complaints.filter(t => re.test(t)))]
|
||
.sort((a, b) => a.length - b.length).slice(0, 2).map(t => t.length > 34 ? t.slice(0, 34) + '…' : t);
|
||
const longQuotes = pickQuotes(/\d{2,}\s*格|太长|读不完|跟不住|绕/);
|
||
const grazeQuotes = pickQuotes(/紧贴|擦/);
|
||
const sd = a => { const m = mean(a); return Math.sqrt(mean(a.map(v => (v - m) ** 2))); };
|
||
const retest = get => {
|
||
const ok = retestPairs.filter(p => Number.isFinite(get(p[0])) && Number.isFinite(get(p[1])));
|
||
if (ok.length < 8) return null;
|
||
const A = ok.map(p => get(p[0])), B = ok.map(p => get(p[1]));
|
||
const sa = sd(A), sb = sd(B);
|
||
if (!sa || !sb) return null;
|
||
const ma = mean(A), mb = mean(B);
|
||
return mean(A.map((v, i) => (v - ma) * (B[i] - mb))) / (sa * sb);
|
||
};
|
||
|
||
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 BANDS = [['前档 L1–L12', 0, 12], ['中档 L13–L32', 12, 32], ['后档 L33–L50', 32, 50]];
|
||
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 = 刚刚好;无解感越低越好。这两项只在<b>按档平均</b>时可读:逐关复测 r = ${f2(retest(m => Number(m.rating?.difficulty)))} / ${f2(retest(m => Number(m.rating?.stuck)))}(本轮 ${retestPairs.length} 对复测现算),好玩与清晰度更低,不列。三档单调上升是这一轮的结果,逐关的高低不要当真。</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>:这一轮三档是 ${BANDS.map(([, a, b]) => f1(mean(levels.slice(a, b).map(l => verdicts[l.id]).filter(Boolean).map(m => Number(m.rating?.difficulty)).filter(Number.isFinite)))).join(' / ')},第一次单调上升,但后档 ${f1(mean(levels.slice(32).map(l => verdicts[l.id]).filter(Boolean).map(m => Number(m.rating?.difficulty)).filter(Number.isFinite)))} 仍低于「刚刚好」的 3 分。</p>
|
||
<p><b>两件事我没有擅自改,留给你定。</b>agent 这一轮写了 ${complaints.length} 条抱怨,反复出现两个主题:<b>路线太长看不完</b>(${longQuotes.map(q => `「${esc(q)}」`).join('') || '—'}),以及<b>紧贴容易看漏</b>(${grazeQuotes.map(q => `「${esc(q)}」`).join('') || '—'})。两项都是感知问题,而<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 实测)`);
|