lili 6e388d9bbb feat(amodel-gen): agentic A-model 生成 harness(ReAct+M3)+ skill + 四层设计
spike 验证通过:M3 经 new-api(openai+tools)agentic 自闭环产出可玩 A-model 游戏。

- harness game-runtime/tools/amodel-gen/(tools/play/m3/prompt/gen.mjs,零三方依赖):
  scaffold(clone _template)→ ReAct(read/write/list/check/build/done;done 门=check+build 绿;
  compaction + loop-detection)→ 循环外 play(headless Chrome boot+截图+交互 = 能跑能玩)。
- 3/3 玩法干净自闭环实证:打地鼠 / 陨石躲避 / 反应点击(13-22 步,175-378K token,play PASS)。
- skill .agents/skills/agentic-amodel-generation.md(链路/配方/8 坑/M3 实证/主流定性)+ README/AGENTS 索引同步。
- 设计 docs/agent-specs/2026-06-21-agentic-amodel-generation-design.md(§0 整链路 current→new + 四层 + §0.8 主流对照;draft 待 §6.8 双评审)。

取代旧 gamedef/factory 单次产线(A-model 生产形态);production 删 gamedef 待双评审(D1=A 无退路)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:50:38 -07:00

245 lines
9.9 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,
} 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 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 };
}
// ───────────────────────── 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> <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 {
console.error('未知命令:', cmd);
process.exit(2);
}
}