lili 882ca3f357 feat(cheap-gen): W-AXIS 波2 判定语义重置——独立模型玩法判定阻断+拆残笼+判定档切 M3
裁定三落地:accepted = 机械预筛 ∧ 独立模型玩法判定,九门 pass 降为「未见明显死」预筛。

- 波2a 玩法判定器(cheap_verify.py):读 brief+run-summary+全程截图(含新增局中连拍
  midplay-1..N,play.cdp.cjs 只读并发拍、不进任何门判据),布尔裁 broken/hollow/off_brief,
  fail-closed;判定真相层落 evidence/judge.json + verdict.json 写回 accepted/judge 段;
  金标夹具 fixtures/judge-golden/ + judge_golden.py 校准跑批
- 波2b 拆残笼:check 词法 tokenizer 替换 stripCode 正则(字符串内 // 假阴根修,292 在册
  源零差异);prompt v1.6.3 外科清洗(两层奖励→纯设计语/删盲驱动器围设计/删熔断恐吓),
  registry 热取==内置逐字节一致(cheap_roles 内置回退同步);edit_file 空白归一+近似片段提示
- 判定器补修:空输出有界重试一次(glm 思考吃光 max_tokens 时 content 空)+重试放大公式
  反缩 bug 修(旧 min(×2,6000) 大 base 反缩)+finishReason 落盘;imagesSeen 模型自报+
  盲检微扰重掷、两掷均盲=仪器故障 degraded(闲鱼中转静默丢图实锤,不再错杀 hollow)
- 判定档切 MiniMax-M3(创始人 2026-07-10 拍板,裁定三①边界随 v2 plan 修订):
  generation.yaml judge.model+max_tokens=202752(512K 网关实探 400 拒,取实探上限);
  3 例盲案 M3 重判全 accept、¥0.026/局
- 三路消费对齐:CLI 预筛语义修(cheap_studio 喂 verdict.pass 而非 finished,违裁定三的
  放行已纠)/Service 路 apply_gameplay_judge 显式 prefilter 参数/result_out 权威改读
  accepted(无键回落预筛,旧产物兼容)+trace.gameplayJudge additive 透传;
  tier2 gate_judge/genconfig 同步判定配置与消费

测试:test_gameplay_judge 33 项新增,全仓 pytest 480 绿;真判定闭环 accept ¥0.061。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:27:18 -07:00

866 lines
50 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 的工具层
* owneramodel-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 + 未定义标识符/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-<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()」)。
* 抹除时把内容换成【等量空格、保留换行】——结果串与原文行号一一对应(红线报错据此定位到行),
* 且长度不变、不误匹配(空格非 \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 阶段一Btime/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 抽其声明的方法名集(宽松:取所有「名(」形态,含泛型方法 名<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;
}
// ───────────────────────── 词法真实红线判定(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<line>
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.<键>.<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()/.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-<id>/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/<gameId>/;SAA play/emit 既有读盘契约,治 review B1)。 */
export function saaGameDir(gameId) {
return join(WG1_DIR, gameId);
}
/**
* scaffoldSaa:clone 模板 → 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)。
*
* @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/<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, arg3] = process.argv;
if (!cmd || !id) {
console.error('用法:node tools.mjs <scaffold|check|build|scaffold-saa|build-saa|stage> <id> [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);
}
}