lili cd2903baab feat(amodel-gen): Phase 2 批A — harness SAA-mode(__GameBundle 信封 + stage + 循环内 smoke-boot)
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>
2026-06-22 04:12:06 -07:00

315 lines
14 KiB
JavaScript
Raw 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.

/**
* 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);
}
}