#!/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 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 `
缩略图是开局盘面:绿色的棋子开局就能点,灰色的被挡着。点「试玩」用真游戏打开这一关。
| 档 | 已评关数 | 难度 | 无解感 | 盲转/关 | 空点/关 |
|---|
机械面是干净的,可以信。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 是按旋转深度、棋子数、互锁程度排的,不是按谁测出来更难。评分能用的只有整档:这一轮三档是 ${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 分。
两件事我没有擅自改,留给你定。agent 这一轮写了 ${complaints.length} 条抱怨,反复出现两个主题:路线太长看不完(${longQuotes.map(q => `「${esc(q)}」`).join('') || '—'}),以及紧贴容易看漏(${grazeQuotes.map(q => `「${esc(q)}」`).join('') || '—'})。两项都是感知问题,而已上线的 v0.5 在这两项上更严重(最长路线 88 格 vs 这里 ${Math.max(...Object.values(perceptual).map(p => p.longest))} 格)——它是这个游戏本来的性格,不是这次构造引入的。卡片上「最长路线」超 45 格、「紧贴棋子」超 1 枚会标黄,你试玩时留意。