feat(bench): batch runners, aggregation and a standalone kit

Four ways in, depending on what you have: a shell agent driving bench.mjs, a
direct-API loop for models with only an endpoint (plain text in, one JSON line
out, so weak and local models can play), a rule-following bot that sweeps 1000
levels in 12 seconds, and a folder you can hand to someone else.

bench-kit.mjs builds that folder: CLI, engine, level pack, observer page and
tile atlas, about 1.5 MB, needing only Node 20 — no repo, no install, no
network. The engine files are discovered by following imports so a new module
can't be left behind, every path rewrite is asserted so a rename fails the
build loudly, and the builder plays a real game inside the result before
declaring it done.

bench-report.mjs aggregates two ways: per level (is this level any good) and
per model (how does this agent play). bench-import.mjs brings runs played
elsewhere back in under a tag.

bench-pack.mjs is the earlier, smaller export with no observer page; superseded
by bench-kit but kept in history rather than dropped silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 14:00:33 +08:00
co-authored by Claude Opus 5
parent 47be0ad9c4
commit f3e91e3597
7 changed files with 699 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
// Direct-API agent loop: the fast path for batch runs. Plain text in, one JSON
// line (or a JSON array to chain several actions) out — no tool-use API needed,
// so weak and local models can play. The compact system prompt is sent once.
// BENCH_PROVIDER=anthropic ANTHROPIC_API_KEY=… node scripts/bench-agent.mjs --run=r1 --level=1 --model=claude-sonnet-5 --persona=tapper
// BENCH_PROVIDER=openai OPENAI_API_KEY=… node scripts/bench-agent.mjs --run=r2 --level=1 --model=gpt-4o-mini
// BENCH_PROVIDER=openai BENCH_BASE_URL=http://localhost:11434/v1 node scripts/bench-agent.mjs --run=r3 --level=1 --model=qwen2.5:7b
// BENCH_PROVIDER=mock node scripts/bench-agent.mjs --run=r4 --level=1 # rule-following bot, no network
// --dry prints the first prompt and exits · --full-protocol sends the whole AGENT.md instead of the compact rules
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { createRun, observe, actMany, rate, rulesText, RATING_QUESTIONS, describeAction, metrics } from '../src/bench.js';
const flags = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
// Presets for the endpoints we know; anything else OpenAI-compatible works via
// BENCH_PROVIDER=openai + BENCH_BASE_URL. Model ids change — pass --model.
const PROVIDERS = {
anthropic: { style: 'anthropic', base: 'https://api.anthropic.com', keyVar: 'ANTHROPIC_API_KEY', model: 'claude-sonnet-5' },
openai: { style: 'openai', base: 'https://api.openai.com/v1', keyVar: 'OPENAI_API_KEY', model: 'gpt-4o-mini' },
deepseek: { style: 'openai', base: 'https://api.deepseek.com', keyVar: 'DEEPSEEK_API_KEY', model: 'deepseek-flash' },
xai: { style: 'openai', base: 'https://api.x.ai/v1', keyVar: 'XAI_API_KEY', model: 'grok-4.6' },
mock: { style: 'mock', base: '', keyVar: '', model: 'rulebot' },
};
const providerName = process.env.BENCH_PROVIDER || 'anthropic';
const provider = PROVIDERS[providerName];
if (!provider) { console.error(`未知 BENCH_PROVIDER=${providerName},可用:${Object.keys(PROVIDERS).join(' / ')}`); process.exit(1); }
const model = flags.model || process.env.BENCH_MODEL || provider.model;
const baseUrl = process.env.BENCH_BASE_URL || provider.base;
const apiKey = provider.keyVar ? process.env[provider.keyVar] || '' : '';
const RUNS = resolve('bench/runs');
if (!flags.run || !flags.level) { console.error('需要 --run=NAME --level=ID|序号'); process.exit(1); }
// A missing key otherwise surfaces as a raw 401 stack halfway through a batch.
if (provider.keyVar && !apiKey && !process.env.BENCH_BASE_URL && !flags.dry) {
console.error(`${providerName} 需要环境变量 ${provider.keyVar}。本地端点用 BENCH_PROVIDER=openai BENCH_BASE_URL=…,无网络试跑用 BENCH_PROVIDER=mock。`);
process.exit(1);
}
const loadSet = path => { const raw = JSON.parse(readFileSync(resolve(path), 'utf8')); return Array.isArray(raw) ? raw : Array.isArray(raw.levels) ? raw.levels : Object.values(raw).filter(l => l && l.cells); };
const levels = loadSet(flags.set || 'levels/prototypes/d5.json');
const level = levels.find(l => l.id === flags.level) || levels[Number(flags.level) - 1];
if (!level) { console.error(`找不到关卡 ${flags.level}`); process.exit(1); }
const runObj = createRun({ level, run: flags.run, persona: flags.persona || 'tapper', model, agent: flags.agent ? String(flags.agent) : `${providerName} API`, peek: !!flags.peek, budget: Number(flags.budget || 80), startedAt: new Date().toISOString() });
runObj.timing = { decisions: [], totalMs: 0 };
// The run file is ours alone; the shared index is only touched at start and
// end (parallel runs would otherwise lose each other's entries), and the
// batch runner rebuilds it from the directory anyway.
function save(withIndex = false) {
mkdirSync(RUNS, { recursive: true });
runObj.updatedAt = new Date().toISOString();
writeFileSync(resolve(RUNS, `${runObj.run}.json`), JSON.stringify(runObj));
if (!withIndex) return;
const indexPath = resolve(RUNS, 'index.json');
let index = [];
try { index = existsSync(indexPath) ? JSON.parse(readFileSync(indexPath, 'utf8')) : []; } catch { index = []; }
const entry = { run: runObj.run, level: level.id, persona: runObj.persona, model, peek: runObj.peek, status: runObj.status, steps: runObj.steps.length, updatedAt: runObj.updatedAt };
const at = index.findIndex(e => e.run === runObj.run); if (at >= 0) index[at] = entry; else index.push(entry);
writeFileSync(indexPath, JSON.stringify(index, null, 1));
}
const PERSONAS = {
reader: '你是读盘型玩家:先把路线看清楚,想好了再点,尽量少试错。',
tapper: '你是试错型玩家:看个大概就点,用反馈学习;被挡了再想。',
turner: '你是转盘型玩家:不确定的时候先转一下看看打开了什么。',
novice: '你是第一次玩的新手,没有固定策略,按直觉来。',
};
const ACTION_FORMAT = [
'输出格式:每一轮只输出 JSON,不要任何别的文字。一个动作是一个对象;连做几步就输出一个数组(最多 8 个),链在第一个没成功的动作和任何一次旋转之后自动停下并返回新盘面。',
'{"act":"tap","id":"t5","note":"为什么"} {"act":"rotate","direction":"right"|"left","note":"…"} {"act":"undo","note":"…"}' + (runObj.peek ? ' {"act":"peek","id":"t3","note":"…"}' : '') + ' {"act":"giveup","reason":"…"}',
'[{"act":"tap","id":"t4","note":"边上"},{"act":"tap","id":"t5","note":"边上"},{"act":"rotate","direction":"right","note":"清完就转"}]',
'像人一样:看清一次盘面就把有把握的几步一起提交,别每点一枚重新读一遍;旋转之后必须重新看盘再决定。每个动作都带 note。',
].join('\n');
const system = flags['full-protocol']
? ['你正在玩一关六角轨道解谜。下面是操作协议、规则和你的人设。', '', readFileSync(resolve('labs/bench/AGENT.md'), 'utf8'), '', rulesText(runObj.peek), '', PERSONAS[runObj.persona] || PERSONAS.novice, '', ACTION_FORMAT].join('\n')
: ['你正在玩一关六角轨道解谜,目标是让所有棋子离场。', '', rulesText(runObj.peek), '', PERSONAS[runObj.persona] || PERSONAS.novice, '', ACTION_FORMAT].join('\n');
async function chat(messages) {
if (provider.style === 'mock') return mockPlayer(messages);
if (provider.style === 'anthropic') {
const res = await fetch(`${baseUrl}/v1/messages`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({ model, max_tokens: 600, system, messages }) });
const data = await res.json();
if (!res.ok) throw new Error(JSON.stringify(data));
return data.content.map(c => c.text || '').join('');
}
// Only the parameters every OpenAI-compatible endpoint we target accepts —
// DeepSeek rejects frequency_penalty / presence_penalty outright.
const res = await fetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey || 'none'}` },
body: JSON.stringify({ model, max_tokens: 600, temperature: 0.3, messages: [{ role: 'system', content: system }, ...messages] }) });
const data = await res.json();
if (!res.ok) throw new Error(JSON.stringify(data));
const choice = data.choices?.[0]?.message;
if (!choice) throw new Error(JSON.stringify(data));
return choice.content || '';
}
// Rule bot: plays exactly the written advice — tap everything whose line is
// clear, otherwise turn right. A floor for the LLM runs and a fast sweep tool.
function mockPlayer() {
const view = observe(runObj, { format: 'json' });
const clear = view.pieces.filter(p => p.route.end === 'exit' && !p.route.onRoute.length && !p.route.grazes.length).slice(0, 8);
if (clear.length) return JSON.stringify(clear.map(p => ({ act: 'tap', id: p.id, note: '路上没有棋子也不紧贴谁' })));
return JSON.stringify({ act: 'rotate', direction: 'right', note: '一枚都走不了,转一下' });
}
const firstJson = text => { const m = text.match(/\{[\s\S]*?\}/); if (!m) return null; try { return JSON.parse(m[0]); } catch { return null; } };
const toAction = j => {
if (!j || !j.act) return null;
if (j.act === 'rotate') return { type: 'rotate', direction: ['right', 'r', 1, '1', '+1', '右'].includes(j.direction) ? 1 : -1, note: j.note || '' };
if (j.act === 'tap' || j.act === 'peek') return { type: j.act, id: String(j.id || ''), note: j.note || '' };
if (j.act === 'undo') return { type: 'undo', note: j.note || '' };
if (j.act === 'giveup') return { type: 'giveup', reason: j.reason || j.note || '', note: j.note || '' };
return null;
};
const parseActions = text => {
const arr = text.match(/\[[\s\S]*\]/);
if (arr) { try { const list = JSON.parse(arr[0]); if (Array.isArray(list)) return list.map(toAction).filter(Boolean).slice(0, 8); } catch { /* fall through */ } }
const one = toAction(firstJson(text));
return one ? [one] : [];
};
save(true);
const keep = Number(flags.context || 6);
const history = [];
let prompt = `${observe(runObj)}\n\n请输出 JSON 动作(或数组连做几步)。`;
if (flags.dry) { console.log('=== system ===\n' + system + '\n\n=== user ===\n' + prompt); process.exit(0); }
const started = Date.now();
console.log(`开始 ${runObj.run} · ${level.id} · ${providerName}/${model} · 人设 ${runObj.persona}`);
while (runObj.status === 'playing') {
history.push({ role: 'user', content: prompt });
const window = history.slice(-keep * 2);
let actions = [], reply = '';
const t0 = Date.now();
for (let attempt = 0; attempt < 3 && !actions.length; attempt++) {
reply = await chat(attempt ? [...window, { role: 'assistant', content: reply }, { role: 'user', content: '上一条不是合法的 JSON 动作。只输出 JSON,例如 {"act":"tap","id":"t3","note":"…"} 或一个数组。' }] : window);
actions = parseActions(reply);
}
const thinkMs = Date.now() - t0;
if (!actions.length) { console.error('连续三次无法解析动作,停止。最后回复:' + reply); break; }
history.push({ role: 'assistant', content: JSON.stringify(actions.map(a => ({ act: a.type, ...(a.id ? { id: a.id } : {}), ...(a.direction ? { direction: a.direction } : {}), note: a.note }))) });
const { outcomes, stopped } = actMany(runObj, actions);
runObj.timing.decisions.push({ step: runObj.steps.length, actions: outcomes.length, thinkMs });
save();
const lines = outcomes.map(out => `${describeAction(out.step?.action || out.rejected?.action)} → ${out.feedback}`);
for (const [i, out] of outcomes.entries()) console.log(` ${String(out.step?.n ?? '-').padStart(3)} ${lines[i]}${i === 0 ? ` [${thinkMs} ms]` : ''}${actions[i]?.note ? ` 「${actions[i].note}」` : ''}`);
if (stopped) { console.log(` (${stopped})`); lines.push(`(${stopped})`); }
prompt = `${lines.join('\n')}\n\n${observe(runObj)}\n\n请输出 JSON 动作(或数组连做几步)。`;
}
if (runObj.status !== 'playing') {
const ask = `本局结束(${runObj.status})。请评分,只输出一行 JSON:` + RATING_QUESTIONS.map(q => `${q.key}=${q.text}`).join(';');
// The rule bot has no opinion; leaving rating null keeps it out of the averages.
if (provider.style !== 'mock') { const reply = await chat([...history.slice(-keep * 2), { role: 'user', content: ask }]); rate(runObj, firstJson(reply) || {}); }
}
runObj.timing.totalMs = Date.now() - started;
save(true);
const m = metrics(runObj);
console.log(`结束:${runObj.status} · ${m.steps} 步 · ${m.decisions} 次决策 · 空点 ${m.deadTaps} · 旋转 ${m.rotations}/${m.minimumRotations ?? '?'} · ${(runObj.timing.totalMs / 1000).toFixed(1)} s · bench/runs/${runObj.run}.json`);
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
// Batch runner: many levels × personas through the direct-API loop, N at a time.
// BENCH_PROVIDER=anthropic ANTHROPIC_API_KEY=… node scripts/bench-batch.mjs --levels=1-18 --personas=tapper,reader --model=claude-sonnet-5 --concurrency=6
// BENCH_PROVIDER=mock node scripts/bench-batch.mjs --set=levels/v0.5.json --levels=all --concurrency=8 # rule bot over the whole campaign
// Options: --set=… (default levels/prototypes/d5.json) --levels=all|1-18|1,4,7 --personas=… --model=… --budget=80 --peek --prefix=NAME --concurrency=4 --force
// Runs already finished (bench/runs/<prefix>-L<n>-<persona>.json with status ≠ playing) are skipped unless --force.
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { rebuildIndex } from './bench-index.mjs';
const flags = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
const set = flags.set || 'levels/prototypes/d5.json';
const raw = JSON.parse(readFileSync(resolve(set), 'utf8'));
const levels = Array.isArray(raw) ? raw : Array.isArray(raw.levels) ? raw.levels : Object.values(raw).filter(l => l && l.cells);
const spec = String(flags.levels || 'all');
const indices = spec === 'all' ? levels.map((_, i) => i + 1)
: spec.split(',').flatMap(part => { const m = part.match(/^(\d+)-(\d+)$/); return m ? Array.from({ length: Number(m[2]) - Number(m[1]) + 1 }, (_, i) => Number(m[1]) + i) : [Number(part)]; }).filter(n => n >= 1 && n <= levels.length);
const personas = String(flags.personas || 'tapper').split(',');
const provider = process.env.BENCH_PROVIDER || 'anthropic';
// Same presets as bench-agent.mjs; --model overrides.
const DEFAULT_MODEL = { mock: 'rulebot', anthropic: 'claude-sonnet-5', openai: 'gpt-4o-mini', deepseek: 'deepseek-flash', xai: 'grok-4.6' };
const model = flags.model || process.env.BENCH_MODEL || DEFAULT_MODEL[provider] || 'unknown';
const prefix = flags.prefix || (provider === 'mock' ? 'bot' : model.replace(/[^\w.-]+/g, '-'));
const concurrency = Number(flags.concurrency || 4);
const jobs = [];
for (const n of indices) for (const persona of personas) {
const run = `${prefix}-L${n}-${persona}`;
const file = resolve('bench/runs', `${run}.json`);
if (existsSync(file) && !flags.force) { try { const r = JSON.parse(readFileSync(file, 'utf8')); if (r.status !== 'playing') continue; } catch { /* rerun */ } }
jobs.push({ run, n, persona });
}
console.log(`${set} · ${indices.length} 关 × ${personas.length} 人设 → ${jobs.length} 局待跑(已完成的已跳过)· ${provider}/${model} · 并发 ${concurrency}`);
const started = Date.now(), total = jobs.length;
let done = 0, failed = 0;
const results = [];
async function worker() {
while (jobs.length) {
const job = jobs.shift();
const args = ['scripts/bench-agent.mjs', `--run=${job.run}`, `--level=${job.n}`, `--set=${set}`, `--persona=${job.persona}`, `--model=${model}`];
if (flags.budget) args.push(`--budget=${flags.budget}`);
if (flags.agent) args.push(`--agent=${flags.agent}`);
if (flags.peek) args.push('--peek');
if (flags['full-protocol']) args.push('--full-protocol');
const t0 = Date.now();
const code = await new Promise(res => { const p = spawn('node', args, { stdio: ['ignore', 'pipe', 'pipe'] }); let tail = ''; p.stdout.on('data', d => { tail = (tail + d).slice(-400); }); p.stderr.on('data', d => { tail = (tail + d).slice(-400); }); p.on('close', c => { job.tail = tail.trim().split('\n').pop(); res(c); }); });
done++;
if (code !== 0) failed++;
results.push({ ...job, code, seconds: (Date.now() - t0) / 1000 });
console.log(`[${done}/${total}] ${job.run.padEnd(30)} ${code === 0 ? job.tail : `失败(exit ${code}) ${job.tail}`}`);
}
}
// Children only write their own run file; the shared index is rebuilt here so
// the observer page can list runs while the batch is still going.
const indexTimer = setInterval(() => { try { rebuildIndex(); } catch { /* next tick */ } }, 5000);
await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, jobs.length)) }, worker));
clearInterval(indexTimer);
rebuildIndex();
const elapsed = (Date.now() - started) / 1000;
console.log(`\n完成 ${done} 局,失败 ${failed},用时 ${elapsed.toFixed(0)} s(平均 ${(elapsed / Math.max(1, done)).toFixed(1)} s/局,并发 ${concurrency})`);
console.log('汇总:node scripts/bench-report.mjs');
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
// Bring runs played elsewhere (a bench-pack folder, another machine, another
// harness) into bench/runs so the observer page and the report can see them.
// node scripts/bench-import.mjs --from=bench/pack/runs [--tag=grok] [--force]
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { rebuildIndex } from './bench-index.mjs';
const flags = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
if (!flags.from) { console.error('需要 --from=<存放 runs 的目录>'); process.exit(1); }
const from = resolve(String(flags.from));
const to = resolve('bench/runs');
if (!existsSync(from)) { console.error(`没有 ${from}`); process.exit(1); }
mkdirSync(to, { recursive: true });
const tag = flags.tag ? String(flags.tag).replace(/[^\w.-]+/g, '-') : null;
let imported = 0, skipped = 0, bad = 0;
for (const file of readdirSync(from)) {
if (!file.endsWith('.json') || file === 'index.json') continue;
let run;
try { run = JSON.parse(readFileSync(resolve(from, file), 'utf8')); } catch { bad++; continue; }
if (!run?.run || !run.level?.cells || !Array.isArray(run.steps)) { bad++; continue; }
const name = tag ? `${tag}-${run.run}` : run.run;
const target = resolve(to, `${name}.json`);
if (existsSync(target) && !flags.force) { skipped++; continue; }
run.run = name;
run.imported = { from: String(flags.from), at: new Date().toISOString() };
writeFileSync(target, JSON.stringify(run));
imported++;
}
const index = rebuildIndex();
console.log(`导入 ${imported} 局${skipped ? `,跳过 ${skipped} 局(已存在,用 --force 覆盖)` : ''}${bad ? `,忽略 ${bad} 个无效文件` : ''}`);
console.log(`bench/runs 现有 ${index.length} 局 · 汇总:node scripts/bench-report.mjs`);
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
// Rebuild bench/runs/index.json from the run files. Parallel agents must not
// read-modify-write a shared index; they write their own run file and the
// index is regenerated here (the batch runner calls this periodically).
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
export function rebuildIndex(dir = resolve('bench/runs')) {
if (!existsSync(dir)) return [];
const index = [];
for (const f of readdirSync(dir)) {
if (!f.endsWith('.json') || f === 'index.json') continue;
try {
const r = JSON.parse(readFileSync(resolve(dir, f), 'utf8'));
index.push({ run: r.run, level: r.level.id, persona: r.persona, model: r.model, peek: r.peek, status: r.status, steps: r.steps.length, updatedAt: r.updatedAt });
} catch { /* a file being written right now; next rebuild picks it up */ }
}
index.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
writeFileSync(resolve(dir, 'index.json'), JSON.stringify(index, null, 1));
return index;
}
if (process.argv[1] && process.argv[1].endsWith('bench-index.mjs')) {
const index = rebuildIndex();
console.log(`index.json · ${index.length} 局`);
}
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env node
// Build a standalone Agent bench: a folder someone can unzip and run with
// nothing but Node 20+. CLI, engine, level pack, observer page and its tile
// atlas — no repo, no npm install, no network.
//
// node scripts/bench-kit.mjs [--set=levels/prototypes/d5.json] [--levels=all|1-18|1,4]
// [--out=bench/hex-bench] [--name=hex-bench] [--zip] [--peek]
//
// Hand the folder (or the zip) to anyone. They run `node serve.mjs`, point an
// agent at AGENT.md, and watch it play. Send `runs/` back and import with
// `node scripts/bench-import.mjs --from=<path>/runs --tag=<who>`.
//
// The engine files are discovered by following imports, so adding a module to
// src/ never silently breaks the kit. Every text rewrite is asserted: a rename
// in the repo fails the build loudly instead of shipping something broken.
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname, relative, resolve } from 'node:path';
const flags = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
const setPath = flags.set || 'levels/prototypes/d5.json';
const kitName = String(flags.name || 'hex-bench');
const out = resolve(flags.out || `bench/${kitName}`);
const die = m => { console.error(m); process.exit(1); };
// ---------- levels ----------
const raw = JSON.parse(readFileSync(resolve(setPath), 'utf8'));
const all = Array.isArray(raw) ? raw : Array.isArray(raw.levels) ? raw.levels : Object.values(raw).filter(l => l && l.cells);
const spec = String(flags.levels || 'all');
const picked = spec === 'all' ? all
: spec.split(',').flatMap(part => { const m = part.match(/^(\d+)-(\d+)$/); return m ? Array.from({ length: Number(m[2]) - Number(m[1]) + 1 }, (_, i) => Number(m[1]) + i) : [Number(part)]; })
.filter(n => n >= 1 && n <= all.length).map(n => all[n - 1]);
if (!picked.length) die(`没有选中任何关卡(${setPath} 共 ${all.length} 关)`);
// ---------- engine closure ----------
// Follow relative imports from the three entry points; whatever they reach
// under src/ is what the kit needs.
const seen = new Set();
function trace(file) {
const abs = resolve(file);
if (seen.has(abs)) return;
if (!existsSync(abs)) die(`bench-kit 过时了:${abs} 不存在`);
seen.add(abs);
for (const m of readFileSync(abs, 'utf8').matchAll(/from\s+'(\.[^']+)'/g)) trace(resolve(dirname(abs), m[1]));
}
['labs/bench/app.js', 'scripts/bench.mjs', 'scripts/bench-report.mjs'].forEach(f => trace(resolve(f)));
const engine = [...seen].map(f => relative(resolve('.'), f)).filter(f => f.startsWith('src/')).sort();
if (!engine.includes('src/bench.js') || !engine.includes('src/core.js')) die('bench-kit 过时了:没追踪到 src/bench.js 或 src/core.js');
// ---------- rewrites ----------
// Each entry is [file label, [from, to], …]; every `from` must be present.
const rewrite = (label, text, pairs) => {
for (const [from, to] of pairs) {
if (!text.includes(from)) die(`bench-kit 过时了:${label} 里找不到 ${JSON.stringify(from)}`);
text = text.split(from).join(to);
}
return text;
};
const cli = rewrite('scripts/bench.mjs', readFileSync(resolve('scripts/bench.mjs'), 'utf8'), [
["from '../src/bench.js'", "from './src/bench.js'"],
["resolve('bench/runs')", "resolve('runs')"],
["'levels/prototypes/d5.json'", "'levels.json'"],
['labs/bench/index.html', 'observer/index.html'],
["resolve('scripts/serve.mjs')", "resolve('serve.mjs')"],
['node scripts/bench.mjs', 'node bench.mjs'],
['labs/bench/AGENT.md', 'AGENT.md'],
['这个副本没有观察页(便携包);把 runs/ 交回原仓库用 bench-import.mjs 导入后再看。',
'找不到 observer/index.html 或 serve.mjs —— 这份实验台不完整,重新解压一次。'],
]);
const report = rewrite('scripts/bench-report.mjs', readFileSync(resolve('scripts/bench-report.mjs'), 'utf8'), [
["from '../src/bench.js'", "from './src/bench.js'"],
["flags.dir || 'bench/runs'", "flags.dir || 'runs'"],
["resolve('bench/report.json')", "resolve('report.json')"],
['node scripts/bench-report.mjs', 'node report.mjs'],
]);
// observer/ sits one level down, so ../src and ../runs; the repo page keeps its
// two base paths on one line precisely so this stays a one-line swap.
const app = rewrite('labs/bench/app.js', readFileSync(resolve('labs/bench/app.js'), 'utf8'), [
["'../../src/", "'../src/"],
["const DATA = '../../bench/runs', ASSET_BASE = '../../';", "const DATA = '../runs', ASSET_BASE = '../';"],
['scripts/bench.mjs', 'bench.mjs'],
]);
const page = rewrite('labs/bench/index.html', readFileSync(resolve('labs/bench/index.html'), 'utf8'), [
// No main game to go back to here, but the slot still has to hold something:
// an empty one lets the picker and the badge wrap onto two rows.
['<a class="back" href="../../">← 返回主线</a>', '<span class="back">HEX TRAILS 关卡实验台</span>'],
['node scripts/bench.mjs start --model=你是谁 --level=1', 'node bench.mjs start --model=你是谁 --level=1'],
['bench/runs/*.json', 'runs/*.json'],
['labs/bench/AGENT.md', 'AGENT.md'],
]);
const guide = rewrite('labs/bench/AGENT.md', readFileSync(resolve('labs/bench/AGENT.md'), 'utf8'), [
['node scripts/bench.mjs', 'node bench.mjs'],
['labs/bench/index.html', 'observer/index.html'],
]);
// ---------- assemble ----------
const ATLAS = (readFileSync(resolve('src/sprites.js'), 'utf8').match(/TILE_ATLAS\s*=\s*'([^']+)'/) || [])[1];
if (!ATLAS || !existsSync(resolve(ATLAS))) die(`bench-kit 过时了:读不到 src/sprites.js 里的贴图路径(${ATLAS})`);
rmSync(out, { recursive: true, force: true });
mkdirSync(resolve(out, 'runs'), { recursive: true });
mkdirSync(resolve(out, 'src'), { recursive: true });
mkdirSync(resolve(out, 'observer'), { recursive: true });
mkdirSync(resolve(out, dirname(ATLAS)), { recursive: true });
for (const f of engine) cpSync(resolve(f), resolve(out, f));
cpSync(resolve(ATLAS), resolve(out, ATLAS));
cpSync(resolve('scripts/serve.mjs'), resolve(out, 'serve.mjs'));
cpSync(resolve('labs/bench/style.css'), resolve(out, 'observer/style.css'));
writeFileSync(resolve(out, 'bench.mjs'), cli);
writeFileSync(resolve(out, 'report.mjs'), report);
writeFileSync(resolve(out, 'observer/app.js'), app);
writeFileSync(resolve(out, 'observer/index.html'), page);
writeFileSync(resolve(out, 'AGENT.md'), guide);
writeFileSync(resolve(out, 'levels.json'), JSON.stringify({ version: 1, source: setPath, levels: picked }));
writeFileSync(resolve(out, 'runs/.gitkeep'), '');
writeFileSync(resolve(out, 'package.json'), JSON.stringify({
name: 'hex-trails-agent-bench', version: '1.0.0', private: true, type: 'module',
description: 'HEX TRAILS 关卡实验台:让 Agent 像人一样玩关卡,人在网页上实时观看。',
engines: { node: '>=20' },
scripts: { start: 'node serve.mjs', bench: 'node bench.mjs', report: 'node report.mjs' },
}, null, 2) + '\n');
writeFileSync(resolve(out, 'README.md'), readme({ kitName, setPath, count: picked.length, engine }));
// ---------- smoke test ----------
// A kit that does not run is worse than no kit: play a real game inside it.
const node = (...args) => spawnSync(process.execPath, args, { cwd: out, encoding: 'utf8' });
const list = node('bench.mjs', 'list');
if (list.status !== 0 || !list.stdout.includes('关')) die(`自测失败:bench.mjs list\n${list.stdout}${list.stderr}`);
const start = node('bench.mjs', 'start', '--run=selftest', '--model=selftest', '--level=1', '--no-rules');
if (start.status !== 0 || !start.stdout.includes('每枚棋子当前的路线')) die(`自测失败:bench.mjs start\n${start.stdout}${start.stderr}`);
const firstTile = (start.stdout.match(/^\s{2}(t\d+) @/m) || [])[1];
if (!firstTile) die(`自测失败:读不出棋子 id\n${start.stdout}`);
const act = node('bench.mjs', 'act', '--run=selftest', 'tap', firstTile, '--note=自测');
if (act.status !== 0) die(`自测失败:bench.mjs act\n${act.stdout}${act.stderr}`);
const rated = node('bench.mjs', 'rate', '--run=selftest', '--json={"clarity":3,"rotation":3,"stuck":1,"fun":3,"difficulty":3}');
if (rated.status !== 0 || !rated.stdout.includes('本局:')) die(`自测失败:bench.mjs rate\n${rated.stdout}${rated.stderr}`);
const reported = node('report.mjs');
if (reported.status !== 0 || !reported.stdout.includes('按关卡')) die(`自测失败:report.mjs\n${reported.stdout}${reported.stderr}`);
// The observer must be able to reach every file it imports.
for (const f of ['observer/index.html', 'observer/app.js', 'observer/style.css', ATLAS, 'src/bench.js', 'levels.json'])
if (!existsSync(resolve(out, f))) die(`自测失败:缺少 ${f}`);
for (const m of app.matchAll(/from\s+'(\.[^']+)'/g))
if (!existsSync(resolve(out, 'observer', m[1]))) die(`自测失败:观察页 import 的 ${m[1]} 不在包里`);
rmSync(resolve(out, 'runs/selftest.json'), { force: true });
rmSync(resolve(out, 'runs/index.json'), { force: true });
rmSync(resolve(out, 'report.json'), { force: true });
// ---------- done ----------
let zipped = null;
if (flags.zip) {
const parent = resolve(out, '..'), folder = out.split('/').pop();
const z = spawnSync('zip', ['-qr', `${folder}.zip`, folder], { cwd: parent, encoding: 'utf8' });
if (z.status === 0) zipped = resolve(parent, `${folder}.zip`);
else console.warn(`打包失败(${z.error?.message || z.stderr.trim()})——手动执行:cd ${parent} && zip -r ${folder}.zip ${folder}`);
}
console.log(`独立实验台已生成:${out}`);
console.log(` ${picked.length} 关(来自 ${setPath})· 引擎 ${engine.length} 个模块 · 含观察页与贴图`);
console.log(` 自测通过:list → start → act → rate → report,观察页依赖齐全`);
if (zipped) console.log(` 已打包:${zipped}`);
else console.log(` 打包:cd ${resolve(out, '..')} && zip -r ${out.split('/').pop()}.zip ${out.split('/').pop()}`);
console.log(` 对方拿到后:cd ${kitName} && node serve.mjs,然后让 agent 读 AGENT.md`);
function readme({ kitName, setPath, count, engine }) {
return `# HEX TRAILS 关卡实验台
让 **Agent 像人一样玩** 一款六角轨道解谜,你在网页上实时看着它操作、听它讲每一步为什么这么做,最后让它给关卡打分。
用途不是"让 AI 解谜",而是**用 Agent 当低成本的试玩员**:找出哪些关让人觉得无解、哪些关旋转没有意义、哪些路线看不清。
> 只需要 **Node 20 或更新**。不联网、不装依赖、不需要原仓库。
---
## 三步跑起来
**1. 打开观察页(开着别关)**
\`\`\`bash
cd ${kitName}
node serve.mjs
\`\`\`
浏览器打开 <http://localhost:4173/observer/index.html>。现在还是空的。
**2. 在这个目录里启动任意一个能执行 bash 的 agent**
Claude Code、Codex、Grok Build、DeepSeek Harness 都行。对它说这一句:
> 读这个目录里的 \`AGENT.md\`,然后按它说的玩第 1 关。
\`AGENT.md\` 里有规则、读盘方法、全部命令和示例,它读完就能直接开始,不需要你再解释什么。
**3. 看着它玩**
观察页会每秒刷新:棋盘按它的操作一步步动,右边是计时板(总用时精确到毫秒、本步思考了多久、步数、决策次数、已离场、空点与旋转)和它每一步写下的理由。棋盘上方显示现在是哪个模型、在什么环境下跑。
点右边实况里的任意一步可以回看那一刻的棋盘;方向键 ↑↓ 逐步走,「↩ 回到实时」跟回 agent。
---
## 想多跑几关
对 agent 说"玩第 2 关"、"玩第 5 关"就行,一关一局。本包共 **${count} 关**(\`node bench.mjs list\` 可以看)。
跑完几局之后看汇总:
\`\`\`bash
node report.mjs
\`\`\`
它按关和按模型各出一张表。**带标记的关先看**——「有放弃」「有人觉得无解」「盲转≥2」这三个标记,就是这套东西要抓的东西。
---
## 你会得到什么
每一局完整写在 \`runs/<局名>.json\` 里:每一步的动作、理由、结果、时间戳,以及最后的评分。
评分有五项,1–5 分:
| 键 | 问题 | 看什么 |
|---|---|---|
| \`clarity\` | 路线看得清吗 | 低分 = 可读性问题 |
| \`rotation\` | 旋转有意义吗 | 低分 = 这一关的旋转是摆设 |
| \`stuck\` | 有没有觉得无解的时刻 | **高分 = 最该改的关** |
| \`fun\` | 好玩吗 | |
| \`difficulty\` | 难度 | 1 太简单 / 3 刚好 / 5 太难,两头都是问题 |
另外自动算出来的指标里,最值得看的是 **空点**(点了没动的次数)、**盲转**(转了一次什么都没打开)和**多转**(比最少旋转数多用了几次)。
## 把结果交回来
把整个 \`runs/\` 目录打包发回即可。
---
## 目录里有什么
\`\`\`
bench.mjs Agent 用的命令行(唯一的操作入口)
AGENT.md 给 Agent 读的说明——唯一要交给它的文件
serve.mjs 本地静态服务(观察页要用)
report.mjs 跨局汇总
observer/ 观察页
src/ 游戏引擎(${engine.length} 个模块,和正式版同一套规则与渲染)
levels.json ${count} 关(来自 ${setPath})
runs/ 每一局的存档,跑完发回这个目录
\`\`\`
## 常见问题
**端口 4173 被占了** — \`PORT=5173 node serve.mjs\`,然后访问 <http://localhost:5173/observer/index.html>。
**观察页空白 / 下拉框里没有局** — 还没有人开局。让 agent 跑 \`node bench.mjs start …\`,或者你自己跑一局试试:
\`\`\`bash
node bench.mjs start --model=me --level=1
node bench.mjs act --run=<上面打印的局名> tap t0 --note=试试
\`\`\`
**agent 一上来就写脚本批量解题** — 提醒它重看 \`AGENT.md\` 开头的「禁止」那一段。它应该一次一关、一次一个决定,只通过 \`node bench.mjs\` 和棋盘交互。
**agent 说这一关无解** — 这是有用的数据,不是 bug。让它用 \`giveup\` 说明理由,那条记录比通关更有价值。
`;
}
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env node
// Export a self-contained bench any agent can run in its own sandbox: four JS
// files, a level pack and a protocol. No repo, no npm, no network.
// node scripts/bench-pack.mjs [--set=levels/prototypes/d5.json] [--levels=1-18|all|1,4] [--out=bench/pack] [--peek]
// Hand the folder (or a zip of it) to Grok / DeepSeek / any harness; they run
// `node play.mjs …` and send back runs/*.json, which bench-import.mjs reads.
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const flags = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
const setPath = flags.set || 'levels/prototypes/d5.json';
const out = resolve(flags.out || 'bench/pack');
const raw = JSON.parse(readFileSync(resolve(setPath), 'utf8'));
const all = Array.isArray(raw) ? raw : Array.isArray(raw.levels) ? raw.levels : Object.values(raw).filter(l => l && l.cells);
const spec = String(flags.levels || 'all');
const picked = spec === 'all' ? all
: spec.split(',').flatMap(part => { const m = part.match(/^(\d+)-(\d+)$/); return m ? Array.from({ length: Number(m[2]) - Number(m[1]) + 1 }, (_, i) => Number(m[1]) + i) : [Number(part)]; })
.filter(n => n >= 1 && n <= all.length).map(n => all[n - 1]);
if (!picked.length) { console.error('没有选中任何关卡'); process.exit(1); }
// Rewrite the repo CLI into a standalone one. Every replacement is asserted, so
// a rename in bench.mjs breaks the pack loudly instead of shipping it broken.
let cli = readFileSync(resolve('scripts/bench.mjs'), 'utf8');
const swap = (from, to) => { if (!cli.includes(from)) { console.error(`bench-pack 过时了:scripts/bench.mjs 里找不到 ${JSON.stringify(from)}`); process.exit(1); } cli = cli.split(from).join(to); };
swap("from '../src/bench.js'", "from './bench.js'");
swap("resolve('bench/runs')", "resolve('runs')");
swap("flags.set || 'levels/prototypes/d5.json'", "flags.set || 'levels.json'");
swap('node scripts/bench.mjs', 'node play.mjs');
swap('labs/bench/AGENT.md', 'AGENT.md');
rmSync(out, { recursive: true, force: true });
mkdirSync(resolve(out, 'runs'), { recursive: true });
for (const f of ['path-math.js', 'core.js', 'bench.js']) cpSync(resolve('src', f), resolve(out, f));
writeFileSync(resolve(out, 'play.mjs'), cli);
writeFileSync(resolve(out, 'levels.json'), JSON.stringify({ version: 1, source: setPath, levels: picked }));
// The pack ships the same single file agents read in the repo, with the
// command name and paths rewritten for a standalone folder.
const guide = readFileSync(resolve('labs/bench/AGENT.md'), 'utf8')
.split('node scripts/bench.mjs').join('node play.mjs')
.split('labs/bench/AGENT.md').join('AGENT.md')
.split('`src/`').join('`core.js` / `bench.js`')
.split('--set=levels/v0.5.json').join('--set=levels.json');
writeFileSync(resolve(out, 'AGENT.md'), guide);
writeFileSync(resolve(out, 'README.md'), `# HEX TRAILS 关卡实验台 · 便携包
给任意 Agent 用的独立实验台:**只要 Node 20+,不需要联网、不需要 npm install、不需要原仓库。**
包含 ${picked.length} 关(来自 \`${setPath}\`)。
## 给 Agent 的任务提示(整段复制给它)
> 读这个文件夹里的 \`AGENT.md\`,然后按它说的玩第 1 关。你的模型标识写 \`<换成它的名字>\`。
\`AGENT.md\` 里已经包含规则、读盘方法、全部命令和示例,Agent 读完就能直接开始,不需要别的说明。
## 命令
| 命令 | 作用 |
|---|---|
| \`node play.mjs list\` | 列出本包里的关卡 |
| \`node play.mjs start --model=X --level=N --persona=… --no-rules\` | 开一局(局名自动生成)|
| \`node play.mjs observe --run=X\` | 再看一次盘面 |
| \`node play.mjs act --run=X tap t4, tap t5 --note="…"\` | 提交一个决定(可连做几步) |
| \`node play.mjs rate --run=X --json='{…}'\` | 评分(必填) |
| \`node play.mjs status --run=X\` / \`metrics --run=X\` | 回看步骤 / 指标 |
人设:\`reader\`(读盘型)\`tapper\`(试错型)\`turner\`(转盘型)\`novice\`(新手)。
## 交回结果
每局写在 \`runs/<局名>.json\`,含每一步的动作、理由、结果和时间戳。把整个 \`runs/\` 交回即可,原仓库用
\`node scripts/bench-import.mjs --from=<路径>/runs --tag=<模型名>\` 导入,然后在观察页回放、在汇总表里比较。
生成时间:${new Date().toISOString()}
`);
console.log(`便携包已生成:${out}`);
console.log(` ${picked.length} 关 · 文件:play.mjs bench.js core.js path-math.js levels.json AGENT.md README.md runs/`);
console.log(` 打包发给别的 agent:cd ${resolve(out, '..')} && zip -r bench-pack.zip ${out.split('/').pop()}`);
console.log(` 自测:cd ${out} && node play.mjs list && node play.mjs start --run=t --level=1 --model=me`);
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
// Aggregate bench runs two ways: per level (is the level good?) and per model
// (how does this agent play?). Writes bench/report.json.
// node scripts/bench-report.mjs [--dir=bench/runs] [--by=level|model|both] [--filter=prefix] [--json]
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { metrics } from '../src/bench.js';
const flags = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
const dir = resolve(flags.dir || 'bench/runs');
if (!existsSync(dir)) { console.error(`没有 ${dir}`); process.exit(1); }
const runs = readdirSync(dir).filter(f => f.endsWith('.json') && f !== 'index.json')
.filter(f => !flags.filter || f.startsWith(String(flags.filter)))
.map(f => JSON.parse(readFileSync(resolve(dir, f), 'utf8')));
if (!runs.length) { console.error('没有符合条件的对局'); process.exit(1); }
const rows = runs.map(metrics);
const med = a => { const s = a.filter(v => typeof v === 'number').sort((x, y) => x - y); return s.length ? s[Math.floor(s.length / 2)] : null; };
const mean = a => { const s = a.filter(v => typeof v === 'number'); return s.length ? s.reduce((x, y) => x + y, 0) / s.length : null; };
const f = (v, d = 2) => v === null || v === undefined ? '—' : typeof v === 'number' ? Number(v.toFixed(d)) : v;
const secs = ms => ms == null ? '—' : ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
const ratingOf = ms => { const r = ms.map(m => m.rating).filter(Boolean); return Object.fromEntries(['clarity', 'rotation', 'stuck', 'fun', 'difficulty'].map(k => [k, mean(r.map(x => Number(x[k])))])); };
function group(keyOf) {
const map = {};
for (const m of rows) (map[keyOf(m)] ||= []).push(m);
return Object.entries(map).map(([key, ms]) => {
const finished = ms.filter(m => m.status !== 'playing');
const cleared = ms.filter(m => m.cleared);
return {
key, runs: ms.length, finished: finished.length,
clearRate: finished.length ? cleared.length / finished.length : null,
gaveUp: ms.filter(m => m.gaveUp).length, budgetHit: ms.filter(m => m.status === 'budget').length,
actionsPerPiece: med(cleared.map(m => m.actionsPerPiece)),
decisions: med(ms.map(m => m.decisions)), actionsPerDecision: med(ms.map(m => m.actionsPerDecision)),
totalMs: med(ms.map(m => m.timing.totalMs)), decisionMs: med(ms.map(m => m.timing.medianDecisionMs)),
deadTaps: med(ms.map(m => m.deadTaps)), grazeDeadTaps: med(ms.map(m => m.grazeDeadTaps)),
deadStreak: med(ms.map(m => m.longestDeadStreak)), blindStreak: med(ms.map(m => m.longestBlindStreak)),
wrongWay: mean(ms.map(m => m.wrongWayRotations)), rotationEfficiency: mean(ms.map(m => m.rotationEfficiency)),
extraRotations: med(cleared.filter(m => m.minimumRotations !== null).map(m => m.rotations - m.minimumRotations)),
rating: ratingOf(ms), models: [...new Set(ms.map(m => m.model))], personas: [...new Set(ms.map(m => m.persona))], levels: [...new Set(ms.map(m => m.level))],
};
});
}
const byLevel = group(m => m.level);
byLevel.forEach(t => {
const ms = rows.filter(m => m.level === t.key);
t.flags = [t.gaveUp ? '有放弃' : null, ms.some(m => Number(m.rating?.stuck) >= 4) ? '有人觉得无解' : null,
t.blindStreak >= 2 ? '盲转≥2' : null, t.budgetHit ? '预算用尽' : null].filter(Boolean);
});
byLevel.sort((a, b) => (b.flags.length - a.flags.length) || ((a.clearRate ?? 1) - (b.clearRate ?? 1)));
const byModel = group(m => `${m.model}${m.agent ? ` · ${m.agent}` : ''} · ${m.persona}`).sort((a, b) => (b.clearRate ?? 0) - (a.clearRate ?? 0) || (a.actionsPerPiece ?? 9) - (b.actionsPerPiece ?? 9));
const mode = flags.by || 'both';
if (flags.json) console.log(JSON.stringify({ runs: rows, levels: byLevel, models: byModel }, null, 1));
else {
console.log(`bench 汇总 · ${runs.length} 局 · ${byLevel.length} 关 · ${byModel.length} 个 模型/人设 组合\n`);
if (mode === 'model' || mode === 'both') {
console.log('## 按模型(这个 agent 玩得怎么样)\n');
console.log('| 模型 · 人设 | 局数 | 清盘率 | 放弃 | 动作÷棋子 | 决策数 | 步/决策 | 单局耗时 | 每决策 | 空点 | 擦碰 | 盲转 | 多转 | 清晰 | 旋转意义 | 无解感 | 好玩 | 难度 |');
console.log('|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|');
for (const t of byModel) console.log(`| ${t.key} | ${t.runs} | ${f(t.clearRate)} | ${t.gaveUp} | ${f(t.actionsPerPiece)} | ${f(t.decisions, 0)} | ${f(t.actionsPerDecision, 1)} | ${secs(t.totalMs)} | ${secs(t.decisionMs)} | ${f(t.deadTaps, 0)} | ${f(t.grazeDeadTaps, 0)} | ${f(t.blindStreak, 0)} | ${f(t.extraRotations, 0)} | ${f(t.rating.clarity, 1)} | ${f(t.rating.rotation, 1)} | ${f(t.rating.stuck, 1)} | ${f(t.rating.fun, 1)} | ${f(t.rating.difficulty, 1)} |`);
console.log('');
}
if (mode === 'level' || mode === 'both') {
console.log('## 按关卡(这一关好不好)\n');
console.log('| 关卡 | 局数 | 清盘率 | 放弃 | 动作÷棋子 | 单局耗时 | 最长空点 | 最长盲转 | 多转 | 旋转效率 | 清晰 | 旋转意义 | 无解感 | 好玩 | 难度 | 标记 |');
console.log('|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|');
for (const t of byLevel) console.log(`| ${t.key} | ${t.runs} | ${f(t.clearRate)} | ${t.gaveUp} | ${f(t.actionsPerPiece)} | ${secs(t.totalMs)} | ${f(t.deadStreak, 0)} | ${f(t.blindStreak, 0)} | ${f(t.extraRotations, 0)} | ${f(t.rotationEfficiency)} | ${f(t.rating.clarity, 1)} | ${f(t.rating.rotation, 1)} | ${f(t.rating.stuck, 1)} | ${f(t.rating.fun, 1)} | ${f(t.rating.difficulty, 1)} | ${t.flags.join(' ')} |`);
console.log('\n「无解感」= stuck 评分均值;「多转」= 清盘局的旋转次数 − 最少旋转数;「盲转」= 一次旋转什么都没打开;耗时含 agent 自己的进程启动。带标记的关先看。');
}
}
writeFileSync(resolve('bench/report.json'), JSON.stringify({ generatedAt: new Date().toISOString(), runs: rows, levels: byLevel, models: byModel }, null, 1));