feat(amodel-gen): 根治B(input→L1 handleTap)+driver起局扫点+5 footgun蒸馏+wanglanmei E2E→studio可玩

根治 B(缩 M3 错面,结构性根除"点击在错 phase 消费/永不消费→启动不了"):
- input 订阅收归 L1(_template/src/game.js wrapper):L3 只写实例方法 handleTap(x,y)/handleKey(key),
  L1 在 pointerdown/keydown 直达派发;_template/src/game-logic.js 范例改用 handleTap。
- tools.mjs check:拦 ctx.getInput + 必须暴露 ≥1 输入方法;prompt.mjs/README/skill 同步输入契约。
- 机制+门双实证(node 全链路:menu→点开始→play、点客加分、点空白不加分;check 对 getInput/零输入方法拦截)。

driver(play.cdp.cjs):occupied 族起局从固定点 (195,422) 改中线纵向扫点,robust 命中各游戏「开始」按钮
  (原固定点落空→死菜单假阴;实测 wanglanmei「开门营业」在 y≈502)。

资产通道(boot-game-host.loadHostAssets + gen.mjs --assets-dir):M3 只决定怎么用资产、不造。
入库工具 publish-amgen.mjs:staged bundle → game_runtime_package(package_json.engineBundle,status=1)→ studio /play。

5 个 M3 footgun 蒸馏进 prompt.mjs + skill(均 wanglanmei-v2 实测翻车):
  ① scene update 回调签名 (dt) 非 (running,dt)  ② ctx.random.next() 非 ctx.random()
  ③ ctx.time.nowMs() 非 ctx.nowMs()  ④ _forensicsView 的 phase/targets 必须在 state() 内实时算(否则定格 boot 快照)
  ⑤ update(dt) 的 dt 是秒,毫秒计时要 ×1000。

验证:M3 生成 wanglanmei-tycoon-v2(+上述 5 footgun 修复)→ 九门 PASS(score 0→364,distinct 43/43)
  → 入库 → studio /play/80002/80002 加载+boot+渲染菜单真玩(art 走程序化回退,水彩 art 需引擎资产线 E2/E3)。
  (root-fix A 门可信化先于 cbe42933 提交;SAA Java 驱动绑定另track 暂不入此 commit)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-23 05:48:03 -07:00
parent cbe42933d0
commit 88d3a23787
11 changed files with 310 additions and 39 deletions

View File

