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>
272 lines
14 KiB
JavaScript
272 lines
14 KiB
JavaScript
#!/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\` 说明理由,那条记录比通关更有价值。
|
||
`;
|
||
}
|