lili 13126a08ca fix(amodel-gen): API 静态门方法名提取支持泛型声明(治 save.get<T> 被误判不存在)
#5 九门 gen 暴露的正交真 bug:check 的 API-存在静态门用正则
/(?:^|\n)\s*(\w+)\??\s*\(/ 抽 api.d.ts 方法名,遇泛型声明 `get<T = unknown>(`
时 \w+ 后是 `<` 非 `(`、抽不到 `get`,把真实存在(impl + api.d.ts:79 都有)的
save.get 误判"方法名幻觉不存在"→ 误杀一整类带存档持久化的游戏(高分榜等)。
修:两处方法名正则加可选泛型段 (?:<[^>]*>)?(apiMethodSet + apiVoidMethodSet)。

验证:形状门单测 11/11;针对性 check 一段同用 save.get('best',0)+sessionScore.add+
drawButton 的 game-logic → [check] PASS(三者均不再误判)。over-permissive 取向不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 01:39:05 -07:00

517 lines
28 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, '``'); // 模板串
}
/** 插件键 → api.d.ts 目录(host-config 注入的 canonical 键名;特殊:juice/save/palettePost/physics 名≠dir)。 */
const PLUGIN_KEY_DIR = {
sceneFsm: 'scene-fsm', sessionScore: 'session-score', hudUi: 'hud-ui', timerScheduler: 'timer-scheduler',
save: 'save-progress', gamefeel: 'gamefeel', juice: 'particles-juice', palettePost: 'palette-post',
audioMusic: 'audio-music', collision: 'collision', physics: 'physics-lite',
};
/** PluginContext(boot.ctx)真方法集 + 运行时附加 log;time/random 是属性对象(ctx.time.nowMs()/ctx.random.next() 内层 time/random≠ctx,经"非键跳过"放行)。 */
const CTX_METHODS = new Set(['getContext2d', 'onFrame', 'getInput', 'getAudioContext', 'getEngine', 'log']);
const _apiCache = new Map();
/** 读某插件 api.d.ts 抽其声明的方法名集(宽松:取所有「名(」形态,含泛型方法 名<T>(,over-permissive=少误报)。返回 Set|null。 */
function apiMethodSet(dir) {
if (_apiCache.has(dir)) return _apiCache.get(dir);
let set = null;
try {
const txt = readFileSync(join(GAME_RUNTIME, 'src', 'plugins', dir, 'api.d.ts'), 'utf8');
set = new Set();
// 名后可带可选 ? 与泛型形参 <…>(如 save.get<T = unknown>(…)):漏认泛型会把真实方法误判"不存在"、误杀带存档的游戏。
for (const m of txt.matchAll(/(?:^|\n)\s*(\w+)\??\s*(?:<[^>]*>)?\s*\(/g)) set.add(m[1]);
} catch { set = null; }
_apiCache.set(dir, set);
return set;
}
const _voidCache = new Map();
/**
* 读某插件 api.d.ts 抽「返 void 的方法名集」(逐行匹配 `名(...): void`;含 export declare function 与接口实例方法两形态)。
* 用途:形状门①据此判「把返 void 的绘制/音效/特效 API 的返回值赋值或链式消费」这类带病过 check 的幻觉(base2 实证 drawButton)。
* over-permissive:api.d.ts 未标返回类型的方法不入集 → 漏报优于误杀(对齐 apiMethodSet 的少误报取向)。返回 Set|null。
*/
function apiVoidMethodSet(dir) {
if (_voidCache.has(dir)) return _voidCache.get(dir);
let set = null;
try {
const txt = readFileSync(join(GAME_RUNTIME, 'src', 'plugins', dir, 'api.d.ts'), 'utf8');
set = new Set();
// 逐行锚定:行首(可带 export declare function / readonly 修饰)的「名(…): void」;参数里的 `=> void` 不以「): void」收尾、不误收。
for (const m of txt.matchAll(/^\s*(?:export\s+declare\s+function\s+|readonly\s+)?(\w+)\??\s*(?:<[^>]*>)?\s*\([^\n]*\)\s*:\s*void\b/gm)) set.add(m[1]);
} catch { set = null; }
_voidCache.set(dir, set);
return set;
}
/** 从 s 的 openParenIdx(指向某个 `(`)起配平括号,返回匹配 `)` 之后第一个非空白是否为 `.`(= 链式消费该调用的返回值)。s 须已 stripCode(字符串/注释已去,无游离括号)。 */
function isChainedAfter(s, openParenIdx) {
let depth = 0;
let i = openParenIdx;
for (; i < s.length; i++) {
const c = s[i];
if (c === '(') depth += 1;
else if (c === ')') { depth -= 1; if (depth === 0) { i += 1; break; } }
}
while (i < s.length && /\s/.test(s[i])) i += 1;
return s[i] === '.';
}
/**
* 形状静态门③②的判定(从 check 第5节抽出,便于单测):接原始源码与文件名,返回形状门 errors[]。
* ① 返 void 的插件 API 返回值被赋值/链式消费(void 名集按键从 api.d.ts 动态取;base2 drawButton 实证)。
* ② sceneFsm.define 单对象/数组批量误用(base3 2048 实证)。
* 形态刻意收窄到赋值/链式——base1/4/5 实证合法 void 调用全是独立语句 + if(guard)call(),零误杀;条件/参数形态移交 WU-C 5.4。
*/
export function _shapeGateErrors(rawSrc, f) {
const errs = [];
const s = stripCode(rawSrc);
// ① 返 void 插件 API 的返回值被赋值/链式消费:遍历 plugins.<键>.<方法>( 调用,<方法> ∈ 该键 void 名集时判返回值有没有被消费。
for (const m of s.matchAll(/\b(\w+)\.(\w+)\s*\(/g)) {
const key = m[1], method = m[2];
const dir = PLUGIN_KEY_DIR[key];
if (!dir) continue;
const vs = apiVoidMethodSet(dir);
if (!vs || !vs.has(method)) continue;
// 赋值消费:调用前最近非空白是单个 `=`(排除 == / != / <= / >= 比较);链式消费:调用闭括号后紧邻 `.`。
const before = s.slice(0, m.index).replace(/\s+$/, '');
const assigned = before.endsWith('=') && !/[=!<>]/.test(before.slice(-2, -1));
const chained = isChainedAfter(s, m.index + m[0].length - 1);
if (!assigned && !chained) continue;
// 泛化判据:凡 api.d.ts 标注返 void 的插件 API 被赋值/链式消费即拦(void 名集动态从 api.d.ts 取)。
// 阶段一B #5:drawButton 已改返命中矩形 {x,y,w,h}、从 api.d.ts void 集自然剔除,故此处不再有 drawButton 特例——
// 改签名治了根因(直觉写法 const btn=drawButton(...) 现在就对),把"返 void 误用"的拦截留给仍真返 void 的方法(drawText/drawPanel/drawBar/play/flashScreen 等)。
errs.push(`形状门 ${f}: ${key}.${method}() 返 void(只执行副作用、返 undefined)——别${assigned ? '把返回值赋给变量' : '在它后面链式取值'}(返回值恒 undefined → 后续判断/取值短路)。当独立语句调用即可:${key}.${method}(...);需要的状态走对应读取方法(见 src/plugins/${dir}/api.d.ts)`);
}
// ② define 单对象/数组批量(第一参应是场景名字符串):匹配「.define( {」或「.define( [」。
if (/\.define\s*\(\s*[{[]/.test(s)) {
errs.push(`形状门 ${f}: sceneFsm.define(name, handlers) 第一参须是场景名【字符串】、逐场景各调一次——别 define({menu:{},play:{}}) 单对象批量、也别 define([...]) 数组批量(第一参变 [object Object] / 数组、场景全没注册 → transition 被忽略 → 卡菜单)。改:sceneFsm.define('menu',{...}); sceneFsm.define('play',{...}); sceneFsm.define('over',{...})`);
}
return errs;
}
/**
* 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 永不到期、一局不结束)');
}
// 可测性红线:必须实现 _forensicsView()(九门据其 state() 验"真活+有进展";不写则 E_live/H_progress 读不到状态)。
if (!/_forensicsView\s*\(/.test(g)) {
errors.push('game-logic.js 必须实现 _forensicsView()(返回 {state(),measures()};state() 导出 phase/score 等可观测态——九门据此验真活+进展,可测性红线)');
}
// ctx 契约硬化(治 M3 实测错:把 boot.ctx 写成 boot.boot.ctx → 多套一层 → undefined → ctx=null → 不订阅输入 →
// 游戏点不动、卡菜单;且九门默认未驱动时 G_input skip → 漏判,带病过门)。host 传入的 boot 已是 {ctx,mainContext,canvas,seed,assets},
// 受控面就是 boot.ctx。匹配「.boot.ctx」(boot.boot.ctx / b.boot.ctx 皆中),正确的 boot.ctx 不含前导「.boot」不误伤。
if (/\.boot\s*\??\.\s*ctx\b/.test(g)) {
errors.push('init(boot) 受控面是 boot.ctx,不是 boot.boot.ctx——host 传入的 boot 已是 {ctx,...};多套一层 .boot → undefined → ctx=null → 无输入、游戏点不动');
}
// 输入收归 L1(根治 B):L3 绝不自己订阅输入(ctx.getInput / pendingClicks 队列 / update 内挑时机消费点击 →
// M3 实测把点击消费放进 play 分支、在 menu early-return 之后 → 菜单点击永不消费 → 游戏启动不了)。
// 改为:只写实例方法 handleTap(x,y)[/handleKey(key)],L1 的 game.js wrapper 在 pointerdown/keydown 时直达调用。
if (/\bgetInput\s*\(/.test(g)) {
errors.push('game-logic.js 不要调 ctx.getInput()——输入订阅已收归 L1(game.js wrapper):只写实例方法 handleTap(x,y)(点击)/handleKey(key)(键盘),L1 在 pointerdown/keydown 时直达调用它(根治"点击在错 phase 被消费/永不消费→启动不了")');
}
// 必须暴露至少一个输入方法(否则游戏收不到输入、点不动)。handleTap(点击主输入)或 handleKey(方向键/空格)。
if (!/\bhandleTap\b/.test(g) && !/\bhandleKey\b/.test(g)) {
errors.push('game-logic.js 必须实现至少一个输入方法:handleTap(x,y)(点击/经营点客/打地鼠等主输入)或 handleKey(key)(方向键/空格类)——L1 据此在 pointerdown/keydown 时派发;不实现则游戏收不到输入、点不动');
}
} 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,输入经实例方法 handleTap/handleKey 由 L1 派发)`);
}
}
// 4) API-存在静态门(玩法无关,治"方法名幻觉" stopBgm/defineScenes/ctx.nowMs):游戏调的 plugins.<键>.<m>() / ctx.<m>() 必须在 api.d.ts 真存在。
// 只查已知插件键(host-config 注入的 11)+ ctx 直接方法;非键(state/Math/g/bundle/time/random 内层…)跳过 → over-permissive 少误报。
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 m of s.matchAll(/\b(\w+)\.(\w+)\s*\(/g)) {
const key = m[1], method = m[2];
if (key === 'ctx') {
if (!CTX_METHODS.has(method)) {
errors.push(`API 静态门 ${f}: ctx.${method}() 不存在——boot.ctx 真方法 = ${[...CTX_METHODS].join('/')};时间用 ctx.time.nowMs()、随机用 ctx.random.next()(time/random 是对象不是函数)`);
}
} else if (PLUGIN_KEY_DIR[key]) {
const ms = apiMethodSet(PLUGIN_KEY_DIR[key]);
if (ms && ms.size && !ms.has(method)) {
errors.push(`API 静态门 ${f}: ${key}.${method}() 不存在——见 src/plugins/${PLUGIN_KEY_DIR[key]}/api.d.ts 的真方法名(M3 方法名幻觉)`);
}
}
}
}
// 5) 形状静态门(治"方法名对但参数形状/返回值幻觉、带病过 check"两类实测翻车):
// ① 返 void 的插件 API 返回值被赋值/链式消费(base2 drawButton 实证);② sceneFsm.define 批量误用(base3 实证)。
// 判定抽到 _shapeGateErrors(便于单测);只查 agent 写的游戏文件。
for (const f of ['game-logic.js', 'core.js', 'render.js']) {
const abs = join(gd, 'src', f);
if (!existsSync(abs)) continue;
for (const e of _shapeGateErrors(readFileSync(abs, 'utf8'), f)) errors.push(e);
}
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 读半成品
}
// 资产目录(若有外采美术):整目录拷到 staged gameId 目录,使 play.cdp / studio 经相对 ./assets/ 可 fetch
// → boot-game-host.loadHostAssets 据 manifest.json 载图注入 boot.assets。无美术(_template 空 manifest)→ 拷个空表,无害。
const assetsSrc = join(gd, 'assets');
if (existsSync(assetsSrc)) {
const assetsDst = join(dst, 'assets');
rmSync(assetsDst, { recursive: true, force: true });
cpSync(assetsSrc, assetsDst, { recursive: true });
}
return { ok: true, dir: dst };
}
/**
* ensurePlaySpec —— 据游戏 _forensicsView().state() 形态自动产 _wg1-gen/<gameId>/play-spec.json(治"纯 harness 裸跑无 driver→driven=false→E/H 降 advisory→假绿")。
* driver 推断(照 play.cdp.cjs 的 driver 家族):
* · state 有 targets 数组(点击类:打地鼠/经营点客/点离散目标)→ tap-targets + targetMode:'occupied'(只点 occupied===true 的目标,且 phase≠play 时自动 start 起局);
* · 否则(按键类,无 targets)→ key-cycle(循环按方向键)。
* gatespec:assertAfterPlay 断 score 上升(真玩应加分)+ expectLatch(限时类 latch 现有 advisory 兜底、不误杀)。
* **已存在 play-spec(如 SAA Java 路产的)则不覆盖**——只在缺失时补。
* @param {string} gameId stage 后的 gameId(写 _wg1-gen/<gameId>/play-spec.json)
* @param {object|null} state smokeBoot 抓到的 _forensicsView().state() 快照(null=读不到→保守按键类)
* @returns {{ok:boolean, wrote:boolean, reason?:string, driverType?:string, path?:string}}
*/
export function ensurePlaySpec(gameId, state) {
try {
const dst = saaGameDir(gameId);
const specPath = join(dst, 'play-spec.json');
if (existsSync(specPath)) {
return { ok: true, wrote: false, reason: '已存在 play-spec(SAA Java 路或上次产)→不覆盖', path: specPath };
}
// 形态推断:state.targets 是非空(或存在)数组 → 点击类;否则按键类。读不到 state 保守按键类(key-cycle 对绝大多数游戏无害)。
const hasTargets = !!(state && Array.isArray(state.targets));
let driver, exportState;
if (hasTargets) {
driver = { type: 'tap-targets', targetMode: 'occupied', targetsPath: 'targets', steps: 50, stepMs: 220 };
exportState = ['phase', 'score', 'targets'];
} else {
driver = { type: 'key-cycle', keys: ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'], steps: 60, stepMs: 180, downMs: 60 };
exportState = ['phase', 'score'];
}
const spec = {
exportState,
driver,
assertAfterPlay: [{ path: 'score', op: 'increased', why: '真玩应加分' }],
expectLatch: true, // latch 现有 advisory 兜底:限时/无快速失败态类玩不到终局但有真进展→降 advisory 不致命(play.cdp.cjs)
};
mkdirSync(dst, { recursive: true });
const tmp = specPath + '.tmp';
writeFileSync(tmp, JSON.stringify(spec, null, 2), 'utf8');
renameSync(tmp, specPath); // 原子写,防 play 读半成品
return { ok: true, wrote: true, driverType: driver.type, path: specPath };
} catch (e) {
return { ok: false, wrote: false, reason: String((e && e.message) || e) };
}
}
// ───────────────────────── 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);
}
}