Files
HexTrails/scripts/bench-kit.mjs
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

272 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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\` 说明理由,那条记录比通关更有价值。
`;
}