feat(amodel): 运行时日志基建(ctx.log+插件自带+harness捕获) + 九门A(play-spec透传)+B2(F_wiring) + 预算

创始人指令:游戏要有日志,读日志诊断运行时问题,模板+可选插件自带日志。

日志基建:
- plugin.js:受控面 ctx 加 log(tag,...args) → window.__gameLog 环形(800)+ console;用受控 time.nowMs。
- boot-game-host.js:B2 插件 Proxy wrap 顺手记插件语义调用进 __gameLog(跳 draw/update/current/get 等高频)→ 插件自带日志免逐个改;recHook 仍记 __engineCalls 供 F_wiring。
- _template game-logic.js:ctx.log 生命周期(init/scene play/over)→ 模板自带。
- play.mjs smokeBoot:抓 __gameLog 写 staged evidence/game-log.json + 末12条进 out.logTail(boot 成败都抓,失败时最有诊断价值)。
- 实证:scaffold→smoke logtest → logCount:165、game-log.json 有 [p:sceneFsm:define/start]+[game:init];读日志诊断可用。

九门对齐(C 之 A+B2,recon ad3c564):
- A(SaaGenNodes validateAmodelStaged):amodel 路 validate-ok→play 短路了 scaffoldNode(唯一写 play-spec.json 处)→ 补写 play-spec.json(从 design 产 K_PLAY_SPEC)→ 九门可驱动(治 E_live/G_input/H_progress 机制)。
- B2(boot-game-host):插件 recHook wrap → F_wiring callCount>0(实证 e2e 从 [E_live,F_wiring] → [E_live,H_progress],F_wiring 已过)。叠加式,factory/gamedef 零回归。

预算(gen.mjs):maxSteps 45→70 / maxWallMs 12→20min —— SAA design 富化 brief 比 raw(7步)需更多步。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-22 22:21:29 -07:00
parent 32fd756f3f
commit db3c42c5de
6 changed files with 95 additions and 3 deletions

View File

@ -449,6 +449,19 @@ final class SaaGenNodes {
} catch (IOException e) {
errs.add("amodel staged 产物读取失败:" + e.getMessage());
}
// ③ 补写 play-spec.json(治九门 E_live/G_input/H_progress):amodel 路由 validate-ok→play 短路了 scaffoldNode
// (唯一写 play-spec.json 处),九门 play.cdp.cjs 读不到 spec → 用空默认 → 不驱动游戏(停 menu→E_live distinctStates:1)、
// 不跑 assertAfterPlay(H_progress skip)。故在此从 K_PLAY_SPEC(design 节点产 gatespec merge 后的 playSpec)补落盘,
// 与 scaffoldNode 同语义。harness 只 stage bundle+index、不知 gatespec,故由 Java 侧补。
if (errs.isEmpty()) {
try {
Path spec = gameRuntimeRoot.resolve("games").resolve("_wg1-gen").resolve(gameId).resolve("play-spec.json");
String playSpec = s.value(SaaStudioNodes.K_PLAY_SPEC, String.class).orElse("{}");
Files.write(spec, playSpec.getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
errs.add("amodel play-spec.json 写入失败:" + e.getMessage());
}
}
return validateOut(s, errs);
}

View File

@ -79,8 +79,10 @@ export function createGame({ plugins, bundle, viewport }) {
roundTimer = timerScheduler.after(ROUND_MS, () => {
sessionScore.lose();
sceneFsm.transition(SCENE_OVER);
if (ctx) ctx.log('scene', 'over 结算', { score: sessionScore.getScore(), best: sessionScore.getBest() });
});
sceneFsm.transition(SCENE_PLAY);
if (ctx) ctx.log('scene', 'play 开始', { roundMs: ROUND_MS });
}
/** 处理一次"点屏"(play 期命中判定;menu/over 期点按钮)。 */
@ -131,6 +133,7 @@ export function createGame({ plugins, bundle, viewport }) {
defineScenes();
sceneFsm.start(SCENE_MENU);
inited = true;
ctx.log('init', 'game-logic 就绪 menu', { vw: VW, vh: VH }); // 运行时日志:读它诊断(boot/scene/score/err 都记)
},
/** 逻辑步:驱动插件 onFrame(必调 bundle.tick)+ 场景 update + 倒计时显示递减。 */

