feat(amodel-gen): Phase 2 批A — harness SAA-mode(__GameBundle 信封 + stage + 循环内 smoke-boot)

execution spec v3 Phase 2 批A(harness 侧·集成 C):
- 新增 games/_wg1-gen/_shared/entry-bundle.amodel.template.js:per-game 多文件信封,导出 bootGameHost + 经 --global-name=__GameBundle(治 review B2;game 根 entry 用 2-up、src/* 用 3-up)。
- tools.mjs:scaffoldSaa(_template + SAA 信封)/ buildSaa(__GameBundle)/ stage(原子 tmp→rename 到 _wg1-gen/<gameId>/,治 review B1)。
- play.mjs:smokeBoot(SAA 信封 __genBooted 同进程 boot+帧检,治 review HIGH-1)。
- gen.mjs:--mode saa --game-id;done 门加 stage + smoke-boot;stdout 末行 JSON 契约;max-steps 默认 45 + 12min 墙钟上限 bound thrash。
- prompt.mjs + littlejs-game-dev.md:幻觉 API footgun(❌defineScenes→✅define、❌opts.ctx→✅boot.ctx、修 bug 别引入 Math.random、方法名以 api.d.ts 为准)。

验证:控制组(_template SAA 信封)+ saae2e4 e2e 全绿(agentic→__GameBundle→stage→smoke→stdout JSON);HIGH-1 smoke-boot 两次实测捕获"编译过但起不来"。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-22 04:12:06 -07:00
parent 3b425c4294
commit cd2903baab
6 changed files with 256 additions and 33 deletions

View File

@ -125,6 +125,15 @@ export default function createGame(opts) {
**典型组合**:菜单→玩→结算 用 `scene-fsm`;分数与最高分用 `session-score`(best 接 `save-progress`);倒计时/刷新波次用 `timer-scheduler`;分数/按钮/血条画面用 `hud-ui`;手感打击用 `particles-juice` + `gamefeel`。
### ⚠️ M3 实测易犯的幻觉 API(❌别这么写 → ✅正确)
> **铁律:插件方法名一律以 `src/plugins/<name>/api.d.ts` 为准,别按"常见库套路"臆测。** 以下是 M3 即便读了 api.d.ts 仍反复臆测出的错名(HJ-AGI-003 saae 实测),逐条记牢:
- ❌ `sceneFsm.defineScenes({...})` → ✅ `sceneFsm.define(name, {onEnter,onExit,update,render})` **逐个**定义(**没有** `defineScenes`)。
- ❌ 工厂里用 `opts.ctx` / `opts.boot` 取受控面 → ✅ 受控面在**实例 `init(boot)` 的 `boot.ctx`**(`boot.ctx.time` / `boot.ctx.random` / `boot.ctx.getInput()` / `boot.ctx.getEngine()`);工厂入参 `opts` **只有** `runtime:{plugins,bundle}`,**没有 ctx**。
- ❌ 修 bug 时图省事写 `Math.random()` / `Date.now()` / `setTimeout()` → ✅ `boot.ctx.random.next()` / `boot.ctx.random.range(a,b)`、`boot.ctx.time.nowMs()`、`timer-scheduler.after(ms,cb)`(红线 check 必拦;**越改越要守,thrash 时尤其别引入**)。
- ❌ `sessionScore.add(n)` 等臆测名 → ✅ 先 `read_file` 看 `session-score/api.d.ts`(实为 `addScore(d)`);**任何方法名拿不准都先读对应 api.d.ts**。
---
## 5. 资产:美术/音乐/音效放哪 + 改图去哪找

View File

@ -0,0 +1,59 @@
/**
* entry-bundle.amodel.template.js —— A-model SAA-mode bundle 入口模板(逐字拷进生成 game 目录为 entry-bundle.js)
* owner:amodel-gen(HJ-AGI-003 Phase2 批A) | 消费方:esbuild --global-name=__GameBundle → SAA 九门 index 调 __GameBundle.bootGameHost
*
* ════════════════════════════════════════════════════════════════════════════
* 【与 entry-bundle.template.js 的区别】
* entry-bundle.template.js:generic 7 插件 + 单文件 generated-factory.js(旧 factory 形态)。
* 本模板:**per-game 多文件 host-config**——用 agent 写的 src/host-config.js(buildTemplateHostConfig:
* 自由选插件 + wiring)+ src/game.js(工厂),即 A-model 形态。
* 【信封对齐(治 review B2)】导出 bootGameHost + 经 --global-name=__GameBundle 挂 window.__GameBundle,
* 与 SAA 九门 index.template.html(调 __GameBundle.bootGameHost)+ emit(断言 __GameBundle)对齐 → SAA play 客观门可复用。
* 【Q4 铁律】引擎 import 只活本入口(Option C 动态 import 后经 opts.engine 注入);src/game.js 工厂零引擎 import。
* 【路径深度】本文件落 games/amgen-<gameId>/entry-bundle.js(game 根,2-up 到 game-runtime):boot-game-host 用 `../../src/host`(2-up)。
* 注:agent 写的 src/*.js 在 src/ 内(深一层),引擎/插件用 `../../../src`(3-up,与 catch-fruit 范例逐字一致)——两处深度不同,勿混。
* ════════════════════════════════════════════════════════════════════════════
*/
'use strict';
// 通用宿主(引擎骨架参数化;本身零引擎 import,引擎经 opts.engine 注入)。本文件在 game 根 → 2-up。
import { bootGameHost as bootGameHostCore } from '../../src/host/boot-game-host.js';
// agent 写的 per-game 装配(导出名 buildTemplateHostConfig 固定,check 强校;返回 {factory,plugins,registerOrder,viewport,buildFactoryOpts})。
import { buildTemplateHostConfig } from './src/host-config.js';
// agent 写的游戏工厂(默认导出 GameHostFactory,零引擎 import)。
import gameFactory from './src/game.js';
/** 【契约导出】宿主从 window.__GameBundle.GameHostFactory 取它。 */
export const GameHostFactory = gameFactory;
export default gameFactory;
/**
* 【SAA boot 入口】host 页(index.template.html)调 window.__GameBundle.bootGameHost。
* @param {{canvas:HTMLCanvasElement, statusEl?:HTMLElement, seed?:number, recHook?:Function, onReady?:Function, engine?:any, mode?:string}} opts
* @returns {Promise<object>} bootGameHost 句柄(frameCount/stepFrames/tap/do/state 等)
*/
export async function bootGameHost(opts) {
const o = opts || {};
// Option C 动态 import 引擎:调用时才求值(浏览器运行期),esbuild 已内联进本 bundle。
const engine = o.engine || (await import('littlejsengine'));
// 主随机种子(缺省固定值,确定性可复现)。
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
// per-game 装配(agent 的 host-config:选插件 + wiring + 视口 + 工厂)。
const cfg = buildTemplateHostConfig({ seed });
return bootGameHostCore({
canvas: o.canvas,
statusEl: o.statusEl || null,
engine,
seed,
mode: o.mode || 'play',
engineMode: 'real', // SAA 真玩一律引擎掌帧
viewport: cfg.viewport,
factory: cfg.factory, // = src/game.js 默认导出
plugins: cfg.plugins, // per-game 插件集
registerOrder: cfg.registerOrder,
buildFactoryOpts: cfg.buildFactoryOpts, // 注入 runtime:{plugins,bundle}
recHook: o.recHook || null, // 九门取证:引擎 call-ID 探针
onReady: o.onReady || null, // 九门取证:收 internals(uncaught)
});
}

View File

@ -6,7 +6,8 @@
* → play(循环外·权威门) → 落 verdict / run-log / run-summary。
*
* 用法(cwd=game-runtime,mini-desktop):
* node tools/amodel-gen/gen.mjs --id run1 --brief "挡板接球打砖块,清屏即胜" [--max-steps 40] [--port 4320] [--cdp 9222]
* spike:node tools/amodel-gen/gen.mjs --id run1 --brief "..." [--max-steps 40] [--port 4320] [--cdp 9222]
* SAA :node tools/amodel-gen/gen.mjs --mode saa --game-id <gameId> --brief "..."(__GameBundle 信封 + stage 到 _wg1-gen/ + done 门含 smoke-boot;stdout 末行 JSON)
*
* 注:本文件是 harness 侧,允许 Date.now/setTimeout(红线只约束游戏 src)。
*/
@ -15,10 +16,10 @@
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { scaffold, readFileTool, writeFileTool, listDirTool, check, build, gameDir } from './tools.mjs';
import { scaffold, scaffoldSaa, readFileTool, writeFileTool, listDirTool, check, build, buildSaa, stage, gameDir } from './tools.mjs';
import { chat, MODEL } from './m3.mjs';
import { TOOLS, buildSystemPrompt } from './prompt.mjs';
import { play } from './play.mjs';
import { play, smokeBoot } from './play.mjs';
function arg(name, def) {
const i = process.argv.indexOf(`--${name}`);
@ -51,11 +52,17 @@ function compactHistory(messages, keepLast = 8) {
}
async function main() {
const id = arg('id', 'run1');
const mode = arg('mode', 'spike'); // 'spike'(默认,_template/__gameBooted 信封)| 'saa'(__GameBundle 信封 + stage 到 _wg1-gen)
const isSaa = mode === 'saa';
const id = isSaa ? arg('game-id', arg('id', 'run1')) : arg('id', 'run1');
const brief = arg('brief', '一个简单的点击得分小游戏');
const maxSteps = Number(arg('max-steps', '40'));
const maxSteps = Number(arg('max-steps', '45')); // saae4 实测需 41 步(含 1 次循环内自恢复)→ 默认 45,别压到 30 误杀正常 run
const maxWallMs = Number(arg('max-wall-ms', '720000')); // 单次 attempt 墙钟上限 12min:bound thrash(saae3 thrash 1646s)而不误杀正常(saae4 446s);超时失败 → 交批 B SAA repair fresh 重试
const port = Number(arg('port', '4320'));
const cdpPort = Number(arg('cdp', '9222'));
// 模式分派:SAA 用 __GameBundle 信封 + buildSaa + stage + smoke;spike 用 _template 信封 + build + play。
const doScaffold = isSaa ? () => scaffoldSaa(id) : () => scaffold(id);
const doBuild = isSaa ? () => buildSaa(id) : () => build(id);
const log = [];
const t0 = Date.now();
@ -65,9 +72,9 @@ async function main() {
log.push(line);
};
rec(`model=${MODEL} id=${id} brief=「${brief}」maxSteps=${maxSteps}`);
rec(`scaffold amgen-${id}(clone _template)`);
scaffold(id);
rec(`model=${MODEL} mode=${mode} id=${id} brief=「${brief}」maxSteps=${maxSteps}`);
rec(`scaffold amgen-${id}(clone _template${isSaa ? ' + SAA 信封' : ''})`);
doScaffold();
const messages = [
{ role: 'system', content: buildSystemPrompt(id) },
@ -79,11 +86,13 @@ async function main() {
let doneSummary = null;
let finished = false;
let nudgedDone = false;
let lastSmoke = null;
let steps = 0;
let totalTokens = 0;
const callSig = new Map(); // loop-detection:同一 (name,args) 计数
while (!finished && steps < maxSteps) {
if (Date.now() - t0 > maxWallMs) { rec(`wall-time cap ${Math.round(maxWallMs / 1000)}s 超时,截停(防 thrash;失败交 repair fresh 重试)`); break; }
steps++;
compactHistory(messages); // 每轮先压旧大块,控 token
let resp;
@ -129,24 +138,36 @@ async function main() {
result = lastCheck.ok ? 'PASS' : 'FAIL:\n' + lastCheck.errors.join('\n');
rec(`check ${lastCheck.ok ? 'PASS' : 'FAIL(' + lastCheck.errors.length + ')'}`);
} else if (name === 'build') {
lastBuild = build(id);
lastBuild = doBuild();
result = lastBuild.ok ? 'PASS' : 'FAIL:\n' + lastBuild.error;
rec(`build ${lastBuild.ok ? 'PASS' : 'FAIL'}`);
} else if (name === 'done') {
// done 门:复核 check+build 必须都绿,否则拒绝并回喂
// done 门:复核 check+build 必须都绿;SAA 模式再加 stage + 同进程 smoke-boot(治 review HIGH-1:boot 失败挡在 generate 内,不漏到 SAA play)。
const c2 = check(id);
const b2 = c2.ok ? build(id) : { ok: false, error: 'check 未过,先修 check' };
const b2 = c2.ok ? doBuild() : { ok: false, error: 'check 未过,先修 check' };
lastCheck = c2; if (c2.ok) lastBuild = b2;
if (c2.ok && b2.ok) {
let gate = c2.ok && b2.ok;
let gateMsg = `check=${c2.ok ? 'PASS' : 'FAIL: ' + c2.errors.join('; ')} build=${b2.ok ? 'PASS' : 'FAIL: ' + (b2.error || '')}`;
if (gate && isSaa) {
const st = stage(id);
if (!st.ok) { gate = false; gateMsg += ` stage=FAIL: ${st.error}`; }
else {
rec('done 门:stage OK → smoke-boot…');
lastSmoke = await smokeBoot(id, { port, cdpPort });
if (!lastSmoke.ok) { gate = false; gateMsg += ` smoke=FAIL(booted=${lastSmoke.booted} framesAdvanced=${lastSmoke.framesAdvanced} err=${lastSmoke.bootErr || '-'})`; }
else { gateMsg += ' stage=OK smoke=PASS'; }
}
}
if (gate) {
doneSummary = a.summary || '';
result = 'DONE 已接受';
result = 'DONE 已接受' + (isSaa ? '(已 stage + smoke-boot 绿)' : '');
rec(`done 接受: ${doneSummary}`);
messages.push({ role: 'tool', tool_call_id: c.id, content: result });
finished = true;
break;
}
result = `DONE 被拒:done 前 check+build 必须都绿。check=${c2.ok ? 'PASS' : 'FAIL: ' + c2.errors.join('; ')} build=${b2.ok ? 'PASS' : 'FAIL: ' + (b2.error || '')}`;
rec('done 被拒(check/build 未全绿)');
result = `DONE 被拒:${isSaa ? 'done 前 check+build+stage+smoke 必须都绿' : 'done 前 check+build 必须都绿'}。${gateMsg}`;
rec('done 被拒(' + gateMsg + ')');
} else {
result = 'ERROR: 未知工具 ' + name;
}
@ -167,9 +188,9 @@ async function main() {
}
}
// 循环外·权威门:play(仅在 agent 合格 done 后跑)
// 收口:SAA 模式已在 done 门 stage+smoke(SAA play 九门是 batch B 的 Java 节点职责,本 harness 不跑);spike 模式跑循环外真玩门。
let verdict = null;
if (finished) {
if (finished && !isSaa) {
rec('play(循环外·权威门:boot+截图+交互 = 能跑能玩)...');
try {
verdict = await play(id, { port, cdpPort });
@ -177,15 +198,19 @@ async function main() {
} catch (e) {
rec('play 异常: ' + (e.message || e));
}
} else if (finished && isSaa) {
rec(`SAA 模式收口:已 stage → games/_wg1-gen/${id}/ + smoke-boot 绿;SAA play 九门交 batch B Java 节点。`);
} else {
rec('未合格 done,跳过 play(本 run 未产出可玩游戏)');
rec('未合格 done,跳过收口(本 run 未产出合格游戏)');
}
const summary = {
id, brief, model: MODEL, steps, finished, doneSummary,
id, mode, brief, model: MODEL, steps, finished, doneSummary,
check: lastCheck ? { ok: lastCheck.ok, errors: lastCheck.errors } : null,
build: lastBuild ? { ok: lastBuild.ok } : null,
smoke: lastSmoke ? { ok: lastSmoke.ok, booted: lastSmoke.booted, framesAdvanced: lastSmoke.framesAdvanced, bootErr: lastSmoke.bootErr } : null,
verdict: verdict ? { pass: verdict.pass, notes: verdict.notes, error: verdict.error || null } : null,
stagedDir: isSaa && finished ? `games/_wg1-gen/${id}` : null,
approxTokens: totalTokens,
wallSec: Number(((Date.now() - t0) / 1000).toFixed(1)),
};
@ -195,6 +220,10 @@ async function main() {
writeFileSync(join(evDir, 'run-summary.json'), JSON.stringify(summary, null, 2));
console.log('\n=== RUN SUMMARY ===');
console.log(JSON.stringify(summary, null, 2));
// SAA↔harness CLI 契约(§5):stdout 末行单行 JSON,SAA generate 节点专用 runner 解析。
if (isSaa) {
console.log(JSON.stringify({ ok: !!finished, gameId: id, stagedDir: finished ? `games/_wg1-gen/${id}` : null, fail: finished ? null : 'agent 未合格 done' }));
}
}
main().catch((e) => { console.error('gen.mjs 顶层异常:', e); process.exit(1); });

View File

@ -174,16 +174,70 @@ export async function play(id, opts = {}) {
return verdict;
}
// ───────────────────────── CLI ─────────────────────────
// 用法:node play.mjs <id> [port] [cdpPort]
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const [, , id, port, cdp] = process.argv;
if (!id) { console.error('用法:node play.mjs <id> [port] [cdpPort]'); process.exit(2); }
play(id, { port: port ? Number(port) : undefined, cdpPort: cdp ? Number(cdp) : undefined })
.then((v) => {
console.log('[play]', v.pass ? 'PASS(能跑)' : 'FAIL', '—', v.notes.join(' | '));
if (v.error) console.log(' error:', v.error);
console.log(' evidence:', join(gameDir(id), 'evidence'));
process.exit(v.pass ? 0 : 1);
});
/**
* smokeBoot —— 轻量同进程"能起来"检(boot + 帧推进,无九门/截图/tap)。
* owner:amodel-gen 批A | 用途:① SAA-mode done 门(治 review HIGH-1:--no-play 后仍在 generate 内挡住 boot 失败)
* ② 控制验证(证 SAA 信封 + stage 真通 B1/B2)。读 SAA 信封标记 __genBooted/__genBootError/__genHost(非 spike 的 __gameBooted)。
* @param {string} gameId stage 后的游戏(读 games/_wg1-gen/<gameId>/index.html)
* @returns {Promise<{ok,booted,bootErr,framesAdvanced,f0,f1}>}
*/
export async function smokeBoot(gameId, opts = {}) {
const port = opts.port || 4320, cdpPort = opts.cdpPort || 9222;
const url = `http://localhost:${port}/games/_wg1-gen/${gameId}/index.html`;
const userDataDir = `/tmp/amgen-chrome-smoke-${gameId}`;
const out = { gameId, url, ok: false, booted: false, bootErr: null, framesAdvanced: false, f0: -1, f1: -1 };
const server = startServer(port);
const chrome = startChrome(cdpPort, userDataDir);
let cdp;
try {
await sleep(900);
cdp = await connectCDP(await getWsUrl(cdpPort, 15000));
await cdp.send('Page.enable');
await cdp.send('Runtime.enable');
await cdp.send('Page.navigate', { url });
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
const st = await cdp.evalJs('({ b: window.__genBooted, e: window.__genBootError })').catch(() => null);
if (st && st.b === true) { out.booted = true; break; }
if (st && st.e) { out.bootErr = st.e; break; }
await sleep(400);
}
if (out.booted) {
out.f0 = await cdp.evalJs('window.__genHost ? window.__genHost.frameCount() : -1');
await sleep(1000);
out.f1 = await cdp.evalJs('window.__genHost ? window.__genHost.frameCount() : -1');
out.framesAdvanced = typeof out.f1 === 'number' && out.f1 > out.f0;
}
} catch (e) {
out.bootErr = out.bootErr || String((e && e.message) || e);
} finally {
try { cdp && cdp.ws.close(); } catch {}
try { chrome.kill('SIGKILL'); } catch {}
try { server.kill('SIGKILL'); } catch {}
}
out.ok = out.booted && out.framesAdvanced;
return out;
}
// ───────────────────────── CLI ─────────────────────────
// 用法:node play.mjs <id> [port] [cdp] —— spike 信封真玩(__gameBooted)
// node play.mjs smoke <gameId> [port] [cdp] —— SAA 信封 smoke-boot(__genBooted)
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
if (args[0] === 'smoke') {
const [, id, port, cdp] = args;
if (!id) { console.error('用法:node play.mjs smoke <gameId> [port] [cdp]'); process.exit(2); }
smokeBoot(id, { port: port ? Number(port) : undefined, cdpPort: cdp ? Number(cdp) : undefined })
.then((o) => { console.log('[smoke]', o.ok ? 'PASS' : 'FAIL', JSON.stringify(o)); process.exit(o.ok ? 0 : 1); });
} else {
const [id, port, cdp] = args;
if (!id) { console.error('用法:node play.mjs <id> [port] [cdp] | smoke <gameId> [port] [cdp]'); process.exit(2); }
play(id, { port: port ? Number(port) : undefined, cdpPort: cdp ? Number(cdp) : undefined })
.then((v) => {
console.log('[play]', v.pass ? 'PASS(能跑)' : 'FAIL', '—', v.notes.join(' | '));
if (v.error) console.log(' error:', v.error);
console.log(' evidence:', join(gameDir(id), 'evidence'));
process.exit(v.pass ? 0 : 1);
});
}
}

View File

@ -83,6 +83,8 @@ A 读手册(1-2) → B 按 brief 定玩法(核心循环 / 胜负 / 计分 / 数
- game.js 默认导出工厂、**零引擎 import**;host-config.js **保留导出名 buildTemplateHostConfig**(只改插件集/wiring,别改函数名)。
- 游戏源(game/core/render)**零裸** Math.random / Date.now / setTimeout / requestAnimationFrame / new AudioContext / addEventListener —— 时间随机经 boot.ctx,定时经 timer-scheduler,输入经 ctx.getInput。
- game.js 的 update(dt) 内**必须调 opts.runtime.bundle.tick(dt)**(否则插件 onFrame 不推进、timer 永不到期、一局不结束)。
- **受控面 ctx 在实例 init(boot) 入参的 boot.ctx**(nowMs/random/getInput/getContext2d 全经它);**工厂入参 opts 只有 runtime:{plugins,bundle}——别在 opts 上找 ctx**(常见错:写成 opts.ctx)。五法 = init(boot)/update(dt)/render(g)/destroy()(+可选 _forensicsView)。
- **插件方法名一律以 api.d.ts 为准、别按常见库套路臆测**(M3 实测幻觉:scene-fsm 写成 defineScenes〔应 define〕)。拿不准先 read 该插件 api.d.ts;skill「⚠️ M3 实测易犯的幻觉 API」一节逐条列了 ❌→✅。
- 插件由 host 拥有、经 opts.runtime.plugins 注入;游戏只**用**、不 new、不 register;插件键名与 host-config 的 plugins 对象一致。
【完成判据 + 停机纪律(重要)】

View File

@ -19,7 +19,7 @@
import {
readFileSync, writeFileSync, readdirSync, statSync,
mkdirSync, existsSync, rmSync, cpSync,
mkdirSync, existsSync, rmSync, cpSync, renameSync,
} from 'node:fs';
import { resolve, dirname, relative, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -31,6 +31,8 @@ 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
@ -217,12 +219,69 @@ export function build(id) {
return { ok: true };
}
// ───────────────────────── SAA 模式(批 A:信封对齐 __GameBundle + stage 到 _wg1-gen)─────────────────────────
/** SAA stage 目标目录(_wg1-gen/<gameId>/;SAA play/emit 既有读盘契约,治 review B1)。 */
export function saaGameDir(gameId) {
return join(WG1_DIR, gameId);
}
/**
* scaffoldSaa:clone _template → amgen-<gameId>/,换上 A-model SAA 信封 plumbing(治 review B2)。
* - entry-bundle.js = entry-bundle.amodel.template.js(导出 bootGameHost,经 --global-name=__GameBundle 挂 window.__GameBundle)
* - index.html = _wg1-gen/_shared/index.template.html(调 __GameBundle.bootGameHost,置 __genBooted)
* - 去 spike 入口(entry.js/src/main.js;SAA 不用)+ dist/test
* agent 仍只写 src/{game,core,render,host-config,balance,assets}.js(与 spike 同;信封差异全在 plumbing)。
*/
export function scaffoldSaa(gameId) {
const gd = gameDir(gameId);
if (existsSync(gd)) rmSync(gd, { recursive: true, force: true });
cpSync(TEMPLATE_DIR, gd, { recursive: true });
rmSync(join(gd, 'dist'), { recursive: true, force: true });
rmSync(join(gd, 'test'), { recursive: true, force: true });
// 换 SAA 信封 plumbing(覆盖 _template 的 spike 信封 entry.js/index.html)
cpSync(join(SHARED_DIR, 'entry-bundle.amodel.template.js'), join(gd, 'entry-bundle.js'));
cpSync(join(SHARED_DIR, 'index.template.html'), join(gd, 'index.html'));
// 去 spike 入口(SAA 用 entry-bundle.js;entry.js/main.js 不参与构建,删之免混淆)
rmSync(join(gd, 'entry.js'), { force: true });
rmSync(join(gd, 'src', 'main.js'), { force: true });
return gd;
}
/** buildSaa:esbuild 打 entry-bundle.js → bundle.iife.js(--global-name=__GameBundle)。 */
export function buildSaa(gameId) {
const entry = `games/amgen-${gameId}/entry-bundle.js`;
const out = `games/amgen-${gameId}/bundle.iife.js`;
const r = spawnSync('node', ['scripts/build.mjs', entry, out, '--global-name=__GameBundle'],
{ cwd: GAME_RUNTIME, encoding: 'utf8', timeout: 60000 });
if (r.status !== 0) {
const err = ((r.stderr || '') + (r.stdout || '')).trim().split('\n').slice(-8).join('\n');
return { ok: false, error: err || `buildSaa 退出码 ${r.status}` };
}
return { ok: true };
}
/** stage:原子(写 tmp→rename)把自包含 bundle + SAA index 拷到 _wg1-gen/<gameId>/(SAA play/emit 读这里;play-spec 由 SAA 产、本步不碰)。 */
export function stage(gameId) {
const gd = gameDir(gameId);
const dst = saaGameDir(gameId);
mkdirSync(dst, { recursive: true });
for (const f of ['bundle.iife.js', 'index.html']) {
const src = join(gd, f);
if (!existsSync(src)) return { ok: false, error: `stage 缺源 ${f}(先 buildSaa)` };
const tmp = join(dst, f + '.tmp');
cpSync(src, tmp);
renameSync(tmp, join(dst, f)); // 原子 rename,防 play 读半成品
}
return { ok: true, dir: dst };
}
// ───────────────────────── CLI(独立验证用)─────────────────────────
// 用法:node tools.mjs scaffold <id> | check <id> | build <id>
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const [, , cmd, id] = process.argv;
if (!cmd || !id) {
console.error('用法:node tools.mjs <scaffold|check|build> <id>');
console.error('用法:node tools.mjs <scaffold|check|build|scaffold-saa|build-saa|stage> <id>');
process.exit(2);
}
if (cmd === 'scaffold') {
@ -237,6 +296,17 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
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);