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:
parent
3b425c4294
commit
cd2903baab
@ -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. 资产:美术/音乐/音效放哪 + 改图去哪找
|
||||
|
||||
@ -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)
|
||||
});
|
||||
}
|
||||
@ -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); });
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 对象一致。
|
||||
|
||||
【完成判据 + 停机纪律(重要)】
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user