View File

@ -490,6 +490,28 @@ function createHostDevContext(opts) {
// lazy 受控引擎能力面(共享单例,T1b-β);host-dev 无工厂返回 null 并告警一次(插件须容错降级)。
return getEngineShared();
},
/**
* 受控运行时日志(可观测红线:让"读日志诊断运行时问题"成立)。写 window.__gameLog 环形缓冲 + console 镜像。
* 游戏/插件经 ctx.log(tag, ...args) 记语义事件(boot/scene/score/spawn/err 等);harness 把 __gameLog dump 进 evidence 供离线读。
* 时间用受控 time.nowMs(不裸 Date.now);node/无 window 时仅 console;失败静默(日志绝不影响玩法)。
* @param {string} tag 事件标签(如 'init'/'scene'/'score'/'err')
* @param {...any} args 附加信息(对象自动 JSON 化)
*/
log(tag, ...args) {
try {
const msg = args.map((a) => {
if (a == null || typeof a !== 'object') return String(a);
try { return JSON.stringify(a); } catch (_) { return String(a); }
}).join(' ');
const entry = { t: (time && time.nowMs) ? time.nowMs() : 0, scope: scope || 'game', tag: String(tag), msg };
if (typeof window !== 'undefined') {
const buf = (window.__gameLog || (window.__gameLog = []));
buf.push(entry);
if (buf.length > 800) buf.shift(); // 环形上限,防无限增长拖垮页面
}
if (typeof console !== 'undefined' && console.log) console.log('[' + entry.scope + ':' + entry.tag + ']', msg);
} catch (_) { /* 日志失败不影响玩法 */ }
},
time,
random,
};

View File

