#!/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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 ``; }).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 `` + `${ARROWS[t.dir]}`; }).join(''); return `` + `${cells}${pieces}`; } // 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 `${label}${rows.length}${f1(g('difficulty'))}${f1(g('stuck'))}${f1(mean(rows.map(m => m.blindRotations)))}${f1(mean(rows.map(m => m.deadTaps)))}`; }; const html = ` 五十关 · 人工审核

五十关 · 人工审核

缩略图是开局盘面:绿色的棋子开局就能点,灰色的被挡着。点「试玩」用真游戏打开这一关。

关卡集 levels/campaign-50.json · agent 实测批次 ${esc(AGENT)}(${rated.length}/${levels.length} 关已评)· 生成于 ${new Date().toLocaleString('sv-SE').slice(0, 16)}

难度曲线(agent 实测)

${bandRow('前档 L1–L12', 0, 12)} ${bandRow('中档 L13–L32', 12, 32)} ${bandRow('后档 L33–L50', 32, 50)}
档已评关数难度无解感盲转/关空点/关

难度 3 = 刚刚好;无解感越低越好。这两项是评分里唯二复测可信的(r = 0.93 / 0.85),其余几项是模型噪声,不列。

这批数字里,哪些能信

机械面是干净的,可以信。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} 关通关。「动了半天没结果」这类结构缺陷已经被生成期的闸门挡掉了。

难度评分不能信,它区分不了这批关卡。${rated.length} 关里有 ${rated.filter(m => Number(m.rating?.difficulty) === 2).length} 关被打成 2 分,而这些关卡机械上差着三倍(决策数 0–8)。原因不在关卡,在 bench:文字盘面会把每颗棋子的路线、挡路者、紧贴者直接列成清单,agent 从来不需要用眼睛顺着线走一遍——而那正是这个游戏的难点所在。agent 自己在报告里写过这句:「挡路名单已经写全,不用顺着格子数」。

所以难度顺序是机械假设,等你的判定。L1 到 L50 是按旋转深度、棋子数、互锁程度排的,不是按谁测出来更难。评分唯一能用的是它的总体读数:整批偏易一档(2 分 vs 刚刚好的 3 分)。

两件事我没有擅自改,留给你定。agent 的 33 条抱怨里反复出现两个主题:路线太长看不完(「68 格 27 弯」「42 格 42 弯」),以及紧贴容易看漏(「看着能出界却会擦到」)。两项都是感知问题,而已上线的 v0.5 在这两项上更严重(最长路线 88 格 vs 这里 ${Math.max(...Object.values(perceptual).map(p => p.longest))} 格)——它是这个游戏本来的性格,不是这次构造引入的。卡片上「最长路线」超 45 格、「紧贴棋子」超 1 枚会标黄,你试玩时留意。

逐关

${levels.map((l, i) => { const c = l.campaign ?? {}; const v = verdicts[l.id]; const flags = []; if (v && !v.cleared) flags.push('未通关'); if (v && Number(v.rating?.stuck) >= 4) flags.push('觉得无解'); if (v && v.blindRotations >= 2) flags.push('盲转多'); return `

${String(i + 1).padStart(2, '0')}${flags.join('')}${esc(c.family ?? '')}

${thumbnail(l)}
旋转 / 棋子${c.rotations ?? '?'} 转 · ${c.pieces ?? l.tiles.length} 枚 开局可点${c.openingChoices ?? '?'} 枚 开局演出${c.openingBestMs != null ? (c.openingBestMs / 1000).toFixed(1) + 's' : '?'} 长巡游 / 反差${c.cruises ?? '?'} · ${c.contrast ?? '?'} 最长路线${perceptual[l.id].longest} 格 紧贴棋子${perceptual[l.id].grazeTraps} 枚
${v ? `
难度 ${v.rating?.difficulty ?? '—'}无解感 ${v.rating?.stuck ?? '—'}${v.steps} 步${v.decisions} 决策
${v.rating?.worst ? `
${esc(String(v.rating.worst).slice(0, 90))}
` : ''}
` : '
尚未实测
'} 试玩 →
`; }).join('\n')}
`; writeFileSync(OUT, html); console.log(`审核页已生成:${OUT}(${levels.length} 关,${rated.length} 关已有 agent 实测)`);