Files
cabinwandClaude Opus 5 f3e91e3597 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>
2026-09-14 14:00:33 +08:00

75 lines
6.1 KiB
JavaScript

#!/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));