execution spec v3 Phase 2 批A(harness 侧·集成 C): - 新增 games/_wg1-gen/_shared/entry-bundle.amodel.template.js:per-game 多文件信封,导出 bootGameHost + 经 --global-name=__GameBundle(治 review B2;game 根 entry 用 2-up、src/* 用 3-up)。 - tools.mjs:scaffoldSaa(_template + SAA 信封)/ buildSaa(__GameBundle)/ stage(原子 tmp→rename 到 _wg1-gen/<gameId>/,治 review B1)。 - play.mjs:smokeBoot(SAA 信封 __genBooted 同进程 boot+帧检,治 review HIGH-1)。 - gen.mjs:--mode saa --game-id;done 门加 stage + smoke-boot;stdout 末行 JSON 契约;max-steps 默认 45 + 12min 墙钟上限 bound thrash。 - prompt.mjs + littlejs-game-dev.md:幻觉 API footgun(❌defineScenes→✅define、❌opts.ctx→✅boot.ctx、修 bug 别引入 Math.random、方法名以 api.d.ts 为准)。 验证:控制组(_template SAA 信封)+ saae2e4 e2e 全绿(agentic→__GameBundle→stage→smoke→stdout JSON);HIGH-1 smoke-boot 两次实测捕获"编译过但起不来"。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
315 lines
14 KiB
JavaScript
315 lines
14 KiB
JavaScript
/**
|
||
* tools.mjs — agentic A-model 生成 harness 的工具层
|
||
* owner:amodel-gen spike | 消费方:gen.mjs(ReAct 循环) / play.mjs / CLI
|
||
*
|
||
* 【两类东西】
|
||
* A) 给 agent 的 5 工具(L1 沙箱边界):
|
||
* - read_file : repo 根只读(agent 自读 skill / api.d.ts / catch-fruit 范例);拒绝逃出 repo 根;超大截断。
|
||
* - write_file : 仅许写本 run 的 game 目录 games/amgen-<id>/;拒绝越界。
|
||
* - list_dir : repo 根内列目录。
|
||
* - check : 循环内快反馈 = node --check 各 src/*.js + 五法/导出名/红线静态 lint。
|
||
* - build : esbuild(scripts/build.mjs 锁参)→ bundle;循环内快反馈。
|
||
* 不给 agent 任意 shell。
|
||
* B) 确定性 scaffold(克隆 _template → 本 run 目录)。
|
||
*
|
||
* 【路径基准】本文件在 game-runtime/tools/amodel-gen/ → GAME_RUNTIME=../.. → REPO_ROOT=../../..
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
import {
|
||
readFileSync, writeFileSync, readdirSync, statSync,
|
||
mkdirSync, existsSync, rmSync, cpSync, renameSync,
|
||
} from 'node:fs';
|
||
import { resolve, dirname, relative, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { spawnSync } from 'node:child_process';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
// tools/amodel-gen → game-runtime → repo 根
|
||
export const GAME_RUNTIME = resolve(__dirname, '../..');
|
||
export const REPO_ROOT = resolve(__dirname, '../../..');
|
||
const GAMES_DIR = join(GAME_RUNTIME, 'games');
|
||
const TEMPLATE_DIR = join(GAMES_DIR, '_template');
|
||
const SHARED_DIR = join(GAMES_DIR, '_wg1-gen', '_shared'); // SAA 信封 plumbing 模板源
|
||
const WG1_DIR = join(GAMES_DIR, '_wg1-gen'); // SAA stage 目标根(play/emit 既有读盘契约)
|
||
|
||
const MAX_READ_BYTES = 200 * 1024; // 单文件读上限,防撑爆 agent context
|
||
|
||
/** 把 repo 相对路径解析为绝对路径,并校验不逃出 repo 根。 */
|
||
function resolveInRepo(p) {
|
||
const abs = resolve(REPO_ROOT, p);
|
||
if (abs !== REPO_ROOT && !abs.startsWith(REPO_ROOT + '/')) {
|
||
throw new Error(`路径越界(必须在 repo 根内):${p}`);
|
||
}
|
||
return abs;
|
||
}
|
||
|
||
/** 本 run 的 game 目录(= 写边界)。 */
|
||
export function gameDir(id) {
|
||
return join(GAMES_DIR, `amgen-${id}`);
|
||
}
|
||
|
||
/** 校验绝对路径落在本 run 的 game 目录内(写边界)。 */
|
||
function assertInGameDir(id, abs) {
|
||
const gd = gameDir(id);
|
||
if (abs !== gd && !abs.startsWith(gd + '/')) {
|
||
throw new Error(`write 越界:只许写 ${relative(REPO_ROOT, gd)}/ 内(你给的是 ${relative(REPO_ROOT, abs)})`);
|
||
}
|
||
}
|
||
|
||
// ───────────────────────── 给 agent 的 5 工具 ─────────────────────────
|
||
|
||
/** read_file:repo 根只读。返回 {ok, content?, truncated?, error?}。 */
|
||
export function readFileTool(path) {
|
||
try {
|
||
const abs = resolveInRepo(path);
|
||
const st = statSync(abs);
|
||
if (st.isDirectory()) return { ok: false, error: `是目录,不是文件:${path}(请用 list_dir)` };
|
||
let content = readFileSync(abs, 'utf8');
|
||
let truncated = false;
|
||
if (Buffer.byteLength(content, 'utf8') > MAX_READ_BYTES) {
|
||
content = content.slice(0, MAX_READ_BYTES);
|
||
truncated = true; // 截断:超大文件只给前 200KB,提示 agent 它被截了
|
||
}
|
||
return { ok: true, content, truncated };
|
||
} catch (e) {
|
||
return { ok: false, error: String((e && e.message) || e) };
|
||
}
|
||
}
|
||
|
||
/** write_file:仅许写本 run game 目录。返回 {ok, bytes?, error?}。 */
|
||
export function writeFileTool(id, path, content) {
|
||
try {
|
||
const abs = resolveInRepo(path);
|
||
assertInGameDir(id, abs);
|
||
mkdirSync(dirname(abs), { recursive: true });
|
||
writeFileSync(abs, content, 'utf8');
|
||
return { ok: true, bytes: Buffer.byteLength(content, 'utf8') };
|
||
} catch (e) {
|
||
return { ok: false, error: String((e && e.message) || e) };
|
||
}
|
||
}
|
||
|
||
/** list_dir:repo 根内列目录(目录名带尾 /)。返回 {ok, entries?, error?}。 */
|
||
export function listDirTool(path) {
|
||
try {
|
||
const abs = resolveInRepo(path);
|
||
const entries = readdirSync(abs, { withFileTypes: true })
|
||
.map((d) => (d.isDirectory() ? d.name + '/' : d.name))
|
||
.sort();
|
||
return { ok: true, entries };
|
||
} catch (e) {
|
||
return { ok: false, error: String((e && e.message) || e) };
|
||
}
|
||
}
|
||
|
||
// ───────────────────────── scaffold / check / build ─────────────────────────
|
||
|
||
/** scaffold:克隆 _template → games/amgen-<id>/(去掉 dist;保留可玩骨架 + plumbing)。返回 game 目录。 */
|
||
export function scaffold(id) {
|
||
const gd = gameDir(id);
|
||
if (existsSync(gd)) rmSync(gd, { recursive: true, force: true });
|
||
cpSync(TEMPLATE_DIR, gd, { recursive: true });
|
||
rmSync(join(gd, 'dist'), { recursive: true, force: true }); // 旧产物(若有)清掉
|
||
rmSync(join(gd, 'test'), { recursive: true, force: true }); // 去 test/:spike 不让 agent 钻测试(gate=check+build+play),也去掉 README 的"跑测试"诱因
|
||
return gd;
|
||
}
|
||
|
||
// 游戏源红线:零裸时间/随机/DOM(时间随机经 boot.ctx,定时经 timer-scheduler)。只匹配"调用形态"。
|
||
const BANNED = [
|
||
/\bMath\.random\s*\(/, /\bDate\.now\s*\(/, /\bperformance\.now\s*\(/,
|
||
/\bsetTimeout\s*\(/, /\bsetInterval\s*\(/, /\brequestAnimationFrame\s*\(/,
|
||
/\bnew\s+AudioContext\b/, /\baddEventListener\s*\(/,
|
||
];
|
||
|
||
/** 去注释 + 字符串字面量,避免红线/结构正则误伤文档注释里的示例(如注释里写「禁用 Math.random()」)。 */
|
||
function stripCode(s) {
|
||
return s
|
||
.replace(/\/\*[\s\S]*?\*\//g, ' ') // 块注释
|
||
.replace(/\/\/[^\n]*/g, ' ') // 行注释
|
||
.replace(/'(?:[^'\\]|\\.)*'/g, "''") // 单引号串
|
||
.replace(/"(?:[^"\\]|\\.)*"/g, '""') // 双引号串
|
||
.replace(/`(?:[^`\\]|\\.)*`/g, '``'); // 模板串
|
||
}
|
||
|
||
/**
|
||
* check:循环内快反馈门(便宜模型自纠语法/契约错的头号手段)。
|
||
* 1) node --check 各 src/*.js + entry.js(语法);
|
||
* 2) 结构契约:game.js 默认导出 + update 内调 bundle.tick;host-config.js 导出 buildTemplateHostConfig;
|
||
* 3) 红线:游戏源(game/core/render)零裸时间/随机/DOM。
|
||
* 返回 {ok, errors[]}。
|
||
*/
|
||
export function check(id) {
|
||
const gd = gameDir(id);
|
||
const errors = [];
|
||
const srcDir = join(gd, 'src');
|
||
let srcFiles = [];
|
||
try {
|
||
srcFiles = readdirSync(srcDir).filter((f) => f.endsWith('.js'));
|
||
} catch {
|
||
return { ok: false, errors: ['src/ 目录不存在或不可读'] };
|
||
}
|
||
|
||
// 1) 语法
|
||
for (const rel of [...srcFiles.map((f) => join('src', f)), 'entry.js']) {
|
||
const abs = join(gd, rel);
|
||
if (!existsSync(abs)) continue;
|
||
const r = spawnSync('node', ['--check', abs], { encoding: 'utf8' });
|
||
if (r.status !== 0) {
|
||
errors.push(`语法错误 ${rel}: ${(r.stderr || '').trim().split('\n').slice(0, 3).join(' ')}`);
|
||
}
|
||
}
|
||
|
||
// 2) 结构契约
|
||
const gameAbs = join(gd, 'src', 'game.js');
|
||
if (existsSync(gameAbs)) {
|
||
const g = stripCode(readFileSync(gameAbs, 'utf8'));
|
||
if (!/export\s+default/.test(g)) errors.push('game.js 必须 export default(工厂默认导出)');
|
||
if (!/\bbundle\.tick\s*\(/.test(g)) {
|
||
errors.push('game.js 的 update(dt) 内必须调 bundle.tick(dt)(否则插件 onFrame 不推进、timer 永不到期、一局不结束)');
|
||
}
|
||
} else {
|
||
errors.push('缺 src/game.js');
|
||
}
|
||
const hcAbs = join(gd, 'src', 'host-config.js');
|
||
if (existsSync(hcAbs)) {
|
||
const hc = stripCode(readFileSync(hcAbs, 'utf8'));
|
||
if (!/export\s+function\s+buildTemplateHostConfig\b/.test(hc)) {
|
||
errors.push('host-config.js 必须保留导出名 buildTemplateHostConfig(固定 plumbing main.js 据此 import,勿改名)');
|
||
}
|
||
} else {
|
||
errors.push('缺 src/host-config.js');
|
||
}
|
||
|
||
// 2.5) game.js 必须真改写过(防 write 路径写错→没覆盖→拿 _template 原样蒙混过 check)
|
||
try {
|
||
const tmpl = readFileSync(join(TEMPLATE_DIR, 'src', 'game.js'), 'utf8');
|
||
if (existsSync(gameAbs) && readFileSync(gameAbs, 'utf8') === tmpl) {
|
||
errors.push(`game.js 与 _template 完全相同:还没真正改写游戏(确认 write 路径写进了 ${relative(REPO_ROOT, gd)}/src/)`);
|
||
}
|
||
} catch { /* 模板缺失则跳过 */ }
|
||
|
||
// 3) 红线(只查 agent 写的游戏文件)
|
||
for (const f of ['game.js', 'core.js', 'render.js']) {
|
||
const abs = join(gd, 'src', f);
|
||
if (!existsSync(abs)) continue;
|
||
const s = stripCode(readFileSync(abs, 'utf8'));
|
||
for (const re of BANNED) {
|
||
if (re.test(s)) errors.push(`红线 ${f}: 禁用 ${re.source}(时间/随机经 boot.ctx,定时经 timer-scheduler,输入经 ctx.getInput)`);
|
||
}
|
||
}
|
||
|
||
return { ok: errors.length === 0, errors };
|
||
}
|
||
|
||
/** build:esbuild 锁参打包(cwd=game-runtime)。返回 {ok, error?}。 */
|
||
export function build(id) {
|
||
const entry = `games/amgen-${id}/entry.js`;
|
||
const out = `games/amgen-${id}/dist/template-bundle.js`;
|
||
const r = spawnSync(
|
||
'node',
|
||
['scripts/build.mjs', entry, out, '--global-name=TemplateGame'],
|
||
{ cwd: GAME_RUNTIME, encoding: 'utf8', timeout: 60000 },
|
||
);
|
||
if (r.status !== 0) {
|
||
const err = ((r.stderr || '') + (r.stdout || '')).trim().split('\n').slice(-8).join('\n');
|
||
return { ok: false, error: err || `build 退出码 ${r.status}` };
|
||
}
|
||
return { ok: true };
|
||
}
|
||
|
||
// ───────────────────────── SAA 模式(批 A:信封对齐 __GameBundle + stage 到 _wg1-gen)─────────────────────────
|
||
|
||
/** SAA stage 目标目录(_wg1-gen/<gameId>/;SAA play/emit 既有读盘契约,治 review B1)。 */
|
||
export function saaGameDir(gameId) {
|
||
return join(WG1_DIR, gameId);
|
||
}
|
||
|
||
/**
|
||
* scaffoldSaa:clone _template → amgen-<gameId>/,换上 A-model SAA 信封 plumbing(治 review B2)。
|
||
* - entry-bundle.js = entry-bundle.amodel.template.js(导出 bootGameHost,经 --global-name=__GameBundle 挂 window.__GameBundle)
|
||
* - index.html = _wg1-gen/_shared/index.template.html(调 __GameBundle.bootGameHost,置 __genBooted)
|
||
* - 去 spike 入口(entry.js/src/main.js;SAA 不用)+ dist/test
|
||
* agent 仍只写 src/{game,core,render,host-config,balance,assets}.js(与 spike 同;信封差异全在 plumbing)。
|
||
*/
|
||
export function scaffoldSaa(gameId) {
|
||
const gd = gameDir(gameId);
|
||
if (existsSync(gd)) rmSync(gd, { recursive: true, force: true });
|
||
cpSync(TEMPLATE_DIR, gd, { recursive: true });
|
||
rmSync(join(gd, 'dist'), { recursive: true, force: true });
|
||
rmSync(join(gd, 'test'), { recursive: true, force: true });
|
||
// 换 SAA 信封 plumbing(覆盖 _template 的 spike 信封 entry.js/index.html)
|
||
cpSync(join(SHARED_DIR, 'entry-bundle.amodel.template.js'), join(gd, 'entry-bundle.js'));
|
||
cpSync(join(SHARED_DIR, 'index.template.html'), join(gd, 'index.html'));
|
||
// 去 spike 入口(SAA 用 entry-bundle.js;entry.js/main.js 不参与构建,删之免混淆)
|
||
rmSync(join(gd, 'entry.js'), { force: true });
|
||
rmSync(join(gd, 'src', 'main.js'), { force: true });
|
||
return gd;
|
||
}
|
||
|
||
/** buildSaa:esbuild 打 entry-bundle.js → bundle.iife.js(--global-name=__GameBundle)。 */
|
||
export function buildSaa(gameId) {
|
||
const entry = `games/amgen-${gameId}/entry-bundle.js`;
|
||
const out = `games/amgen-${gameId}/bundle.iife.js`;
|
||
const r = spawnSync('node', ['scripts/build.mjs', entry, out, '--global-name=__GameBundle'],
|
||
{ cwd: GAME_RUNTIME, encoding: 'utf8', timeout: 60000 });
|
||
if (r.status !== 0) {
|
||
const err = ((r.stderr || '') + (r.stdout || '')).trim().split('\n').slice(-8).join('\n');
|
||
return { ok: false, error: err || `buildSaa 退出码 ${r.status}` };
|
||
}
|
||
return { ok: true };
|
||
}
|
||
|
||
/** stage:原子(写 tmp→rename)把自包含 bundle + SAA index 拷到 _wg1-gen/<gameId>/(SAA play/emit 读这里;play-spec 由 SAA 产、本步不碰)。 */
|
||
export function stage(gameId) {
|
||
const gd = gameDir(gameId);
|
||
const dst = saaGameDir(gameId);
|
||
mkdirSync(dst, { recursive: true });
|
||
for (const f of ['bundle.iife.js', 'index.html']) {
|
||
const src = join(gd, f);
|
||
if (!existsSync(src)) return { ok: false, error: `stage 缺源 ${f}(先 buildSaa)` };
|
||
const tmp = join(dst, f + '.tmp');
|
||
cpSync(src, tmp);
|
||
renameSync(tmp, join(dst, f)); // 原子 rename,防 play 读半成品
|
||
}
|
||
return { ok: true, dir: dst };
|
||
}
|
||
|
||
// ───────────────────────── CLI(独立验证用)─────────────────────────
|
||
// 用法:node tools.mjs scaffold <id> | check <id> | build <id>
|
||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
const [, , cmd, id] = process.argv;
|
||
if (!cmd || !id) {
|
||
console.error('用法:node tools.mjs <scaffold|check|build|scaffold-saa|build-saa|stage> <id>');
|
||
process.exit(2);
|
||
}
|
||
if (cmd === 'scaffold') {
|
||
const gd = scaffold(id);
|
||
console.log('[scaffold] →', relative(REPO_ROOT, gd));
|
||
} else if (cmd === 'check') {
|
||
const r = check(id);
|
||
console.log('[check]', r.ok ? 'PASS' : 'FAIL');
|
||
r.errors.forEach((e) => console.log(' -', e));
|
||
process.exit(r.ok ? 0 : 1);
|
||
} else if (cmd === 'build') {
|
||
const r = build(id);
|
||
console.log('[build]', r.ok ? 'PASS' : 'FAIL');
|
||
if (!r.ok) { console.log(r.error); process.exit(1); }
|
||
} else if (cmd === 'scaffold-saa') {
|
||
const gd = scaffoldSaa(id);
|
||
console.log('[scaffold-saa] →', relative(REPO_ROOT, gd), '(SAA 信封 plumbing)');
|
||
} else if (cmd === 'build-saa') {
|
||
const r = buildSaa(id);
|
||
console.log('[build-saa]', r.ok ? 'PASS' : 'FAIL');
|
||
if (!r.ok) { console.log(r.error); process.exit(1); }
|
||
} else if (cmd === 'stage') {
|
||
const r = stage(id);
|
||
console.log('[stage]', r.ok ? 'PASS → ' + relative(REPO_ROOT, r.dir) : 'FAIL ' + r.error);
|
||
if (!r.ok) process.exit(1);
|
||
} else {
|
||
console.error('未知命令:', cmd);
|
||
process.exit(2);
|
||
}
|
||
}
|