lili 32fd756f3f feat(amodel): 落地 3 层架构——L1 全11插件固定 + game.js 拆 wrapper/game-logic + L3 扁平契约
实现创始人 3 层模型(评审稿 2c220c13):
- L1 工程骨架(LLM 不写,writeFileTool 拒):host-config 注入全 11 插件(编排4+save+gamefeel/juice/palettePost/audioMusic/collision/physics,registerOrder 合集显式定义,viewport 硬编码 390×844 下传)+ game.js 薄 wrapper(摊平 opts.runtime→交 game-logic)。
- L3 游戏代码(LLM 只写):新 game-logic.js = createGame({plugins,bundle,viewport}) 收扁平件(无 opts.runtime 嵌套坑);必写 1=game-logic,core/render 推荐拆可内联。
- harness:tools.mjs check/真改写守卫迁 game-logic.js + 验 export createGame;writeFileTool 拒全 L1 文件(host-config/game.js/index.html/entry-bundle/entry/main)。
- prompt.mjs + skill + README:全对齐扁平契约 + 11 插件键 + 手感二分(方法名以 api.d.ts 为准,修 burst 幻觉)。

验证(全绿):
- M1 控制门:全11插件一起 boot(ctrl11 smoke PASS)+ 拆分后 boot(ctrl12 smoke PASS)——两个从未跑过的组合都过。
- 端到端 newarch1(M3 写 game-logic):**ok:true / 7 步 / 102s / 82K token / smoke PASS**,且 **M3 只写 1 文件 game-logic.js**。对比修前(5文件含wiring/thrash21-70步/600K+常失败)→ '降低 LLM 的活' 彻底兑现。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:08:46 -07:00

327 lines
15 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);
// L1 工程骨架固定 plumbing,agent 不许写——根除 M3 反复搞坏的 wiring 错类(viewport 漏键/registerOrder/插件装配/boot 接线)。
// agent 只写 L3:game-logic.js/core.js/render.js/balance.js/assets.js。
const base = abs.replace(/^.*[\\/]/, '');
const L1_FIXED = ['host-config.js', 'game.js', 'index.html', 'entry-bundle.js', 'entry.js', 'main.js'];
if (L1_FIXED.includes(base)) {
return { ok: false, error: `${base} 是 L1 固定 plumbing(boot 链/插件装配/viewport/工厂 wiring),不要写它。` +
'你只写 L3 游戏本体:src/{game-logic,core,render,balance,assets}.js。' +
'游戏逻辑写在 game-logic.js 的 createGame({plugins,bundle,viewport});插件经 plugins.<键> 直接用(已替你注入),无需装配。' };
}
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) 结构契约(3 层架构:agent 写 game-logic.js;game.js/host-config 是 L1 固定 plumbing,writeFileTool 拒写)
const logicAbs = join(gd, 'src', 'game-logic.js');
if (existsSync(logicAbs)) {
const g = stripCode(readFileSync(logicAbs, 'utf8'));
// 窄契约(L1 game.js wrapper 按此名 import;名写错→import 失败→boot 崩):必须命名导出 createGame。
if (!/export\s+function\s+createGame\b/.test(g)) {
errors.push('game-logic.js 必须 export function createGame({plugins,bundle,viewport})(L1 的 game.js wrapper 按此名 import,勿改名/勿默认导出)');
}
if (!/\bbundle\.tick\s*\(/.test(g)) {
errors.push('game-logic.js 的 update(dt) 内必须调 bundle.tick(dt)(否则插件 onFrame 不推进、timer 永不到期、一局不结束)');
}
} else {
errors.push('缺 src/game-logic.js(你要写的游戏本体;game.js 是 L1 固定 wrapper,别写它)');
}
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-logic.js 必须真改写过(防 write 路径写错→没覆盖→拿 _template 原样蒙混过 check)
try {
const tmpl = readFileSync(join(TEMPLATE_DIR, 'src', 'game-logic.js'), 'utf8');
if (existsSync(logicAbs) && readFileSync(logicAbs, 'utf8') === tmpl) {
errors.push(`game-logic.js 与 _template 完全相同:还没真正改写游戏(确认 write 路径写进了 ${relative(REPO_ROOT, gd)}/src/game-logic.js)`);
}
} catch { /* 模板缺失则跳过 */ }
// 3) 红线(只查 agent 写的游戏文件;game.js/host-config 固定不查)
for (const f of ['game-logic.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);
}
}