@ -12,8 +12,8 @@
## 0. 三句话心法
1. **你只写「玩法身份(WHAT)」,通用机制(HOW)一律调插件——能调就别自己实现。** 写碰撞数学/粒子/补间/计分/状态机 = 错;这些是插件。
2. **你的唯一产出靶 = 一个游戏工厂**:`export default (opts) => GameInstance`(五法 init/update/render/onInput?/destroy)。独立文件、**零引擎 import**。
3. **受控面铁律**:游戏里 **绝不出现** `Date.now` / `performance.now` / `Math.random` / `setTimeout` / `requestAnimationFrame` / `new AudioContext` / `import 'littlejsengine'`。时间/随机/输入/音频/引擎能力一律经 `boot.ctx` 或注入的插件拿(确定性、可取证、引擎可换)。
2. **你的唯一产出靶 = `game-logic.js` 的命名导出 `createGame({plugins,bundle,viewport})`**,返回 GameInstance:`init/update/render/destroy` + 输入方法 `handleTap(x,y)`[/`handleKey(key)`] + `_forensicsView()`。**零引擎 import**。
3. **受控面铁律**:游戏里 **绝不出现** `Date.now` / `performance.now` / `Math.random` / `setTimeout` / `requestAnimationFrame` / `new AudioContext` / `import 'littlejsengine'`。时间/随机/音频/引擎能力一律经 `boot.ctx` 或注入的插件拿;**输入不调 `ctx.getInput`**——写实例方法 `handleTap(x,y)`/`handleKey(key)`,L1 据此在 pointerdown/keydown 时派发(确定性、可取证、引擎可换)。
---
@ -77,12 +77,12 @@ export function createGame({ plugins, bundle, viewport }) {
return {
async init(boot) {
this.ctx = boot.ctx; // 受控面:getInput/time/random/getEngine(只在 init(boot) 拿)
this.ctx = boot.ctx; // 受控面:time/random/getEngine/log(只在 init(boot) 拿;输入不在此订阅)
this.assets = await loadAssets('./assets/'); // 容错:缺图降级不崩
boot.ctx.getInput().on('pointerdown', (e) => core.tap(e.x, e.y));
},
update(dt) { bundle.tick(dt); core.update(dt); }, // ★ 必调 bundle.tick(驱动插件 onFrame)
render(g) { drawFrame(g, core.state(), this.assets, hudUi, viewport); }, // g=引擎 mainContext;读 viewport.w/h
handleTap(x, y) { core.tap(x, y); }, // ★输入入口:L1 在 pointerdown 时直达调用(别自己订阅输入)
destroy() {}, // 卸载(幂等)
};
}
@ -92,6 +92,7 @@ export function createGame({ plugins, bundle, viewport }) {
- **render(g) 的 `g` = `boot.mainContext`**(引擎绘制面);绘制画在 `g` 上,**不要**用插件的 `getContext2d()`(那是另一块 overlay)。
- **插件扁平注入:直接 `const { sceneFsm, … } = plugins`**(`plugins` 是 createGame 入参;**不是 `opts.runtime.plugins`** —— 那是 L1 内部的、你见不到,写了就 undefined → boot 崩)。游戏只「用」,不 `new`、不 `register`。
- 时间/随机/引擎走 **`boot.ctx`**(`init(boot)` 内拿;入参里没有 ctx)。
- **输入收归 L1(别自己订阅)**:不写 `ctx.getInput()`、不自建点击队列、不在 `update` 里挑时机消费点击——只写实例方法 **`handleTap(x,y)`**(点击)/**`handleKey(key)`**(键盘),L1 的 game.js wrapper 在 pointerdown/keydown 时**直达**调用。〔HJ-AGI 实测:M3 把点击塞 `pendingClicks`、在 `update` 的 play 分支(menu early-return 之后)才消费 → 菜单点击永不处理 → 启动不了;收归 L1 结构性根除。check 会拦 `getInput` 与"零输入方法"。〕
- **⚠️ 驱动插件 onFrame**:`update(dt)` 内**必须 `bundle.tick(dt)`**(`bundle` 是 createGame 入参)——否则 timer-scheduler/gamefeel 等的 onFrame 不推进、timer 永不到期、一局不结束。
- **viewport**:画面口径读入参 `viewport.w/h`,别硬编码 390×844。
- **取证面(`_forensicsView()` 必须实现,可测性红线)**:实例必须挂 `_forensicsView()`→`{ state(), measures() }`;`state()` 导出可观测态——至少 `{ phase, score }`,有可点目标则加 `targets:[{x,y,occupied}]`(九门据此验「真活+有进展」+ 驱动)。**不写则 check 拦 + 九门 E_live/H_progress 读不到状态**。另可选挂 `_handleAction(action)`(host.do() 走逻辑驱动,便于确定性 e2e)。范式见 `_template/src/game-logic.js`。
@ -132,7 +133,10 @@ export function createGame({ plugins, bundle, viewport }) {
> **铁律:插件方法名一律以 `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**。
- ❌ scene 的 update 回调臆造首参写成 `update(running, dt)` → ✅ **scene 的 `update` 回调只收一个参数 `(dt)`**(`sceneFsm.define(name, { update(dt){…} })`;`sceneFsm.update(dt)` 只透传 dt,没有 running 之类首参)。写错首参 → 真正的 dt 落到第二参=undefined → `state.elapsedMs += undefined = NaN` → 不再 spawn/计时、计时显示「NaNs」、游戏空转看着像没 bug 实则永不推进(实测 wanglanmei-v2:菜单能进 play 但无顾客、营业额恒 0)。**任何回调签名都以 `src/plugins/<name>/api.d.ts` 为准,别臆造参数。**
- ❌ `update(dt)` 把 dt 当毫秒直接累加 `elapsedMs += dt` → ✅ **dt 单位是「秒」(≈1/60)**;游戏内毫秒计时(倒计时/spawn 间隔/耐心,常量如 60000/1300/4800)累加要 **`elapsedMs += dt * 1000`**。漏 ×1000 → 计时慢 1000 倍 → 永不到 spawn 阈值=零顾客、一局永不结束(实测 wanglanmei-v2 翻车点;`_template` 范例里 `remainMs - dt*1000` 就是这个换算)。
- ❌ `_forensicsView()` 在**外层**先算好 phase/targets 再闭包返回(`const phase=...; return { state(){ return { phase } } }`)→ ✅ **phase/targets/score 必须在 `state()` 函数体内部实时计算**(host 只在 boot 调一次 `_forensicsView()`,外层算的值定格成 boot 快照=永远 menu、targets 永远空 → 自动验收/驱动看不到目标、永判死菜单。实测 wanglanmei-v2:游戏真在 play 但门读到的 phase 恒 menu)。照 `_template/src/game-logic.js` 的 `state: () => ({ phase: sceneFsm.current(), ... })` 写。
- ❌ 在 game-logic.js 取受控面用 `opts.ctx` / `opts.boot` → ✅ 受控面在**实例 `init(boot)` 的 `boot.ctx`**(`boot.ctx.time` / `boot.ctx.random` / `boot.ctx.getEngine()` / `boot.ctx.log()`);**输入不经 `boot.ctx.getInput`——写实例方法 `handleTap(x,y)`/`handleKey(key)`,L1 派发**;`createGame` 入参是扁平 `{plugins,bundle,viewport}`,**没有 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**。
- ⚠️ **`host-config.js` 和 `game.js` 都是 L1 固定 plumbing,你不写**(amodel harness `write_file` 会拒)——**全 11 插件**(sceneFsm/sessionScore/hudUi/timerScheduler/save/gamefeel/juice/palettePost/audioMusic/collision/physics)+ viewport(390×844)+ 工厂 wiring 已替你配好。**你只写 `game-logic.js`(必)+ core/render/balance/assets**。〔历史教训 HJ-AGI-003:曾让 agent 写 host-config + game.js 工厂,M3 反复破坏返回契约(漏 viewport 崩 `reading 'w'`)/ registerOrder / 取插件漏 .plugins 崩 `reading 'define'`,故把 wiring 全收进 L1 固定、L3 只写游戏逻辑收扁平 {plugins,bundle,viewport},结构性根除。〕

View File

@ -18,9 +18,10 @@ export function createGame({ plugins, bundle, viewport }) {
const { sceneFsm, sessionScore, hudUi, timerScheduler } = plugins;
// 可用键:sceneFsm sessionScore hudUi timerScheduler save gamefeel juice palettePost audioMusic collision physics
return {
async init(boot) { const ctx = boot.ctx; /* 装资产→订阅 ctx.getInput→定义场景→start */ },
async init(boot) { const ctx = boot.ctx; /* 装资产→定义场景→start;输入不订阅(L1 派发到 handleTap) */ },
update(dt) { bundle.tick(dt); /* ★必调;再推进玩法 */ },
render(g) { /* 画在 g;读 viewport.w/h */ },
handleTap(x, y) { /* ★输入入口:各 phase 点击逻辑;L1 在 pointerdown 时直达调用(别自己订阅输入) */ },
destroy() {},
// 可选·建议:_forensicsView(){ return { state(){…}, measures(){…} }; }
};
@ -32,7 +33,7 @@ export function createGame({ plugins, bundle, viewport }) {
- **入参扁平 `{plugins, bundle, viewport}`** —— 没有 opts、没有 opts.runtime、没有 ctx(L1 已摊平)。受控面 ctx 只在 `init(boot).boot.ctx`(时间/随机/输入/引擎)。
- **插件直接 `const {…} = plugins`** —— 别写 `opts.runtime.plugins`(L1 内部的,你见不到)。游戏只用、不 new、不 register。
- **`update(dt)` 必调 `bundle.tick(dt)`** —— 否则 timer-scheduler/gamefeel 等 onFrame 不推进、timer 永不到期、一局不结束。
- **零引擎 import**(引擎经 `boot.ctx.getEngine()`);游戏源**零裸** Math.random/Date.now/setTimeout/new AudioContext/addEventListener —— 时间随机经 boot.ctx,定时经 timer-scheduler,音效经 plugins.audioMusic,输入经 ctx.getInput。
- **零引擎 import**(引擎经 `boot.ctx.getEngine()`);游戏源**零裸** Math.random/Date.now/setTimeout/new AudioContext/addEventListener —— 时间随机经 boot.ctx,定时经 timer-scheduler,音效经 plugins.audioMusic;**输入不调 ctx.getInput**——写实例方法 `handleTap(x,y)`(点击)/`handleKey(key)`(键盘),L1 的 game.js wrapper 在 pointerdown/keydown 时直达调用(根治"点击消费时机错→启动不了")。
- **插件方法名一律以 `src/plugins/<name>/api.d.ts` 为准**,别臆测(scene-fsm 是 `define` 不是 defineScenes;particles-juice 没有 `burst`)。
- **画面读 `viewport.w/h`**,别硬编码 390×844。

View File

@ -85,8 +85,11 @@ export function createGame({ plugins, bundle, viewport }) {
if (ctx) ctx.log('scene', 'play 开始', { roundMs: ROUND_MS });
}
/** 处理一次"点屏"(play 期命中判定;menu/over 期点按钮)。 */
function onTap(x, y) {
/**
* 【输入入口·L3 只写这个】处理一次"点屏"(各 phase 自行判定:menu/over 点按钮、play 命中判定)。
* L1(game.js wrapper)在每次 pointerdown 时直达调用本方法 → 你不订阅输入、不自管点击队列 / 消费时机。
*/
function handleTap(x, y) {
measures.taps += 1;
const cur = sceneFsm.current();
if (cur === SCENE_MENU) {
@ -124,12 +127,12 @@ export function createGame({ plugins, bundle, viewport }) {
/* ════════════ GameInstance 五法 ════════════ */
/** @type {import('../../../src/core/game-host.d.ts').GameInstance} */
const instance = {
/** 引擎首帧前一次性:存受控面、装资产、订阅受控输入、注册场景并 start(menu)。 */
/** 引擎首帧前一次性:存受控面、装资产、注册场景并 start(menu)。输入由 L1 派发到 handleTap,本处不订阅。 */
async init(b) {
boot = b;
ctx = b.ctx;
assets = await loadAssets('./assets/'); // 缺图不崩(容错返空集)
ctx.getInput().on('pointerdown', (e) => onTap(e.x, e.y)); // 经 ctx.getInput,不 addEventListener
// 注:输入订阅由 L1(game.js wrapper)接管 —— pointerdown 会直达本实例的 handleTap(x,y);此处不订阅输入。
defineScenes();
sceneFsm.start(SCENE_MENU);
inited = true;
@ -153,10 +156,8 @@ export function createGame({ plugins, bundle, viewport }) {
sceneFsm.render(g);
},
/** 可选输入入口(冗余兜底)。 */
onInput(ev) {
if (ev && ev.type === 'pointerdown') onTap(ev.x || 0, ev.y || 0);
},
/** 【输入方法·L1 据此派发】pointerdown 直达本方法;只写各 phase 的点击逻辑,绝不自订阅输入。 */
handleTap,
/** 卸载卫生(幂等):取消倒计时、清场景。 */
destroy() {
@ -188,7 +189,7 @@ export function createGame({ plugins, bundle, viewport }) {
if (action.type === 'start' && cur === SCENE_MENU) enterPlay();
else if (action.type === 'restart' && cur === SCENE_OVER) enterPlay();
else if (action.type === 'hitTarget' && cur === SCENE_PLAY && target) {
onTap(target.x, target.y);
handleTap(target.x, target.y);
}
},
};

View File

@ -18,15 +18,56 @@ import { createGame as createGameLogic } from './game-logic.js';
/**
* 【装载契约 GameHostFactory】host boot 调本工厂建 GameInstance。
* 摊平 opts.runtime → 交 game-logic 的 createGame({plugins,bundle,viewport})。
* ① 摊平 opts.runtime → 交 game-logic 的 createGame({plugins,bundle,viewport});
* ② 接管「输入→handleTap 派发」这一固定 plumbing(根治 B):init 后订阅受控输入,
* pointerdown 一律直达实例方法 handleTap(x,y)、keydown 直达 handleKey(key)。
* —— L3 的 game-logic.js **只写** handleTap/handleKey 的各 phase 逻辑,**绝不自己订阅输入**
* (不写 ctx.getInput、不自管 pendingClicks 队列、不在 update 里挑时机消费点击)。
* 结构性根除「点击在错的 phase 分支被消费 / 永不消费 → 游戏启动不了」这类 M3 易犯错。
* @param {Object} [opts] 工厂选项(host wiring)。
* @returns {import('../../../src/core/game-host.d.ts').GameInstance}
*/
export default function gameFactory(opts) {
const runtime = (opts && opts.runtime) || {};
return createGameLogic({
// L3 游戏本体实例(game-logic.js 的 createGame;扁平入参,无 opts.runtime 嵌套坑)。
const game = createGameLogic({
plugins: runtime.plugins || {},
bundle: runtime.bundle,
viewport: runtime.viewport || { w: 390, h: 844 }, // viewport 由 L1 host-config 下传;兜底 390×844
});
/* ── L1 输入 plumbing:包装 init/destroy,把「受控输入→实例 handleTap/handleKey」收归固定层 ── */
const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null;
const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null;
/** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */
let inputSubs = [];
game.init = async function init(boot) {
if (innerInit) await innerInit(boot); // 先让 L3 装资产 / 定义场景 / start(menu)
const ctx = boot && boot.ctx;
const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null;
if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩)
// pointerdown → handleTap(x,y):无论游戏当前在哪个 phase 都直达,菜单/结算/玩中分支由 handleTap 内部自行判定。
if (typeof game.handleTap === 'function') {
inputSubs.push(input.on('pointerdown', (e) => {
try { game.handleTap((e && e.x) || 0, (e && e.y) || 0); }
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleTap 抛错(已隔离)', { msg: err && err.message }); } // 错误隔离:不连坐 boot/后续帧
}));
}
// keydown → handleKey(key):方向键 / 空格类玩法用(可选;游戏写了才接)。
if (typeof game.handleKey === 'function') {
inputSubs.push(input.on('keydown', (e) => {
try { game.handleKey((e && e.key) || ''); }
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); }
}));
}
};
game.destroy = function destroy() {
for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } }
inputSubs = [];
if (innerDestroy) innerDestroy();
};
return game;
}

View File

@ -4,7 +4,7 @@
* 用「Proxy 伪 2D 上下文 + window 桩」真跑 bootGame:
* boot(evidence) → menu 点「开始」→ play 模拟「点中目标」→ 断言 session-score 分数递增
* → 驱动满一局(timer-scheduler.after 经 bundle.tick 受控时钟到期)→ 断言切到 over 场景。
* 整条链路走真实 onTap 命中路由 / 真实 scene-fsm 派发 / 真实 timer-scheduler 受控到期 + 每帧 render(g) 全画,
* 整条链路走真实 handleTap 命中路由 / 真实 scene-fsm 派发 / 真实 timer-scheduler 受控到期 + 每帧 render(g) 全画,
* 任何宿主层运行时错误都会在此抛出。像素正确性留集成段浏览器证据(两层测试纪律)。
*
* 【真用到的插件方法(本测覆盖)】
@ -76,7 +76,7 @@ test('无浏览器全链路:boot→开始→点中目标加分→满局到点切
// ① 初始态:menu。
assert.equal(F.state().phase, 'menu', 'boot 后应在 menu 场景');
// ② menu → play:点「开始」按钮(走真实坐标命中:host.tap → 受控桥 → ctx.getInput → onTap → pointInRect)。
// ② menu → play:点「开始」按钮(坐标命中路径:host.tap → 受控桥 → ctx.getInput 订阅者(L1 wrapper)→ handleTap → pointInRect)。
// 开始按钮几何来自 render.centerButtonRect(H*0.56);此处用确定性命令 host.do('start') 走同一逻辑入口,
// 避免依赖 render 内部按钮坐标(do 与 UI 同走 enterPlay,等价)。
host.do({ type: 'start' });
@ -86,7 +86,7 @@ test('无浏览器全链路:boot→开始→点中目标加分→满局到点切
assert.ok(F.state().target, 'play 期应有一个目标圆');
assert.ok(F.state().pendingTimers >= 1, 'play 期应有一局倒计时挂起(timer-scheduler.after)');
// ③ 点中目标若干次:每次用 host.do('hitTarget')(以当前靶圆心走真实 onTap → pointInCircle 命中 → addScore + 换靶)。
// ③ 点中目标若干次:每次用 host.do('hitTarget')(以当前靶圆心走真实 handleTap → pointInCircle 命中 → addScore + 换靶)。
// 断言分数随命中递增 + 目标确实更换(换靶)。
let prevTarget = JSON.stringify(F.state().target);
for (let i = 1; i <= 5; i++) {

View File

@ -226,18 +226,33 @@ async function runDriver(cdp, driver, hashes) {
return runPaddleIntercept(cdp, driver, hashes);
}
/** type='tap-targets':读 state().<targetsPath> 的离散可点目标 {x,y,occupied[,safe]},每步点一个未占用目标,到 gameover 即停(交 latch 校验)。
* 复用于井字棋/扫雷/翻牌/打地鼠/Simon/见缝插针/节奏等「点击离散目标」类游戏。
* 【家族二分】安全放置族(井字棋/invaders…任一未占用目标都是合法推进招)= 盲点首个;
* 规避/推理族(扫雷…部分目标点了立即致负)须配 driver.safeOnly=true:仅点 safe===true 目标、确定性避负把核心循环跑透。 */
/** type='tap-targets':读 state().<targetsPath> 的离散可点目标 {x,y,occupied[,safe]},每步点一个,到 gameover 即停(交 latch 校验)。
* 复用于井字棋/扫雷/翻牌/打地鼠/Simon/见缝插针/节奏/经营服务客等「点击离散目标」类游戏。
* 【家族三分·按 driver.targetMode】
* · 放置族(默认,井字棋/invaders…任一未占用目标都是合法推进招)= 盲点首个 occupied!==true;
* · 反应/服务族(targetMode='occupied',打地鼠/经营服务等待的客)= 只点 occupied===true(冒头/有客才可点),无则空转等下拍;
* · 规避/推理族(扫雷…配 safeOnly=true)= 仅点 safe===true、确定性避负把核心循环跑透。 */
async function runTapTargets(cdp, driver, hashes) {
const targetsPath = driver.targetsPath || 'targets';
const steps = driver.steps || 12;
const stepMs = driver.stepMs != null ? driver.stepMs : 320;
let drove = 0, cyc = 0;
let drove = 0, cyc = 0, menuTapIdx = 0;
for (let i = 0; i < steps; i++) {
const s = await readGameState(cdp);
if (s && s.phase === 'gameover') break; // 终局即停(交 latch 校验)
if (s && (s.phase === 'gameover' || s.phase === 'over' || s.phase === 'win' || s.phase === 'lose')) break; // 终局即停(含 over/win/lose 常见终态名;交 latch 校验)
// 场景推进(反应/服务族):非游玩态(menu/title/paused…)→ 点「开始」按钮推进场景(多数游戏 menu「中心按钮起局」、结算→菜单)。
// 否则停在 menu、targets 恒空 → 永不进 play(实测 wanglanmei/打地鼠菜单卡死根因——driver 只找目标、不会起局)。仅 occupied 族用,不动放置族既有行为。
// 起局按钮位置随游戏而异(实测 wanglanmei「开门营业」在 y≈502,旧固定点 (195,422) 落空→永远进不去)→
// 改为【中线纵向扫点】:每拍换一个候选纵坐标(覆盖常见 0.46H–0.90H 竖屏区,常见按钮区在前),命中按钮即进 play,下拍走目标点击。
if (driver.targetMode === 'occupied' && s && typeof s.phase === 'string' && s.phase !== 'play' && s.phase !== 'playing') {
const CY = [489, 523, 456, 557, 422, 591, 388, 625, 690, 752]; // 390×844 竖屏起局按钮候选纵坐标(常见区在前);x=中线 195
await tap(cdp, 195, CY[menuTapIdx % CY.length]);
menuTapIdx++;
drove++;
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
await delay(stepMs);
continue;
}
const targets = getPath(s, targetsPath);
if (Array.isArray(targets) && targets.length) {
let t;
@ -246,6 +261,10 @@ async function runTapTargets(cdp, driver, hashes) {
// 无安全目标(核心应已揭完→win)即停,交 latch 校验 win 终态;绝不回退点不安全目标(否则又踩雷、核心零暴露)。
t = targets.find((g) => g && g.occupied !== true && g.safe === true && typeof g.x === 'number');
if (!t) break;
} else if (driver.targetMode === 'occupied') {
// 反应/服务族(打地鼠/经营服务客…):只点 occupied===true 的目标(地鼠冒头/顾客在等才可点);
// 本拍无可点(没冒头/没客)→ 不 tap、空转等下一拍(targets 随时变,下拍重采)。与放置族相反语义。
t = targets.find((g) => g && g.occupied === true && typeof g.x === 'number');
} else {
// 安全放置族(井字棋/invaders…):任一未占用目标都是合法推进招,盲点首个;全占用/未导出 occupied → 按序轮点。
t = targets.find((g) => g && g.occupied !== true && typeof g.x === 'number');
@ -980,9 +999,17 @@ async function main() {
// 守卫E:活性 —— 整段真玩出现 ≥2 个不同画面态(真在动/真在响应);全程恒同一帧=冻屏判否。
// Phase 4 校准(2026-06-20):像素活性 OR 状态活性(score/remaining 真变)——治无运动点击类(打地鼠等)像素采样
// 错过命中瞬间的假阴;真冻屏像素+状态皆不变→仍判否(只加 OR、只放宽不收紧,绝不放过真冻屏)。
// 根治 A(创始人「门要可信」2026-06-23):E_live 旧定义 `distinct>=2 || liveByState` 假阳——菜单 blink 动画也让帧哈希变,
// 一个【启动不了的死菜单游戏】(distinctStates=2 全来自 booting→menu + 闪烁)曾过此门。重定义为【真玩起来了】的真不变量:
// played = 真离开非游玩态(进展态真变 liveByState,或玩后 phase 落在游玩态——不在 NONPLAY 集);
// live = played 且 (进展态变 或 驱动期画面活性)。死菜单 played=false → 必挂(帧哈希再怎么闪也救不回)。
const distinct = new Set(hashes);
const liveByState = stateProgressed(s0, s1);
verdict.guards.E_live = { pass: distinct.size >= 2 || liveByState, distinctStates: distinct.size, samples: hashes.length, liveByState };
const NONPLAY = new Set(['booting', 'boot', 'menu', 'title', 'ready', 'home', 'over', 'gameover', 'win', 'lose', 'result', 'settle', 'end', 'paused']);
const phase1 = (s1 && typeof s1.phase === 'string') ? s1.phase : null;
const played = liveByState || (phase1 != null && !NONPLAY.has(phase1)); // 真进过游玩(状态变 或 phase 是游玩态)
const live = played && (liveByState || distinct.size >= 2); // 进过玩 且 有活性(状态变 或 画面动)
verdict.guards.E_live = { pass: live, played, phase1, distinctStates: distinct.size, samples: hashes.length, liveByState };
// 守卫B:未捕获错误。
const unc = await cdp.evaluate('(function(){ var n=window.__genInternals; var u=(n&&n.uncaught)?n.uncaught:[]; return u.slice(0,10); })()');
@ -1015,7 +1042,9 @@ async function main() {
await waitBoot(ctrl, 30000);
const reached = await advanceToFrame(ctrl, endFrame, 20000);
const ch = await brightPixels(ctrl, '#game-engine');
verdict.guards.G_input = { pass: px1.hash !== ch.hash, inputHash: px1.hash, controlHash: ch.hash, atFrame: endFrame, ctrlFrame: reached };
// 根治 A:旧定义只看「输入实例帧哈希 ≠ 无输入对照」——被动画时序差假阳(死菜单 blink 相位差也让哈希不同 → 谎称"输入有效")。
// 加 `&& played`:必须游戏【真响应了】(phase 进过游玩态 或 进展态变),才算输入有效。点击被吞的死菜单 played=false → G 必挂。
verdict.guards.G_input = { pass: (px1.hash !== ch.hash) && played, hashDiffer: px1.hash !== ch.hash, played, inputHash: px1.hash, controlHash: ch.hash, atFrame: endFrame, ctrlFrame: reached };
} catch (e2) {
verdict.guards.G_input = { pass: false, err: String((e2 && e2.message) || e2) };
} finally {

View File

@ -42,6 +42,39 @@ import { createEngineCaps } from '../../host-dev/engine-caps.js';
// 引擎数学门面构造(纯函数,零引擎 import):real 通道用它包装引擎 lerp/smoothStep/Ease。
import { buildEngineMath } from '../../host-dev/engine-math.js';
/**
* 载图契约(补完 plan 2026-06-19-001 U1 剩余 wiring;HOST TODO 落地):浏览器侧 fetch ./assets/manifest.json →
* 并行 new Image 载图 → 注入 boot.assets={ ref:{ image, file, role } },游戏经 boot.assets[ref].image 取图、g.drawImage 绘制。
* 【ref 约定】= manifest 条目 file 去扩展名(shopkeeper-idle.jpg → 'shopkeeper-idle');音频(.mp3 等)不在此载(归 audio 插件懒解码)。
* 【容错铁律(发行版红线 §7-5)】任一图缺失/失败 → 该 ref 置 {image:null,missing:true},绝不阻断 boot;游戏 render 自走程序化回退。
* 【import-safe】node/无 fetch|Image 环境(node smoke-boot)→ 返回 {}(空集),boot 照常,与无美术游戏行为一致。
* @param {string} [baseUrl] 资产根(缺省 './assets/',相对 index.html;play.cdp/studio iframe 同源可达)。
* @returns {Promise<Record<string,{image:HTMLImageElement|null,file:string,role:string,missing?:boolean}>>}
*/
async function loadHostAssets(baseUrl) {
if (typeof fetch !== 'function' || typeof Image !== 'function') return {}; // node/无浏览器 → 空集,smoke-boot 安全
const root = baseUrl == null ? './assets/' : baseUrl;
let manifest = null;
try {
const resp = await fetch(root + 'manifest.json');
if (!resp || !resp.ok) return {};
manifest = await resp.json();
} catch { return {}; } // 无 manifest(无美术游戏)→ 空集
const list = (manifest && Array.isArray(manifest.assets)) ? manifest.assets : [];
/** @type {Record<string,any>} */
const out = {};
await Promise.all(list.map((a) => new Promise((resolve) => {
const file = a && a.file;
if (!file || !/\.(png|jpe?g|webp|gif)$/i.test(file)) { resolve(); return; } // 只载图;音频归 audio 插件
const ref = String(file).replace(/\.[^.]+$/, '');
const img = new Image();
img.onload = () => { out[ref] = { image: img, file, role: (a.role || '') }; resolve(); };
img.onerror = () => { out[ref] = { image: null, file, role: (a.role || ''), missing: true }; resolve(); }; // 容错降级,不连坐
img.src = root + file;
})));
return out;
}
/**
* B2(治九门 F_wiring 对 3 层架构):给插件实例集包一层 recHook 取证视图(Proxy 透明、保 this 绑定)。
* 每次游戏调某插件的方法 → 记 call-ID `键.方法` 进 recHook(渠道产物 no-op)。新架构游戏经插件 + 2D context 画、
@ -235,14 +268,16 @@ export async function bootGameHost(opts) {
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 键),
// 但此处尚未注入 assets → 生产中 sprite 恒走类目色占位。补法:loadAssets(从 sourceProject 顶层 assets 按 ref
// 异步载图)完成后,传 `assets: { [asset.ref]: { image } }` 入下方 boot。未 wire 前真图源路仅测试注入可达。
// 资产注入(U1 wiring 已落地):boot.assets={ref:{image}} 由顶部 loadHostAssets 异步载 ./assets/manifest.json 列出的图后传入下方 boot。
// boot.assets 注入(上方 HOST TODO 已落地·见顶部 loadHostAssets):浏览器侧载 ./assets/manifest.json 列出的图 → boot.assets。
// node smoke-boot / 无美术游戏 → 空集,boot 与之前逐字等价(无回归)。游戏经 boot.assets[ref].image 取图、g.drawImage 绘制。
const hostAssets = await loadHostAssets();
await game.init({
ctx: bundle.context,
mainContext: renderCtx,
canvas: inputCanvasGetter(),
seed,
assets: hostAssets,
});
// 游戏取证视图(可选;游戏未实现 _forensicsView 时 gameForensics 保持 null,通用宿主不依赖)。
gameForensics = (game && typeof game._forensicsView === 'function') ? game._forensicsView() : null;

View File

@ -14,7 +14,7 @@
'use strict';
import { writeFileSync, mkdirSync } from 'node:fs';
import { writeFileSync, mkdirSync, cpSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { scaffold, scaffoldSaa, readFileTool, writeFileTool, listDirTool, check, build, buildSaa, stage, gameDir } from './tools.mjs';
import { chat, MODEL } from './m3.mjs';
@ -76,6 +76,18 @@ async function main() {
rec(`scaffold amgen-${id}(clone _template${isSaa ? ' + SAA 信封' : ''})`);
doScaffold();
// 资产提供(「不让 M3 造资产、只让它决定怎么用」):--assets-dir 给定则把整目录(manifest+图+音)拷进 amgen/assets/,覆盖 _template 空清单。
// stage 会再把 assets/ 拷到 staged gameId 目录 → boot-game-host.loadHostAssets 据 manifest 载图注入 boot.assets,游戏经 boot.assets[ref].image 取图。
const assetsDir = arg('assets-dir', '');
if (assetsDir) {
if (existsSync(assetsDir)) {
cpSync(assetsDir, join(gameDir(id), 'assets'), { recursive: true });
rec(`assets 提供:${assetsDir} → amgen-${id}/assets/(M3 只决定怎么用,不造)`);
} else {
rec(`⚠ --assets-dir 不存在:${assetsDir}(跳过,游戏走程序化回退)`);
}
}
const messages = [
{ role: 'system', content: buildSystemPrompt(id) },
{ role: 'user', content: `请按这个 brief 造一款游戏:「${brief}」。先读手册再动手。` },

View File

@ -82,15 +82,17 @@ A 读手册(1)+ 读你的起点 game-logic.js(4)→ B 按 brief 定玩法(核心
【红线(check 会拦,违反则 done 被拒)】
- game-logic.js 必须 **export function createGame({ plugins, bundle, viewport })**(命名导出、别改名、别默认导出);**零引擎 import**(引擎经 boot.ctx.getEngine())。
- 游戏源(game-logic / core / render)**零裸** Math.random / Date.now / setTimeout / requestAnimationFrame / new AudioContext / addEventListener —— 时间随机经 boot.ctx,定时经 timer-scheduler,输入经 ctx.getInput,音效经 plugins.audioMusic。
- update(dt) 内**必须调 bundle.tick(dt)**(bundle 是 createGame 入参;否则插件 onFrame 不推进、timer 永不到期、一局不结束)。
- **入参是扁平的 { plugins, bundle, viewport }——没有 opts、没有 opts.runtime、没有 ctx**(L1 已替你摊平)。受控面 ctx 只在五法 **init(boot) 的 boot.ctx**(nowMs/random/getInput/getContext2d/getEngine)。五法 = init(boot)/update(dt)/render(g)/destroy()(+必须 _forensicsView)。
- 游戏源(game-logic / core / render)**零裸** Math.random / Date.now / setTimeout / requestAnimationFrame / new AudioContext / addEventListener —— 时间随机经 boot.ctx,定时经 timer-scheduler,音效经 plugins.audioMusic;**输入不调 ctx.getInput**(已收归 L1,见下「输入契约」)。
- update(dt) 内**必须调 bundle.tick(dt)**(bundle 是 createGame 入参;否则插件 onFrame 不推进、timer 永不到期、一局不结束)。**dt 单位是「秒」(≈1/60)**——游戏内若用毫秒计时(倒计时 / spawn 间隔 / 顾客耐心,常量都是 ms 如 60000/1300),累加时务必「elapsedMs += dt * 1000」,漏 ×1000 → 计时慢 1000 倍 → 永不到 spawn 阈值=零顾客、一局不结束(实测 wanglanmei 翻车点)。
- **入参是扁平的 { plugins, bundle, viewport }——没有 opts、没有 opts.runtime、没有 ctx**(L1 已替你摊平)。受控面 ctx 只在五法 **init(boot) 的 boot.ctx**;**调用形态钉死**:时间 **ctx.time.nowMs()**、随机 **ctx.random.next()/ctx.random.range(a,b)**、画布 ctx.getContext2d()、引擎 ctx.getEngine()、日志 ctx.log(tag,msg)。**time/random 是对象、不是函数——绝不写 ctx.nowMs()/ctx.random()**(实测 M3 翻车:当函数调 → 抛错 → 游戏空转不 spawn/不计分)。五法 = init(boot)/update(dt)/render(g)/destroy()(+必须 _forensicsView)+ **输入方法 handleTap(x,y)**(见下「输入契约」)。
- **插件扁平注入:直接 const { sceneFsm, sessionScore, hudUi, timerScheduler } = plugins(用到才解构)**;**绝不写 opts.runtime.plugins**(那是 L1 内部的,你见不到 → 写了就 undefined → boot 崩 reading 'define')。游戏只用插件、不 new、不 register。
- **可用插件键(全 11 已注入,用到才取)**:sceneFsm / sessionScore / hudUi / timerScheduler / save / gamefeel / juice / palettePost / audioMusic / collision / physics。
- **插件方法名一律以 api.d.ts 为准、别臆测**(M3 实测幻觉:scene-fsm 写 defineScenes〔应 define〕;particles-juice 没有 burst〔事件 API 名以其 api.d.ts 为准〕)。用到某插件先 read_file 它的 api.d.ts;skill「⚠️ M3 实测易犯的幻觉 API」逐条列 ❌→✅。
- **画面读 viewport.w/h**(viewport 是入参;别硬编码 390×844)。
- **必须实现 _forensicsView()**(可测性红线):返回 { state(){…}, measures(){…} };state() 导出可观测状态——至少 { phase, score },有可点目标则加 targets:[{x,y,occupied}]。九门据此验"游戏真活 + 有进展";不写则验证读不到状态(check 会拦)。
- **关键事件用 ctx.log 记日志**:在 init/场景切换/得分/出错处 调 ctx.log('tag', 信息)(ctx 来自 init(boot).boot.ctx)——便于读日志诊断运行时问题(插件调用已自动记,你只补游戏语义事件)。
- **美术资产(若 assets/manifest.json 非空)**:先 read_file('assets/manifest.json') 看有哪些资产;init(boot) 里 const assets = boot.assets || {};render 里 const img = assets['ref名']?.image; if (img) g.drawImage(img, x, y, w, h); else 程序化回退。**ref = manifest 条目 file 去扩展名**(shopkeeper-idle.jpg → 'shopkeeper-idle')。缺图/加载失败时 assets[ref].image 为 null(host 已容错)→ 务必走程序化回退、别崩。BGM/音效经 plugins.audioMusic(不在 boot.assets)。无 manifest/无美术 → 全程序化绘制即可。
- **必须实现 _forensicsView()**(可测性红线 + 自动驱动契约):返回 { state(){…}, measures(){…} };state() 至少 { phase, score }。**若游戏靠点击推进(经营点客/打地鼠/点离散目标等):state().targets:[{x,y,occupied}] 列当前屏上目标,可点的(等待的客/冒头的鼠)置 occupied:true、不可点置 false** —— 自动验收据此点 occupied:true 的目标真驱动你的游戏。**score 用累计进展(营收/得分),随推进上升** —— 自动验收据 score 上升判"真有进展"。不写则验证读不到状态(check 会拦)。**钉死:phase/targets/score 必须在 state() 函数体内部实时读取/计算——绝不在 _forensicsView() 外层先算好再闭包返回**(host 只在 boot 调一次 _forensicsView,外层算的值会被定格成 boot 快照=永远 menu、targets 永远空 → 自动验收看不到顾客、永判死菜单;实测 wanglanmei 翻车点)。
- **关键事件用 ctx.log 记日志**:在 init/场景切换/得分/出错处 调 ctx.log('tag', 信息)。**ctx 来自 init 的入参 boot,即「const ctx = boot.ctx」——绝不是 boot.boot.ctx**(host 传入的 boot 已是 {ctx, mainContext, canvas, seed, assets},多套一层 .boot=undefined → ctx=null → 无输入、游戏点不动;check 会拦 .boot.ctx)。插件调用已自动记,你只补游戏语义事件。
- **输入契约(钉死·根治"启动不了")**:输入订阅已由 L1(game.js wrapper)接管,**你绝不自己订阅输入**——不写 ctx.getInput、不自建 pendingClicks 队列、不在 update(dt) 里挑时机消费点击。你只写实例方法 **handleTap(x, y)**(点击主输入:经营点客 / 打地鼠 / 点按钮等;**menu/玩中/结算各 phase 的判定全写在它内部**),L1 会在每次 pointerdown 时**直达**调用它;键盘玩法(方向键 / 空格)写 **handleKey(key)**,L1 在 keydown 时调用。check 会拦「调了 getInput」与「一个输入方法都没暴露」。〔M3 实测翻车:把点击塞 pendingClicks、在 update 的 play 分支里(menu/over 的 early-return 之后)才消费 → 菜单点击永不被处理 → 卡菜单、启动不了。收归 L1 后结构性根除。〕
【完成判据 + 停机纪律(重要)】
- 判据 = check PASS + build PASS(本 spike **不跑** README 里的 node --test)。**一旦 check 与 build 都 PASS,立即调 done**(summary 一句话)。

View File

@ -0,0 +1,122 @@
/**
* publish-amgen.mjs — 把 amodel-gen(SAA 模式)产出的自包含 bundle 入库到 game_runtime_package,
* 使其在 studio /play/<gameId>/<versionId> 可试玩。复刻 SAA emit + publish_flip 的产物形态(package_json.engineBundle + status=1)。
*
* 用法(在 mini 上跑):
* node publish-amgen.mjs --staged <_wg1-gen/<gid> 绝对路径> --game-id <数字> --version-id <数字> --title "..." [--summary "..."] [--exec]
* 不带 --exec = 干跑(只打印 SQL 摘要 + 校验和,不写库);带 --exec = 真写库(经 docker exec mysql)。
*
* 注意:engineBundle 是大文本,SQL 字面量经 JS 侧转义(\\ 与 ')可靠回环;package_json 入 JSON 列由 MySQL 校验。
*/
'use strict';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
function arg(n, d) { const i = process.argv.indexOf('--' + n); return i >= 0 ? process.argv[i + 1] : d; }
const sha256 = (s) => createHash('sha256').update(s, 'utf8').digest('hex');
// MySQL 单引号字面量转义(默认 NO_BACKSLASH_ESCAPES off):\ → \\,' → \'。回环精确。
const sqlStr = (s) => "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
const staged = arg('staged');
const gameId = arg('game-id');
const versionId = arg('version-id');
const title = arg('title', '网兰梅的小卖部');
const summary = arg('summary', 'A-model 生成·街角小卖部经营(点客成单赚营业额)');
const templateId = arg('template-id', 'generic');
const doExec = process.argv.includes('--exec');
if (!staged || !gameId || !versionId) {
console.error('缺参:--staged <dir> --game-id <n> --version-id <n> [--title ..] [--exec]');
process.exit(2);
}
const bundlePath = join(staged, 'bundle.iife.js');
if (!existsSync(bundlePath)) { console.error('找不到 bundle.iife.js:' + bundlePath + '(先确认 gen stage 成功)'); process.exit(2); }
const engineBundle = readFileSync(bundlePath, 'utf8');
if (!/__GameBundle/.test(engineBundle)) { console.error('⚠ bundle 未见 __GameBundle 全局名,可能不是 SAA 信封产物'); }
const bundleSize = Buffer.byteLength(engineBundle, 'utf8');
const bundleChecksum = sha256(engineBundle); // manifest.checksum = bundle sha256(宿主完整性校验 T-RT-15)
const cover = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
// package_json 形态对齐线上已发布包(93142 实测):meta/assets/gameId/manifest/versionId/gameConfig/provenance/templateId/schemaVersion/engineBundle。
const pkg = {
meta: { cover, title, summary, ageRating: 'all' },
assets: [], // 本期引擎不消费 runtime 图资产(E2/E3 未做)→ 留空;游戏走程序化回退
gameId: String(gameId),
manifest: { entry: 'index.html', checksum: bundleChecksum, bundleSize, preloadPolicy: 'eager', runtimeVersion: '1.0.0' },
versionId: String(versionId),
gameConfig: { theme: 'sim', title, templateId, engineDriven: true },
provenance: { traceId: 'amgen-publish-' + gameId, promptHash: '', generatedAt: '' },
templateId,
schemaVersion: '1.0',
engineBundle,
};
const pkgJson = JSON.stringify(pkg);
const rowChecksum = sha256(pkgJson); // 行列 checksum = sha256(package_json)(publish_flip 自洽校验口径)
const sql = `USE \`ruoyi-vue-pro\`;
SET NAMES utf8mb4;
START TRANSACTION;
DELETE FROM game_feed_rank WHERE game_id = ${gameId};
DELETE FROM game_runtime_package WHERE game_id = ${gameId};
DELETE FROM game_version WHERE game_id = ${gameId};
DELETE FROM game_project WHERE id = ${gameId};
INSERT INTO game_project
(id, creator_user_id, title, summary, cover_url, tags, template_id, age_rating,
status, current_version_id, launch_zone_id, featured, play_count, like_count,
creator, create_time, updater, update_time, deleted, tenant_id)
VALUES
(${gameId}, 1, ${sqlStr(title)}, ${sqlStr(summary)}, ${sqlStr(cover)},
'amgen,sim,wanglanmei', ${sqlStr(templateId)}, 'all',
4, ${versionId}, NULL, b'0', 0, 0,
'0', NOW(), '0', NOW(), b'0', 0);
INSERT INTO game_version
(id, game_id, version_no, gen_task_id, package_url, bundle_size, checksum,
status, creator, create_time, updater, update_time, deleted, tenant_id)
VALUES
(${versionId}, ${gameId}, 2, NULL, '', ${bundleSize}, ${sqlStr(bundleChecksum)},
3, '0', NOW(), '0', NOW(), b'0', 0);
INSERT INTO game_runtime_package
(id, game_id, version_id, template_id, package_url, entry, runtime_version, preload_policy,
bundle_size, checksum, sandbox_attr, allow_origins, status,
creator, create_time, updater, update_time, deleted, tenant_id, package_json)
VALUES
(${versionId}, ${gameId}, ${versionId}, ${sqlStr(templateId)}, '', 'index.html', '1.0.0', 'eager',
${bundleSize}, ${sqlStr(rowChecksum)}, 'allow-scripts', '', 1,
'0', NOW(), '0', NOW(), b'0', 0, ${sqlStr(pkgJson)});
INSERT INTO game_feed_rank
(id, game_id, version_id, zone_id, quality_score, boost, sort_score, pinned, status,
creator, create_time, updater, update_time, deleted, tenant_id)
VALUES
(${versionId}, ${gameId}, ${versionId}, 0, 0.9000, 0.0000, 0.9000, 0, 1,
'0', NOW(), '0', NOW(), b'0', 0);
COMMIT;
SELECT 'project' k, id, status, current_version_id FROM game_project WHERE id=${gameId}
UNION ALL SELECT 'version', id, status, game_id FROM game_version WHERE id=${versionId}
UNION ALL SELECT 'pkg', id, status, version_id FROM game_runtime_package WHERE id=${versionId}
UNION ALL SELECT 'feed', id, status, version_id FROM game_feed_rank WHERE id=${versionId};
SELECT 'engineBundle_len' k, CHAR_LENGTH(JSON_UNQUOTE(JSON_EXTRACT(package_json,'$.engineBundle'))) v FROM game_runtime_package WHERE id=${versionId};
SELECT 'checksum_selfconsistent' k, (checksum = LOWER(SHA2(package_json,256))) v FROM game_runtime_package WHERE id=${versionId};
`;
console.log(`[publish] staged=${staged}`);
console.log(`[publish] gameId=${gameId} versionId=${versionId} title=${title}`);
console.log(`[publish] bundleSize=${bundleSize}B bundleChecksum=${bundleChecksum.slice(0, 16)}… rowChecksum=${rowChecksum.slice(0, 16)}…`);
console.log(`[publish] package_json 长度=${pkgJson.length} SQL 长度=${sql.length}`);
if (!doExec) {
console.log('\n[dry-run] 未带 --exec,未写库。带 --exec 真写库。');
process.exit(0);
}
// 取 MySQL root 密码(docker 容器 env)→ 管道 SQL 入 mysql。
const pwRes = spawnSync('docker', ['exec', 'game-staging-mysql', 'printenv', 'MYSQL_ROOT_PASSWORD'], { encoding: 'utf8' });
const pw = (pwRes.stdout || '').trim();
if (!pw) { console.error('取不到 MYSQL_ROOT_PASSWORD'); process.exit(1); }
const res = spawnSync('docker', ['exec', '-i', 'game-staging-mysql', 'mysql', '--default-character-set=utf8mb4', '-uroot', '-p' + pw, 'ruoyi-vue-pro'],
{ input: sql, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
process.stdout.write(res.stdout || '');
if (res.status !== 0) { console.error('[publish] mysql 退出码 ' + res.status + '\n' + (res.stderr || '')); process.exit(1); }
console.log('\n✅ 入库完成。studio 试玩:/play/' + gameId + '/' + versionId);

View File

@ -185,6 +185,22 @@ export function check(id) {
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。匹配「.boot.ctx」(boot.boot.ctx / b.boot.ctx 皆中),正确的 boot.ctx 不含前导「.boot」不误伤。
if (/\.boot\s*\??\.\s*ctx\b/.test(g)) {
errors.push('init(boot) 受控面是 boot.ctx,不是 boot.boot.ctx——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 时直达调用。
if (/\bgetInput\s*\(/.test(g)) {
errors.push('game-logic.js 不要调 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,别写它)');
}
@ -212,7 +228,7 @@ export function check(id) {
if (!existsSync(abs)) continue;
const s = stripCode(readFileSync(abs, 'utf8'));
for (const re of BANNED) {
if (re.test(s)) errors.push(`红线 ${f}: 禁用 ${re.source}(时间/随机经 boot.ctx,定时经 timer-scheduler,输入经 ctx.getInput)`);
if (re.test(s)) errors.push(`红线 ${f}: 禁用 ${re.source}(时间/随机经 boot.ctx,定时经 timer-scheduler,输入经实例方法 handleTap/handleKey 由 L1 派发)`);
}
}
@ -289,6 +305,14 @@ export function stage(gameId) {
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 };
}