#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>
517 lines
28 KiB
JavaScript
517 lines
28 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);
|
||
// 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);
|
||
}
|
||
}
|