@ -42,6 +42,45 @@ import { createEngineCaps } from '../../host-dev/engine-caps.js';
// 引擎数学门面构造(纯函数,零引擎 import):real 通道用它包装引擎 lerp/smoothStep/Ease。
import { buildEngineMath } from '../../host-dev/engine-math.js';
/**
* B2(治九门 F_wiring 对 3 层架构):给插件实例集包一层 recHook 取证视图(Proxy 透明、保 this 绑定)。
* 每次游戏调某插件的方法 → 记 call-ID `键.方法` 进 recHook(渠道产物 no-op)。新架构游戏经插件 + 2D context 画、
* 多不直调 boot.ctx.getEngine() → 引擎 recHook 记 0;经此包装后,游戏真用能力库即被记录,F_wiring 据 __engineCalls 判真用非自绘。
* 非函数属性原样透传;插件对象/this 不变(v.apply(t))。仅在 opts.recHook 提供时调用(见 bootGameHost 内)。
* @param {Record<string, any>} plugins 原始插件实例集(host 拥有)。
* @param {(id:string)=>void} recHook call-ID 取证钩子。
* @returns {Record<string, any>} 包装后的插件视图(给游戏工厂;host 自身仍用原始集)。
*/
function wrapPluginsRecHook(plugins, recHook) {
const out = {};
for (const key of Object.keys(plugins || {})) {
const p = plugins[key];
if (!p || typeof p !== 'object') { out[key] = p; continue; }
out[key] = new Proxy(p, {
get(t, prop) {
const v = t[prop];
if (typeof v === 'function') {
return (...args) => {
const ps = String(prop);
try { recHook(key + '.' + ps); } catch (_) { /* 取证钩子异常不影响玩法 */ }
// 插件自带日志(语义事件→window.__gameLog;跳过高频绘制/查询 draw*/render*/point*/measure*/get* 防刷屏)。
try {
if (typeof window !== 'undefined' && !/^(draw|render|point|measure|get|is|update|current|tick|step|onFrame|pending)/.test(ps)) {
const b = (window.__gameLog || (window.__gameLog = []));
b.push({ t: 0, scope: 'p:' + key, tag: ps, msg: '' });
if (b.length > 800) b.shift();
}
} catch (_) { /* 日志失败不影响玩法 */ }
return v.apply(t, args);
};
}
return v;
},
});
}
return out;
}
/** 取证固定环境(与 host-dev / browser-evidence FIXED_ENV 对齐;P1 沿用)。 */
const DPR = 2;
const FIXED_DT = 1 / 60;
@ -189,7 +228,11 @@ export async function bootGameHost(opts) {
mark('t_plugins_ready', { initialized: initRes.initialized.length, ok: initRes.ok });
// 4) 经装载契约建游戏实例(host 经 buildFactoryOpts 注入 wiring:wanglanmei runtime / P3 纯净工厂为 undefined)。
const factoryOpts = buildFactoryOpts(bundle.context, { bundle, plugins: pluginInstances, getRealAudioCtx: () => realAudioCtx });
// B2(治九门 F_wiring 对 3 层架构):新架构游戏经插件 + 2D context 画、多不直调 getEngine,故引擎 recHook 记不到 →
// 给「游戏的插件视图」用 Proxy 包一层 recHook(每次游戏调插件方法记 call-ID=键.方法 写 __engineCalls,证真用能力库非自绘伪装)。
// 仅 opts.recHook 提供时包(测试/取证态);渠道产物 recHook=no-op→传原始实例零开销。host 自身 register/init/onFrame 用原始 pluginInstances,不受影响。
const pluginsForGame = opts.recHook ? wrapPluginsRecHook(pluginInstances, recHook) : pluginInstances;
const factoryOpts = buildFactoryOpts(bundle.context, { bundle, plugins: pluginsForGame, getRealAudioCtx: () => realAudioCtx });
game = factory(factoryOpts);
// 5) game.init:建游戏对象 + 异步装资产 + 订阅受控输入。boot=装载契约脊柱(ctx/mainContext/canvas/seed)。
// HOST TODO(plan 2026-06-19-001 U1 剩余 wiring):U1 已建运行时 sprite 渲染 + boot.assets 契约(按 asset.ref 键),

View File

@ -56,8 +56,8 @@ async function main() {
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', '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 maxSteps = Number(arg('max-steps', '70')); // 默认 70:raw brief 7 步(3层架构 newarch1),但 SAA design 富化 brief(juice/音效/屏震/gatespec)需更多步;留余量
const maxWallMs = Number(arg('max-wall-ms', '1200000')); // 单次 attempt 墙钟上限 20min:design 富化 brief + M3 大上下文 turn 慢,raw 102s 但富化需更久;bound thrash 而不误杀富化正常 run
const port = Number(arg('port', '4320'));
const cdpPort = Number(arg('cdp', '9222'));
// 模式分派:SAA 用 __GameBundle 信封 + buildSaa + stage + smoke;spike 用 _template 信封 + build + play。

View File

@ -208,6 +208,17 @@ export async function smokeBoot(gameId, opts = {}) {
out.f1 = await cdp.evalJs('window.__genHost ? window.__genHost.frameCount() : -1');
out.framesAdvanced = typeof out.f1 === 'number' && out.f1 > out.f0;
}
// 抓运行时日志(window.__gameLog:ctx.log 游戏语义 + 插件自带日志)→ 写 staged evidence + 末段进 out。
// 无论 boot 成败都抓(boot 失败时日志最有诊断价值)。让"读日志诊断运行时问题"成立。
try {
const logJson = await cdp.evalJs('JSON.stringify(window.__gameLog||[])').catch(() => '[]');
const arr = JSON.parse(logJson || '[]');
out.logCount = arr.length;
out.logTail = arr.slice(-12).map((e) => `[${e.scope}:${e.tag}]${e.msg ? ' ' + e.msg : ''}`);
const evDir = join(GAME_RUNTIME, 'games', '_wg1-gen', gameId, 'evidence');
mkdirSync(evDir, { recursive: true });
writeFileSync(join(evDir, 'game-log.json'), JSON.stringify(arr, null, 2));
} catch (_) { /* 日志捕获失败不影响 smoke 判定 */ }
} catch (e) {
out.bootErr = out.bootErr || String((e && e.message) || e);
} finally {