#!/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 { 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 level 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?.id) continue; verdicts[d.level.id] = metrics(d); } 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}`; } 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),其余几项是模型噪声,不列。

逐关

${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 ?? '?'}
${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 实测)`);