#!/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=/runs --tag=`. // // 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. ['← 返回主线', 'HEX TRAILS 关卡实验台'], ['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 \`\`\` 浏览器打开 。现在还是空的。 **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\`,然后访问 。 **观察页空白 / 下拉框里没有局** — 还没有人开局。让 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\` 说明理由,那条记录比通关更有价值。 `; }