/** * 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-/;拒绝越界。 * - list_dir : repo 根内列目录。 * - check : 循环内快反馈 = node --check 各 src/*.js + 五法/导出名/红线静态 lint + 未定义标识符/import 断链扫描。 * - 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'; import { createRequire } from 'node:module'; import { scanUndefinedIdentifiers, scanImportChain } from './check-undef.mjs'; 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-/(去掉 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()」)。 * 抹除时把内容换成【等量空格、保留换行】——结果串与原文行号一一对应(红线报错据此定位到行), * 且长度不变、不误匹配(空格非 \w、非 `.`,既有的 matchAll/结构正则语义不变)。 * * 【为什么必须单遍扫描,不能用多条独立正则】(2026-07-08 n=5 收敛环 trpg-cyber 坐实的 check 假阴性根治): * 旧版 5 条独立 .replace 先剥行注释、再剥字符串——但注释与字符串会互相嵌套:字符串里的 `//` * (赛博主题 '> JACK IN // FLOOR 01' 这类)会被行注释正则误当注释、吞到行尾(含闭合引号)→ 引号失衡 * → 后续字符串正则从游离引号起跨行匹配 → 级联误抹真代码(实测抹掉 169 行,连 bundle.tick/_forensicsView/ * handleTap 一起抹)→ check 误报红线方法"缺失"→ 模型明明写对了却被反复拒 → thrash 到步数顶熔断、烧钱失败。 * 单遍状态机逐字符扫,同一时刻只处于「码/行注释/块注释/单引/双引/模板」一种态,嵌套的 `//`、引号各归其态、 * 绝不越界——这是「注释与字符串互嵌」这类词法问题唯一正确的解法。逐字节保长、换行保留(行号对齐不变)。 */ export function stripCode(s) { const out = []; const n = s.length; let state = 'code'; // code | line | block | sq(单引) | dq(双引) | tpl(模板) for (let i = 0; i < n; i++) { const c = s[i]; const c2 = i + 1 < n ? s[i + 1] : ''; if (state === 'code') { if (c === '/' && c2 === '/') { state = 'line'; out.push(' '); i += 1; continue; } if (c === '/' && c2 === '*') { state = 'block'; out.push(' '); i += 1; continue; } if (c === "'") { state = 'sq'; out.push(' '); continue; } if (c === '"') { state = 'dq'; out.push(' '); continue; } if (c === '`') { state = 'tpl'; out.push(' '); continue; } out.push(c); continue; // 正常码逐字节保留(正则字面量按码处理,与旧版同,不新增误伤面) } if (state === 'line') { // 行注释:遇换行收尾 if (c === '\n') { state = 'code'; out.push('\n'); continue; } out.push(' '); continue; } if (state === 'block') { // 块注释:遇 */ 收尾,换行保留 if (c === '*' && c2 === '/') { state = 'code'; out.push(' '); i += 1; continue; } out.push(c === '\n' ? '\n' : ' '); continue; } // 字符串三态(sq/dq/tpl):转义跳一对、遇匹配闭合退回码、换行保留(模板可跨行;单双引跨行容错) const quote = state === 'sq' ? "'" : state === 'dq' ? '"' : '`'; if (c === '\\') { out.push(' '); out.push(c2 === '\n' ? '\n' : (c2 ? ' ' : '')); i += 1; continue; } if (c === quote) { state = 'code'; out.push(' '); continue; } out.push(c === '\n' ? '\n' : ' '); continue; } return out.join(''); } /** 插件键 → 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,经"非键跳过"放行)。 */ // #1 阶段一B:time/random 既是对象(.nowMs()/.elapsedMs()、.next()/.range()/.reseed())又可直接调用(ctx.time()/ctx.random()),故两者都计入合法 ctx 调用名。 const CTX_METHODS = new Set(['getContext2d', 'onFrame', 'getInput', 'getAudioContext', 'getEngine', 'log', 'time', 'random']); const _apiCache = new Map(); /** 读某插件 api.d.ts 抽其声明的方法名集(宽松:取所有「名(」形态,含泛型方法 名(,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(…)):漏认泛型会把真实方法误判"不存在"、误杀带存档的游戏。 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; } // ───────────────────────── 词法真实红线判定(W-AXIS 波2)───────────────────────── // 动机(诊断档 §2.1/§2.4):红线八条与 .boot.ctx/getInput 的命中判定原本是「stripCode 抹字符串/注释 → 对纯文本跑 // 正则」。stripCode 修好后(0be0f61c 单遍状态机)字符串内 // 已不 desync,但「对纯文本跑正则」仍是近似:token 间 // 夹空白/注释(Math./*c*/random())、模板插值内的裸调(`${Math.random()}`)靠正则都不牢。改为真词法扫描—— // 字符串/模板文本/注释一律不产 token(其内的 // 与 API 名天然不参与匹配),码区与插值 ${} 内的标识符/标点才产 // token 带行号,按 token 序列判命中。**纯 JS 零依赖**:check 每回合 shell-out 跑,现货只有 esbuild(无 JS-AST // API)、acorn 未装,若引入解析器硬依赖则一旦缺包 check 崩=生成全线停摆;词法器抛异常时兜底退回 stripCode+正则 // (_bannedLineErrorsViaStrip),既保词法真实又绝不 fail-open 放行裸调。 /** * lexTokens —— 单遍词法扫描,只产「码区」判定需要的 token:标识符(含关键字如 new)与标点 . ( ?.(各带 1-based 行号)。 * 字符串(''/"")、模板文本、行/块注释内不产 token;模板插值 ${…} 内部按【码】扫描(裸调藏进插值也照样产 token)。 * 行号在任何状态遇 \n 自增(命中定位到原文行);跨行容错对齐 stripCode(单双引串内换行退回外层)。 * @param {string} src 原始源码 @returns {{k:'id'|'p', v:string, line:number}[]} */ export function lexTokens(src) { const toks = []; const n = src.length; let line = 1; let state = 'code'; // code | line(行注释) | block(块注释) | sq(单引) | dq(双引) | tpl(模板) const stack = []; // 进字符串/插值前保存的外层 state(支持模板插值嵌套) let brace = 0; // 当前 code 区 { } 深度(仅用于判 ${…} 的闭合 }) const braceBase = []; // 每层插值起始 brace 基准(回到基准的 } = 该层插值结束) for (let i = 0; i < n; i++) { const c = src[i]; const c2 = i + 1 < n ? src[i + 1] : ''; if (state === 'code') { if (c === '\n') { line++; continue; } if (c === '/' && c2 === '/') { state = 'line'; i++; continue; } if (c === '/' && c2 === '*') { state = 'block'; i++; continue; } if (c === "'") { stack.push('code'); state = 'sq'; continue; } if (c === '"') { stack.push('code'); state = 'dq'; continue; } if (c === '`') { stack.push('code'); state = 'tpl'; continue; } if (c === '{') { brace++; continue; } if (c === '}') { if (braceBase.length && brace === braceBase[braceBase.length - 1]) { braceBase.pop(); state = stack.pop(); continue; // 回到插值起始层 → 弹回模板态 } if (brace > 0) brace--; continue; } if (/[A-Za-z_$]/.test(c)) { // 标识符(关键字如 new 也当 id,只看 value) let j = i + 1; while (j < n && /[\w$]/.test(src[j])) j++; toks.push({ k: 'id', v: src.slice(i, j), line }); i = j - 1; continue; } if (c === '?' && c2 === '.') { toks.push({ k: 'p', v: '?.', line }); i++; continue; } if (c === '.') { toks.push({ k: 'p', v: '.', line }); continue; } if (c === '(') { toks.push({ k: 'p', v: '(', line }); continue; } continue; // 其余(运算符/数字/;[]等)与红线匹配无关,跳过 } if (state === 'line') { if (c === '\n') { line++; state = 'code'; } continue; } if (state === 'block') { if (c === '\n') line++; else if (c === '*' && c2 === '/') { state = 'code'; i++; } continue; } if (state === 'sq' || state === 'dq') { const q = state === 'sq' ? "'" : '"'; if (c === '\\') { if (c2 === '\n') line++; i++; continue; } // 转义跳一对(含续行) if (c === '\n') { line++; state = stack.pop(); continue; } // 未闭合换行:退回外层(跨行容错) if (c === q) { state = stack.pop(); } continue; } // state === 'tpl'(模板字面量) if (c === '\\') { if (c2 === '\n') line++; i++; continue; } if (c === '\n') { line++; continue; } if (c === '`') { state = stack.pop(); continue; } if (c === '$' && c2 === '{') { braceBase.push(brace); stack.push('tpl'); state = 'code'; i++; continue; } // 进插值:按码扫描 } return toks; } /** 点号调用形态 [id:obj][p:.][id:prop][p:(] 是否在 toks[i](=obj 处)成立。 */ function _dotCallAt(toks, i, prop) { return toks[i + 1] && toks[i + 1].k === 'p' && toks[i + 1].v === '.' && toks[i + 2] && toks[i + 2].k === 'id' && toks[i + 2].v === prop && toks[i + 3] && toks[i + 3].k === 'p' && toks[i + 3].v === '('; } /** * _bannedLineErrors —— 红线八条命中判定【词法真实版】。用 lexTokens 产的 token 序列判命中,不再对去注释纯文本跑正则: * ① 字符串/模板文本/注释内的同名 API 绝不误命中(0be0f61c 案回归);② 函数内/模板插值内的真实裸调照样命中 * (词法真实,非纯文本近似);③ 报错带确切行号 + edit_file 补救指向。词法器/匹配异常 → 兜底退回 * _bannedLineErrorsViaStrip(绝不 fail-open)。BANNED[] 保留作命中规则的报错源文案(下标须与匹配 add 一致)。 * @param {string} rawSrc 原始源码 @param {string} f 文件名 @returns {string[]} */ export function _bannedLineErrors(rawSrc, f) { try { const toks = lexTokens(rawSrc); const hits = new Map(); // BANNED 下标 → Set const add = (idx, line) => { let s = hits.get(idx); if (!s) { s = new Set(); hits.set(idx, s); } s.add(line); }; for (let i = 0; i < toks.length; i++) { const t = toks[i]; if (t.k !== 'id') continue; const nxt = toks[i + 1]; const call = !!(nxt && nxt.k === 'p' && nxt.v === '('); // 后缀 name( 形态 switch (t.v) { case 'Math': if (_dotCallAt(toks, i, 'random')) add(0, t.line); break; case 'Date': if (_dotCallAt(toks, i, 'now')) add(1, t.line); break; case 'performance': if (_dotCallAt(toks, i, 'now')) add(2, t.line); break; case 'setTimeout': if (call) add(3, t.line); break; case 'setInterval': if (call) add(4, t.line); break; case 'requestAnimationFrame': if (call) add(5, t.line); break; case 'new': if (nxt && nxt.k === 'id' && nxt.v === 'AudioContext') add(6, t.line); break; case 'addEventListener': if (call) add(7, t.line); break; default: break; } } const errs = []; for (let idx = 0; idx < BANNED.length; idx++) { const set = hits.get(idx); if (!set || !set.size) continue; const sorted = [...set].sort((a, b) => a - b).slice(0, 10); errs.push(`红线 ${f} 第 ${sorted.join('/')} 行: 禁用 ${BANNED[idx].source}——时间/随机经 boot.ctx(ctx.time.nowMs()/ctx.random.next())、定时经 timer-scheduler、输入经实例方法 handleTap/handleKey 由 L1 派发;词法真实扫:字符串/注释内的同名不误报、函数内/模板插值内的裸调照样命中(别靠 typeof 守卫或封装绕)。用 edit_file 把该行改回 ctx 形态,别整文件重写。`); } return errs; } catch (e) { return _bannedLineErrorsViaStrip(rawSrc, f); // 词法器异常:退回旧实现(绝不漏报裸调) } } /** * _bannedLineErrorsViaStrip —— 红线八条判定的【旧实现】:stripCode 抹注释/字符串 → 对纯文本跑 BANNED 正则,逐行定位行号。 * 现降为词法真实版 _bannedLineErrors 的【异常兜底】+【新旧 diff 对照基线】(换轴时并跑一批验差异)。逐字节保留旧行为、不删。 * @param {string} rawSrc 原始源码 @param {string} f 文件名 @returns {string[]} 红线 errors */ export function _bannedLineErrorsViaStrip(rawSrc, f) { const errs = []; const s = stripCode(rawSrc); // 已抹注释/字符串、保行号 const lines = s.split('\n'); for (const re of BANNED) { const rx = new RegExp(re.source, re.flags.replace(/[gy]/g, '')); // 去 g/y 防 lastIndex 粘连 if (!rx.test(s)) continue; // 整串命中才报(保持原检测强度,不漏跨行写法) const hit = []; for (let i = 0; i < lines.length && hit.length < 10; i++) { if (rx.test(lines[i])) hit.push(i + 1); } const where = hit.length ? `第 ${hit.join('/')} 行` : '(跨行命中,行号未定位)'; errs.push(`红线 ${f} ${where}: 禁用 ${re.source}——时间/随机经 boot.ctx(ctx.time.nowMs()/ctx.random.next())、定时经 timer-scheduler、输入经实例方法 handleTap/handleKey 由 L1 派发;函数内的回退裸调、任意嵌套层同样命中(正则按纯文本扫,别靠 typeof 守卫绕)。用 edit_file 把该行改回 ctx 形态,别整文件重写。`); } return errs; } /** * _bootCtxLine —— `.boot.ctx` 双层 boot 误用的命中行(0=未命中);词法序列 [p:.][id:boot][p:.|?.][id:ctx]。 * 须有前导 `.`(即 X.boot.ctx),故正确形态 boot.ctx(boot 是入参、前面是 = 或 ( )不误命中。异常兜底退回正则。 */ export function _bootCtxLine(rawSrc) { try { const toks = lexTokens(rawSrc); for (let i = 0; i < toks.length; i++) { if (toks[i].k === 'p' && toks[i].v === '.' && toks[i + 1] && toks[i + 1].k === 'id' && toks[i + 1].v === 'boot' && toks[i + 2] && toks[i + 2].k === 'p' && (toks[i + 2].v === '.' || toks[i + 2].v === '?.') && toks[i + 3] && toks[i + 3].k === 'id' && toks[i + 3].v === 'ctx') { return toks[i + 1].line; } } return 0; } catch { return /\.boot\s*\??\.\s*ctx\b/.test(stripCode(rawSrc)) ? -1 : 0; } // 兜底:命中给 -1(有命中、行号未定位) } /** _getInputLine —— `getInput(` 命中行(0=未命中);词法序列 [id:getInput][p:(]。异常兜底退回正则。 */ export function _getInputLine(rawSrc) { try { const toks = lexTokens(rawSrc); for (let i = 0; i < toks.length; i++) { if (toks[i].k === 'id' && toks[i].v === 'getInput' && toks[i + 1] && toks[i + 1].k === 'p' && toks[i + 1].v === '(') { return toks[i].line; } } return 0; } catch { return /\bgetInput\s*\(/.test(stripCode(rawSrc)) ? -1 : 0; } } /** * check:循环内快反馈门(便宜模型自纠语法/契约错的头号手段)。 * 1) node --check 各 src/*.js + entry.js(语法); * 2) 结构契约:game.js 默认导出 + update 内调 bundle.tick;host-config.js 导出 buildTemplateHostConfig; * 3) 红线:游戏源(game/core/render)零裸时间/随机/DOM; * 4) API-存在静态门;5) 形状静态门; * 6) 未定义标识符 + import 断链静态扫描(check-undef.mjs;node --check 只查语法不查引用,这层拦"运行时才炸")。 * 返回 {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 rawLogic = readFileSync(logicAbs, 'utf8'); const g = stripCode(rawLogic); // 结构存在门(createGame/bundle.tick/_forensicsView/handleTap)仍用去注释文本正则;红线命中判定走词法(下) // 窄契约(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。命中判定走词法(W-AXIS 波2):序列 .boot.ctx(boot.boot.ctx / b.boot.ctx 皆中),正确的 boot.ctx 无前导「.boot」不误伤。 const _bootLn = _bootCtxLine(rawLogic); if (_bootLn) { errors.push(`init(boot) 受控面是 boot.ctx,不是 boot.boot.ctx${_bootLn > 0 ? `(第 ${_bootLn} 行)` : ''}——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 时直达调用。命中判定走词法(W-AXIS 波2)。 const _giLn = _getInputLine(rawLogic); if (_giLn) { errors.push(`game-logic.js${_giLn > 0 ? ` 第 ${_giLn} 行` : ''} 不要调 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 固定不查)。判定抽到 _bannedLineErrors(便于单测),报错带确切行号 + edit_file 补救指向。 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 _bannedLineErrors(readFileSync(abs, 'utf8'), f)) errors.push(e); } // 4) API-存在静态门(玩法无关,治"方法名幻觉" stopBgm/defineScenes/ctx.nowMs):游戏调的 plugins.<键>.() / ctx.() 必须在 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()/.range(a,b))——#1 后 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); } // 6) 未定义标识符 + import 断链静态扫描(4 例实证同族:解谜 gen3 未定义 cy / gen6 未定义 stepUntilMs / // 非遗首败 import 断链——语法过 check、运行时才炸,九门才拦得住,浪费整局;node --check 只查语法不查引用)。 // esbuild(树内现货)当作用域分析器,判定与漏报口径见 check-undef.mjs;esbuild 不可用时函数自身降级返回空。 // 生成侧五文件都查(balance/assets 同为 agent 手笔;存在才扫)。 for (const f of ['game-logic.js', 'core.js', 'render.js', 'balance.js', 'assets.js']) { const abs = join(gd, 'src', f); if (!existsSync(abs)) continue; for (const e of scanUndefinedIdentifiers(readFileSync(abs, 'utf8'), f)) errors.push(e); } // import 断链:以 game-logic.js 为根静态解析整个 import 图(core/render/balance/assets 全经它可达; // 根不可达的文件运行时也不加载,不属"运行时才炸"族)。game-logic.js 缺失时上面第 2 节已报,此处跳过。 if (existsSync(logicAbs)) { for (const e of scanImportChain(logicAbs, gd)) errors.push(e); } return { ok: errors.length === 0, errors }; } // ───────────────────────── tsc 类型门(W-AXIS 波3;advisory·独立通道·永不阻断)───────────────────────── // 定位(诊断档 §四⑤ / plan 波3 ①):TapTap 类生成线都有「真编译/LSP 门」;本项目此前只有 node --check(语法) // + esbuild 作用域分析(未定义标识符),缺一层「真类型检查」——ctx.time 用错形态、拼错本地属性名、把数字当函数 // 调、字符串做算术这类运行时才炸的错,语法门与作用域门都放行。tsc checkJs 对游戏源做真类型推断能把它们挡在 // CDP 真玩开销前(与 TS2551「拼错 push→建议 push」同族,带修复建议)。 // // 硬约束(plan 波3 边界 + AGENTS.md §6.13): // 1) 本波【只 advisory】——findings 绝不进 check 的 errors[]、绝不改 check 退出码/ok;转阻断是 n=5 校准后 // 的另一个决定(下面 _tscGateMode 留了 'block' 开关位,本波默认 'advisory',不激活阻断)。 // 2) tsc 不可用(未装 typescript / 解析失败 / 运行抛错)→ 输出一行降级说明即返回,check 主判定完全不受影响 // ——fail-open 只发生在本通道自身。故整函数不抛、任何异常都吞成 degraded。 // 3) 只查 agent 写的 L3 源(game-logic/core/render/balance/assets),不碰 L1 plumbing。 // // 消噪校准(对 10 款金标语料——2 golden + 6 _template* + 2 _fewshot*——逐一跑到零误伤,W-AXIS 波3 实测): // · lib 含 dom:游戏源 JSDoc 用 CanvasRenderingContext2D 等 DOM 类型,不含 dom 会误报 TS2304。 // · 8xxx(JSDoc 元数据码,如 @param 名不匹配 TS8024)整族丢弃:本门查逻辑/类型 bug,不查 JSDoc 卫生。 // · TS2307 指向平台类型契约(src/core/*.d.ts、src/plugins/*/api.d.ts)丢弃:真实产物在 games/amgen-/src/ // 下该相对路径解析得到,只有金标 fixture 被移出 games/ 才深度错位——位置伪影、非代码错。 // · ctx.log / handleTap / handleKey / _forensicsView / _handleAction 这些【运行时附加、.d.ts 曾漏声明】的成员, // 已在 src/core/{api,game-host}.d.ts 补声明(非在此消噪)——让类型面与运行时契约一致,真实的 handleTap 拼写 // 错等仍会被抓。ctx/plugins/bundle/engine 等运行时注入面无类型标注 → 天然 any → 不产生契约误报。 const _TSC_GATE_ENV = 'CHEAP_TSC_GATE'; // 门开关:'advisory'(默认,本波) | 'off'(关) | 'block'(留位,本波不激活) const _TSC_L3_FILES = ['game-logic.js', 'core.js', 'render.js', 'balance.js', 'assets.js']; /** 读门模式(env CHEAP_TSC_GATE;缺省 advisory)。block 是给 n=5 校准后转阻断留的开关位,本波【不激活】,遇到按 advisory 跑。 */ function _tscGateMode() { const v = (process.env[_TSC_GATE_ENV] || '').trim().toLowerCase(); if (v === 'off') return 'off'; // 'block' 本波不激活:仍按 advisory 跑(留位不阻断);其余一律 advisory。 return 'advisory'; } /** 惰性解析 typescript(装了才有;createRequire 从本文件 resolve → 命中 game-runtime/node_modules/typescript)。解析不到返回 null。 */ function _resolveTypescript() { try { const req = createRequire(import.meta.url); return req('typescript'); } catch { return null; // 未装 typescript:上层输出降级说明,主判定不受影响 } } /** * tscAdvisory:对本 run 游戏 L3 源跑 tsc checkJs 真类型检查,产【advisory】反馈(永不阻断,永不抛)。 * 返回 {mode, available, findings:[{code,loc,msg}], note}——CLI/worker 只把它格式化进 check 输出的独立一节, * 绝不折进 errors[]。tsc 不可用/任何异常 → available:false + note 降级说明。 */ export function tscAdvisory(id) { const mode = _tscGateMode(); if (mode === 'off') return { mode, available: false, findings: [], note: 'tsc 类型门已关(CHEAP_TSC_GATE=off)' }; try { const ts = _resolveTypescript(); if (!ts) { return { mode, available: false, findings: [], note: 'tsc 不可用(未安装 typescript)——类型门 advisory 通道降级,check 主判定不受影响。装:cd game-runtime && npm i -D typescript' }; } const srcDir = join(gameDir(id), 'src'); const files = _TSC_L3_FILES.map((f) => join(srcDir, f)).filter((p) => existsSync(p)); if (!files.length) return { mode, available: true, findings: [], note: '无 L3 源可查' }; const options = { allowJs: true, checkJs: true, noEmit: true, skipLibCheck: true, lib: ['lib.es2020.d.ts', 'lib.dom.d.ts'], target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, strict: false, noImplicitAny: false, noImplicitThis: false, types: [], forceConsistentCasingInFileNames: true, }; const program = ts.createProgram(files, options); const fileSet = new Set(files.map((f) => resolve(f))); const isNoise = (d) => { if (d.code >= 8000 && d.code < 9000) return true; // JSDoc 元数据族 if (d.code === 2307 && d.file && d.start != null) { // 指平台类型契约的 module-not-found = fixture 位置伪影 const t = d.file.text.slice(d.start, d.start + 200); if (/src\/(core|plugins)\/[^'"]*\.d\.ts/.test(t)) return true; } return false; }; const findings = []; for (const d of ts.getPreEmitDiagnostics(program)) { if (!d.file || !fileSet.has(resolve(d.file.fileName)) || isNoise(d)) continue; const msg = ts.flattenDiagnosticMessageText(d.messageText, ' ').replace(/\s+/g, ' ').trim(); let loc = d.file.fileName.split('/').slice(-1)[0]; if (d.start != null) { const { line, character } = d.file.getLineAndCharacterOfPosition(d.start); loc += `:${line + 1}:${character + 1}`; } findings.push({ code: d.code, loc, msg }); } return { mode, available: true, findings, note: null }; } catch (e) { // fail-open 只在本通道:任何异常吞成降级,绝不连累 check 主判定(诊断档铁律:硬依赖不得让 check 崩)。 return { mode, available: false, findings: [], note: `tsc advisory 通道异常(已降级,不影响主判定):${e && e.message ? e.message : e}` }; } } /** 把 tscAdvisory 结果格式化成 check 输出里的独立 advisory 节(供 CLI/worker 追加打印;标记「仅参考不阻断」)。 */ export function formatTscAdvisory(adv) { if (!adv) return ''; if (!adv.available) return `[check:tsc-advisory] ${adv.note || '不可用'}(仅参考,不阻断)`; if (!adv.findings.length) return '[check:tsc-advisory] 类型检查 0 发现(仅参考,不阻断)'; const lines = adv.findings.slice(0, 30).map((f) => ` · TS${f.code} ${f.loc} ${f.msg}`); const more = adv.findings.length > 30 ? `\n …(另 ${adv.findings.length - 30} 条,略)` : ''; return `[check:tsc-advisory] 类型检查 ${adv.findings.length} 发现(仅参考·不阻断·非红线;确认是真问题再改):\n${lines.join('\n')}${more}`; } /** 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//;SAA play/emit 既有读盘契约,治 review B1)。 */ export function saaGameDir(gameId) { return join(WG1_DIR, gameId); } /** * scaffoldSaa:clone 模板 → amgen-/,换上 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)。 * * @param {string} gameId 本 run 游戏 id。 * @param {string} [templateName='_template'] 起始模板目录名(扩模板:经营=_template-shop 等 per-genre 黄金骨架; * 缺省 _template=通用点圆得分起点)。模板不存在则回落 _template(不静默失败,打 warn 可查)。 */ export function scaffoldSaa(gameId, templateName = '_template') { const gd = gameDir(gameId); let tmplDir = join(GAMES_DIR, templateName || '_template'); if (!existsSync(tmplDir)) { console.warn(`[scaffold-saa] 模板 ${templateName} 不存在,回落 _template`); tmplDir = TEMPLATE_DIR; } if (existsSync(gd)) rmSync(gd, { recursive: true, force: true }); cpSync(tmplDir, 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//(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//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//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 | check | build if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const [, , cmd, id, arg3] = process.argv; if (!cmd || !id) { console.error('用法:node tools.mjs [templateName]'); 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)); // tsc 类型门:独立 advisory 节,只打印、绝不改退出码(本波不阻断;缺 tsc 自降级)。放在主判定之后, // 即便它慢/降级也不动 r.ok 与 exit code——fail-open 只在本通道自身(plan 波3 边界)。 try { const line = formatTscAdvisory(tscAdvisory(id)); if (line) console.log(line); } catch { /* advisory 绝不连累主判定 */ } 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, arg3); console.log('[scaffold-saa] →', relative(REPO_ROOT, gd), `(SAA 信封 plumbing, 模板=${arg3 || '_template'})`); } 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); } }