diff --git a/contracts/README.md b/contracts/README.md index c463a01e..734060f0 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -23,6 +23,8 @@ > **Wave1 脊柱 4 模块契约已补并锁**(aigc/runtime/feed/telemetry,2026-06-08):经契约先行起草 + 一致性评审 + 收口(§2.1)+ 主 agent 校验(YAML 语法/裸 select* 0/错误码段 101-104 独占/Flyway V2-5 唯一)。 > **Wave2 变现契约已补并锁**(ad/trade,2026-06-08):`ad.yaml`+`trade.yaml`(API #1)+ `V6.0.0`/`V7.0.0`(DB #2),消费既有 #7 ad-slot / #5 events 不重造;经主 agent 校验(YAML 语法/错误码 111·106 独占/Flyway V6-7 唯一/金额用「分」/资金幂等键+状态机/ad↔trade Feign seam 一致);ad 计费 `uk_trace` 由主 agent 收紧为 `(trace_id,event_type,tenant_id)` 防 reward 被 impression 静默吞掉。**pay=复用 yudao-pay 后置**(收单 P1)。 > **Wave3 跨主线收口(进行中)**:新增 `studio`(创作主链路最小版编排,错误码 112 / Flyway V8)+ `compliance`(锁风门 Gate,发布前合规扫描)两模块契约。Phase A 已收口 project 侧:publish 唯一化(返回 admitted+gates 门禁聚合)、ProjectApi seam 定稿(getCurrentVersionId GAP-3 / createProject 写类 RPC)、GAP-4 把 `events.schema.json` 信封 envelope/user 由 snake_case 统一为 camelCase(与 telemetry.yaml/前端 SDK 对齐)。compliance gate 在 project.submitPublish 内仅留 Phase C 接入 seam(TODO 注释),尚未接 ComplianceGateApi。其余模块(community/ip/biz)待后续波次补。 +> +> **游戏宿主装载契约(指针登记,不折进上表跨仓 8 类)**:Runner v2 P1 新立 additive 第 9 类契约,**落 `game-runtime/src/core/game-host.d.ts`**(与 `api.d.ts` 同目录,**非**本目录)。理由:它是 game-runtime 内部「host↔游戏工厂」接线面,消费方只在 game-runtime(host boot + 生成 agent),落同目录 import 方向顺(`./api.d.ts`);落本目录会让跨仓 SSOT 反向相对 import `../game-runtime/src`(拆仓即断)。消费方=host boot(装游戏)+ 生成 agent(产出靶);与受控面 6 项 additive(受控面零改)。详见 `docs/agent-specs/2026-06-13-P1-王蓝莓上引擎-execution.md` §5/§12-A1。 ``` contracts/ diff --git a/docs/agent-specs/2026-06-13-P1-王蓝莓上引擎-execution.md b/docs/agent-specs/2026-06-13-P1-王蓝莓上引擎-execution.md new file mode 100644 index 00000000..9252e1d0 --- /dev/null +++ b/docs/agent-specs/2026-06-13-P1-王蓝莓上引擎-execution.md @@ -0,0 +1,370 @@ +# P1 · 王蓝莓上引擎(spike 游戏宿主装载契约)· 执行版 + +> **状态**:execution 版 · 2026-06-13 · 给执行 agent 实现。配套 review 版 [`2026-06-13-P1-王蓝莓上引擎-review.md`](./2026-06-13-P1-王蓝莓上引擎-review.md);母梁 [`2026-06-13-runner-v2-arc-review.md`](./2026-06-13-runner-v2-arc-review.md) §5.1。 +> **修订**:2026-06-13 对抗复审(deep·全程读真源码)→ 纳入 4 major(real 就绪标记 / driver 范式切换 / 同屏暖色阈值坐实 / bundle 延后建)+ 5 项不明假设处置(IDL 落点改 game-runtime/src·缺省分叉显式声明·game 工厂硬独立·部署改 git-clone-scratch·步骤0 降常规核对)。逐条裁决见 §12。 +> **模型分流**:一律 opus(创始人拍)。 +> **前提(创始人已拍)**:装载契约 = 新立 additive 第 9 类契约(不污染 `PluginContext`);生命周期 = `init/update/render/onInput` + `destroy`;boot 注入引擎 `mainContext` 给游戏层。 +> **铁律**:受控面 6 项契约(`getContext2d/onFrame/getInput/getAudioContext/time/random`,`api.d.ts:269/281/286/303`)**零改**;引擎 import 只活 entry/host(Q4);区分 verified/inferred/assumption,凡引代码事实带 file:line。 +> **装载契约落点(复审改)**:IDL 落 `game-runtime/src/core/game-host.d.ts`(与 `api.d.ts` 同目录),**不**落 `contracts/`——`contracts/` 是跨三仓五工位的**业务契约** SSOT(API/DB/SDK/event…,README §1),装载契约是 game-runtime 内部「host↔游戏工厂」接线面,消费方只在 game-runtime(host boot + 生成 agent),且让跨仓 SSOT 反向相对 import `../game-runtime/src/*` 违「契约即 SSOT」且 contracts 拆独立仓时即断(README §1 明列拆仓计划)。落同目录后 import 方向自然(`import type { PluginContext } from './api.d.ts'`)。详见 §12-A1。 + +--- + +## 1. 目标 / 范围边界 + +### 1.1 目标 +给 `wanglanmei-ref` 加 `?engine=real` 通道:① 引擎真掌帧(`engineInit` 五回调驱动游戏 `update/render`,废浏览器 RAF);② 游戏美术与引擎粒子**同屏可玩**(游戏绘制面从 `#game` 2D 改引擎 `mainContext`,合并成一张画布);③ 人在 mini-desktop 浏览器真玩;④ 保 `?engine=stub` 回滚(逐像素确定性)。同时把 `makeEngineCaps`(`host.js:197-318`)提升为可 import 模块 `host-dev/engine-caps.js`,host-dev / ref 共用。 + +### 1.2 范围内 +- 新立装载契约工件 **`game-runtime/src/core/game-host.d.ts`**(additive 第 9 类契约;落 game-runtime/src,**非** contracts/,详见 §12-A1)。 +- `host-dev/engine-caps.js` 提升(搬 `host.js:197-318` 闭包为 `createEngineCaps({LJS, engineMath, engineMode, recHook})`);`host.js` 改 import。 +- `wanglanmei-ref` real 通道改造:`entry.js` import 引擎、`main.js` 主循环挂引擎五回调、绘制面改 `mainContext`、`createHostDevContext` 注 `engineFactory`、`mockNowMs` 改逻辑帧钟、RAF 门控。 +- ref 内拆出符合装载契约的 game 工厂(**硬约束:独立文件 `game-shop.js`,默认导出 `GameHostFactory`,零引擎 import;禁 main.js 内联糊过**,详见步骤 3 + §12-A3)。 +- `e2e-input.cdp.cjs` 加 `engine=real` 通道断言 + 同屏像素判据 + **real 通道 driver 范式切换(真触摸+轮询,禁 stepFrames 手摇,详见步骤 8 + §12-M2)**。 +- `index.html` 加 `__refBootedReal` 就绪标记接线(real 通道;详见步骤 4.3 + §12-M1)。 +- `SIZES.md` 增"游戏产物"口径。 + +### 1.3 范围外(非目标) +- 受控面 6 项契约(零改)。 +- P2(泛化宿主/装载任意 GamePackage)→ 母 spec Phase D(O2 已拍)。 +- P3(生成游戏按契约产出)→ W-G1 自身 spec。 +- 后端发布主链 / game-studio / feed / 生成管线。 +- 不接 OSS;不改 feed 引擎选型。 +- 不动 stub 通道既有行为(逐行保旧)。 + +--- + +## 2. 前置 + +| # | 前置 | 状态 / 证据 | +|---|---|---| +| P-1 | A0 引擎掌帧地基已收口(real 通道范式可用) | verified:`host.js:917` engineInit 五回调 + `host.js:919` setupAfterEngine(mainContext);门0 8/8(`3e5cab4`) | +| P-2 | A1-A4 引擎能力包装已落地(makeEngineCaps 已接线) | verified:`host.js:359` `engineFactory: makeEngineCaps`;`engine-math.js` 已独立模块 | +| P-3 | mini-desktop 具备 esbuild + chrome(6c6g 禁 chrome) | verified:`a6-runbook.sh` / `ref-e2e-runbook.sh` 均跑 mini-desktop | +| P-4 | ref 现状:49 单测绿 + e2e 四件套(stub 基线) | verified:`BUILD.md` RESULT.txt 49/49 | +| P-5 | LittleJS 已是 ref devDep(构建可引擎入包) | inferred:host-dev 同仓共用 `littlejsengine`;执行首步须核 `games/wanglanmei-ref` 经 monorepo node_modules 可解析(与 host-dev entry 同口径) | + +--- + +## 3. 涉及文件路径 + +| 文件 | 动作 | 关键行锚 | +|---|---|---| +| `game-runtime/src/core/game-host.d.ts` | **新增** | 装载契约 IDL(§5);落 src/core 与 `api.d.ts` 同目录(**非** contracts/,§12-A1) | +| `contracts/README.md` | 改(索引指针) | 不折进跨仓 8 类;加一行指针:「游戏宿主装载契约(game-runtime 内部,落 `game-runtime/src/core/game-host.d.ts`)」(§12-A1) | +| `host-dev/engine-caps.js` | **新增** | `createEngineCaps`(搬 `host.js:197-318`) | +| `host-dev/host.js` | 改 | `makeEngineCaps` 闭包 → import `createEngineCaps`(`host.js:197-318` 删,`host.js:204/359` 改) | +| `games/wanglanmei-ref/entry.js` | 改 | `entry.js:15` 增 `import * as LJS from 'littlejsengine'` | +| `games/wanglanmei-ref/src/main.js` | 改(real 通道) | `main.js:112`(engineFactory,real 须延后到 engineInit().then 内建·§12-M4)/`617-661`(frame→game.update/render)/`648`(drawFrame g→mainContext)/`668-691`(RAF 门控)/`618`(mockNowMs)/`583-602`(real 输入改挂 mainCanvas·§12-M4) | +| `games/wanglanmei-ref/src/game-shop.js` | **新增(硬约束·非建议)** | 把 main.js 游戏逻辑/绘制段封成装载契约 game 工厂;默认导出 `GameHostFactory`、零引擎 import(步骤 3 + §12-A3) | +| `games/wanglanmei-ref/index.html` | 改 | real 通道 `__refBootedReal` 就绪标记接线(§12-M1);stub 仍用 `__refBooted` | +| `games/wanglanmei-ref/test/e2e-input.cdp.cjs` | 改 | 加 `engine=real` 通道(rect 选择器切 `#game-engine`、waitRefBoot 等 `__refBootedReal`、real 范式=真触摸+轮询禁 stepFrames、同屏像素判据)(步骤 8 + §12-M1/M2/M3) | +| `games/wanglanmei-ref/test/ref-e2e-runbook.sh` | 改(或旁建 real 版) | **沿 git-clone-scratch 口径**(`ref-e2e-runbook.sh:20-21`,**非** tar-pipe);URL 加 `&engine=real`;构建后 `grep -q ParticleEmitter` 核引擎入包(§12-A4) | +| `SIZES.md` | 改 | 增"游戏产物=引擎+包装层"口径 | + +--- + +## 4. 数据流 + +```mermaid +sequenceDiagram + participant HTML as index.html + participant Boot as bootRef(main.js) + participant Caps as engine-caps.js + participant LJS as littlejsengine + participant Game as game(装载契约工厂) + + HTML->>Boot: bootRef({canvas,statusEl}) + alt engine=real + Boot->>Boot: setGLEnable(false)+setDebugWatermark(false)+setCanvasClearColor(BLACK) + Boot->>Caps: createEngineCaps({LJS,engineMath,engineMode:'real',recHook}) + Caps-->>Boot: engineFactory(particles/audio/math 三面) + Boot->>LJS: engineInit(gameInit,gameUpdate,...,gameRender,...) + LJS-->>Boot: .then() → mainContext 就绪 + Boot->>Boot: createHostDevContext({context2d:mainContext, engineFactory, clock:逻辑帧钟}) + Boot->>Game: game.init(ctx, mainContext) + loop 引擎内部 RAF + LJS->>Game: gameUpdate → game.update(FIXED_DT) + LJS->>Game: gameRender → game.render(mainContext) + Game->>LJS: ctx.getEngine().particles.spawnEmitter(粒子自渲落 mainContext) + end + else engine=stub(保旧) + Boot->>Boot: prepareCanvas(#game) 2D + createHostDevContext(无 engineFactory) + Boot->>Boot: 浏览器 RAF + mockNowMs 墙钟 → frame(dtReal) → drawFrame(#game) + end +``` + +**关键数据流不变量**: +- real:掌帧唯一源=引擎(游戏不自起 RAF);绘制面唯一=引擎 `mainContext`(游戏美术+引擎粒子同屏);插件能力经 `ctx.getEngine()`(下层已冻)。 +- **real 时序硬约束(复审 M1/M4)**:`createHostDevContext`(bundle)、`game.init`、输入桥挂 `mainCanvas`、就绪标记 `__refBootedReal=true` **全部在 `engineInit().then(setupReal)` 内**(上图 `.then() → mainContext 就绪` 之后)——引擎掌帧的必然结构:引擎先起、`mainContext`/`mainCanvas` 才有;顶层提前建 bundle 会快照错 ctx(`host.js:323-324`)。 +- stub:与今天 `main.js` 逐行等价(回滚锚);就绪标记仍用 `index.html` 的 `__refBooted`(`index.html:81`)。 + +--- + +## 5. 装载契约 IDL(新立 additive 第 9 类契约 · `game-runtime/src/core/game-host.d.ts`) + +> **设计原则**:游戏 = 纯模块工厂(零引擎 import,Q4)。host 经 `engineInit` 五回调驱动游戏生命周期;游戏绘制面 `render(g)` 的 `g` = 引擎 `mainContext`(boot 注入);插件能力经下层已冻结 `ctx.getEngine()`(不在本契约重复定义)。**与 `api.d.ts` 是 additive 关系:本契约只新增 `GameHostFactory`/`GameInstance`/`GameHostBootContext` 三个类型,受控面 6 项零改。** +> **落点(复审改)**:落 `game-runtime/src/core/game-host.d.ts`(与 `api.d.ts` 同目录),故 import 方向自然=同目录 sibling `./api.d.ts`;**不**落 contracts/(理由见 §12-A1)。 + +```typescript +// game-runtime/src/core/game-host.d.ts —— 游戏宿主装载契约(第 9 类契约 · additive · game-runtime 内部) +// owner:Runner v2 P1 | 消费方:集成段 host(boot 游戏)+ 生成 agent(产出靶) +// +// 【为什么新立、不折进 api.d.ts】 +// api.d.ts 的 PluginContext 是「插件↔引擎」面(getEngine() 等,api.d.ts:303,已冻结); +// 本契约是「游戏整体↔引擎宿主」面——游戏的 update/render 主体不是插件、不走 PluginContext。 +// 两者正交(边界模型「同一职不留两条并行路」):插件经 getEngine() 拿能力,游戏经本契约挂宿主。 +// 引擎 import 只活集成段 host(Q4 铁律),游戏工厂零引擎 import。 +// 【为什么落 game-runtime/src 而非 contracts/】 +// contracts/ 是跨三仓五工位的业务契约 SSOT(API/DB/SDK/event…,contracts/README.md §1); +// 本契约是 game-runtime 内部「host↔游戏工厂」接线面,消费方只在 game-runtime, +// 故与 api.d.ts 同目录、import 方向顺;落 contracts 会让跨仓 SSOT 反向相对 import ../game-runtime/src(拆仓即断)。 + +import type { PluginContext } from './api.d.ts'; + +/** + * 游戏挂宿主时拿到的 boot 上下文。host 在游戏 init 时一次性注入。 + * - ctx:下层已冻结受控面(PluginContext)。游戏经 ctx.getEngine() 拿插件能力(粒子/音频/数学), + * 经 ctx.getInput() 拿受控输入,经 ctx.random/time 拿受控随机/时钟。**不得绕过 ctx 触碰裸引擎/DOM。** + * - mainContext:引擎 mainContext(setGLEnable(false) 后的纯 2D 绘制面)。游戏 render(g) 的 g 即它; + * 游戏美术画在此面 → 与引擎粒子(引擎自渲落同一 mainContext)同屏。**此字段是装载契约的核心** + * (游戏取引擎绘制面的唯一约定,不借 PluginContext.getContext2d——那是插件面)。 + * - seed:主随机种子(确定性);canvas:可见画布引用(输入 rect 映射用,real=引擎 mainCanvas)。 + */ +export interface GameHostBootContext { + ctx: PluginContext; // 下层已冻结受控面(复用) + mainContext: CanvasRenderingContext2D; // 引擎 mainContext(boot 注入,render 绘制面) + canvas: HTMLCanvasElement; // 可见画布(real=引擎 mainCanvas,输入 rect 映射用) + seed: number; // 主随机种子 +} + +/** + * 游戏实例:host 经引擎五回调驱动的稳定生命周期。 + * 生命周期对齐 engineInit 五回调(host.js:883-887 engGameInit/Update/Render): + * · init ← gameInit(引擎首帧前一次性):建游戏对象、订阅 ctx.getInput()、装资产。 + * · update(dt) ← gameUpdate(引擎每逻辑步,dt=FIXED_DT 恒定):core.update/事件/特效计时。 + * · render(g) ← gameRender(引擎清屏后,g=mainContext):画游戏美术;引擎粒子已自渲落同一 g。 + * · onInput(ev)(可选):归一化输入事件回调。**注**:ref 经 ctx.getInput().on 订阅即可, + * onInput 是「不便用 ctx.getInput 的极简游戏」的备选入口;二者择一,ref 用前者。 + * · destroy():卸载卫生(P2 装载多游戏复位用;P1 ref 单游戏可空实现但须声明)。 + */ +export interface GameInstance { + init(boot: GameHostBootContext): void | Promise; // 异步:装资产(ref loadAssets 异步) + update(dt: number): void; // 逻辑步(dt 秒,引擎恒定步长) + render(g: CanvasRenderingContext2D): void; // g=mainContext(boot 注入的同一面) + onInput?(ev: { type: string; x?: number; y?: number; key?: string }): void; // 可选输入入口 + destroy(): void; // 卸载(幂等) +} + +/** + * 游戏工厂:纯模块导出(零引擎 import)。host 调它建 GameInstance。 + * 生成 agent 的唯一产出靶 = 实现并默认导出一个 GameHostFactory。 + * 【硬约束·§12-A3】必须是「独立、可被 P3 生成 agent 复制的工厂模块」: + * · 独立文件默认导出(ref=games/wanglanmei-ref/src/game-shop.js 的 `export default createShopGame`); + * · 零引擎 import(引擎能力一律经 boot.ctx.getEngine()); + * · 禁在 main.js 内用内联闭包「形似实现」糊过——否则 P3 缺真实参照实现,违 P1「spike 装载契约到可被 P3 复制」目标。 + * @param opts 工厂选项(无玩法语义;预留 difficulty/variant 等 additive 扩展位)。 + */ +export type GameHostFactory = (opts?: Record) => GameInstance; +``` + +**IDL 三条不可破约定(对齐边界模型)**: +1. **掌帧唯一源=引擎**:`GameInstance` 不含任何 RAF/计时器;`update/render` 只被引擎五回调驱动。**推论(§12-M2)**:取证 driver 在 real 通道亦不得手摇 `stepFrames`(否则与引擎 RAF 双掌帧,破此条)——真触摸后等引擎自然推帧 + 轮询状态。 +2. **绘制面唯一=引擎 `mainContext`**:`render(g)` 的 g 必为 boot 注入的 `mainContext`;游戏美术与引擎粒子同屏。**推论(§12-M4)**:real 通道 `bundle` 必须在 `engineInit().then` 内、以 `context2d=mainContext` 建(受控面 getContext2d 一次性快照闭包捕获值,`host.js:323-324`);不得在顶层先建再改 ctx。 +3. **能力唯一面=下层 `ctx.getEngine()`**:粒子/音频/easing 经它(不在本契约重复定义引擎能力面)。 + +**additive 关系明确**:`game-host.d.ts` 仅 `import type { PluginContext } from './api.d.ts'`,不修改 `api.d.ts` 任何导出;受控面 6 项零改。 + +--- + +## 6. 关键实现步骤(有序) + +### 步骤 0:核前置(执行首步,read-only · 常规核对) +- mini-desktop scratch clone 后核 `games/wanglanmei-ref/entry.js` 加 `import * as LJS from 'littlejsengine'` 能被 esbuild 解析(与 host-dev entry 同 monorepo node_modules)。**口径(复审降级·§12-A5)**:此项已被 host-dev 既有事实强力支撑(`host-dev/entry.js` 末行已 `import * as LJS from 'littlejsengine'` 且 host-dev real 门 6/6 已过,同 monorepo node_modules)→ 不是可能阻断的高不确定项,作**常规构建核对**:构建 ref-bundle 后 `grep -q ParticleEmitter dist/ref-bundle.js` 即可(无需专门 /tmp dry-build 阻断门)。 + +### 步骤 1:新立装载契约工件 `game-runtime/src/core/game-host.d.ts`(§12-A1) +- 按 §5 IDL 落盘到 **`game-runtime/src/core/game-host.d.ts`**(与 `api.d.ts` 同目录);import 方向=同目录 sibling `./api.d.ts`。 +- `contracts/README.md` **不**把它折进跨仓 8 类清单,仅加一行指针登记:「游戏宿主装载契约(game-runtime 内部,落 `game-runtime/src/core/game-host.d.ts`),消费方=host boot + 生成 agent」(理由 §12-A1)。 +- **验证**:`tsc --noEmit`(若仓有 ts 校验)或 node 类型注释自洽;确认只 `import type { PluginContext } from './api.d.ts'`、不改 `api.d.ts` 任何导出(diff 证 api.d.ts 零改)。 + +### 步骤 2:提升 engine-caps.js(搬运已有件,语义零变) +2.1 新建 `host-dev/engine-caps.js`,导出 `createEngineCaps({ LJS, engineMath, engineMode, recHook })`: + - 把 `host.js:197-318` `makeEngineCaps` 闭包体整体搬入,**逐行保语义**: + - 首行 `if (engineMode !== 'real') return null;`(保 null-engine 回归短路,`host.js:198`)。 + - `rec` 改为参数注入:`const rec = recHook || (() => {});`(**默认 no-op**;原闭包内 `window.__engineCalls.push` 语义由调用方经 recHook 提供)。 + - `mathRaw = engineMath`(原 `buildEngineMath(LJS)` 的结果由调用方传入,或 `createEngineCaps` 内部 `buildEngineMath(LJS)`——二选一,保 `host.js:204` 同口径)。 + - particles/audio.synth 三面包装体(`host.js:214-316`)逐字搬入,引擎引用经入参 `LJS.*`。 +2.2 `host.js` 改:删内联 `makeEngineCaps`(`197-318`),改 `import { createEngineCaps } from './engine-caps.js'`;在 `buildBundle`(`host.js:353`)处 `engineFactory: () => createEngineCaps({ LJS, engineMath: buildEngineMath(LJS), engineMode, recHook: id => { try { (window.__engineCalls||(window.__engineCalls=[])).push(id); } catch(_){} } })`。 + - **关键**:host-dev 仍注入 rec(门② 探针在 host-dev 取证产物里有,符合现状——host-dev 不是渠道产物)。 +2.3 **验证(回归基线)**:mini-desktop 跑 `a6-runbook.sh`(门0 间接 + 门②)+ gate0 driver → **门0 8/8 + real 门 6/6 逐项不变**(提升不得改任何判据数值)。 + +### 步骤 3:ref 拆出装载契约 game 工厂(`game-shop.js` · 硬约束独立文件 · §12-A3) +- **硬约束**:必须是独立文件 `games/wanglanmei-ref/src/game-shop.js`,`export default createShopGame`(`GameHostFactory` 形态),**零引擎 import**(引擎能力一律经 `boot.ctx.getEngine()`)。**禁**在 main.js 内联闭包形似实现糊过——P3 生成 agent 需要一个真实、可复制的参照工厂(§12-A3)。stub 通道亦经此工厂的 stub 适配驱动(绘制面 g=`#game` ctx),保 stub 行为不变由逐像素门⑤兜。 +- 把 `main.js` 的游戏逻辑段与绘制段封成 `createShopGame(): GameInstance`(默认导出 `GameHostFactory`): + - `init(boot)`:搬 `main.js:158-178` 游戏对象创建(`createShopCore`/`createLinePicker`/`createHostFx` 等)+ `main.js:165-167` 异步 `loadAssets` + `main.js:582-611` 输入订阅(经 `boot.ctx.getInput()`)。保存 `boot.mainContext` 为绘制面。 + - `update(dt)`:搬 `main.js:617-647` 逻辑段(`mockNowMs += dt*1000`;`core.update(dtWorld)`;`bundle.tick(dt)`;`applyEvents`;`maybeReplayBufferedServe`;`fx.step`;rush 联动;闲时台词;ghosts 清理;`rebuildZones`)。**注**:`bundle.tick` 仍由游戏 update 调(驱动插件 onFrame 计时;引擎粒子自渲与此解耦,同 host.js A2 注释 `host.js:726-728`)。 + - `render(g)`:搬 `main.js:648-658` 绘制段(`drawFrame(g, {...})` + `palettePost.renderPost(g)`)。**g=boot.mainContext**。 + - `destroy()`:cancel 输入订阅 + dispose registry(P1 可最小实现,须声明)。 +- **难点(real 坐标系)**:引擎 `mainContext` 无 DPR 变换(引擎每帧 `updateCanvas` 重置为单位阵)。游戏按逻辑 390×844 绘制 → `render(g)` 须在 `g.save()` 内 `g.setTransform(DPR,0,0,DPR,0,0)` + `g.imageSmoothingEnabled=false`,对齐 `host.js:489-490` renderParticles 的补偿做法。stub 通道 g=`prepareCanvas(#game)` 已自带 DPR 变换(`main.js:69`)→ 不补。 + +### 步骤 4:main.js bootRef real 通道接线 +4.1 读 `engineMode = getParam('engine', 'stub')`(**缺省 stub** 保旧;real 显式开。注:host-dev 缺省 real,但 ref 缺省 stub 更稳——真玩取证显式带 `&engine=real`)。 +4.2 **stub 分支**:逐行保旧(`prepareCanvas(#game)` + `createHostDevContext` 无 engineFactory + 浏览器 RAF + `frame(dtReal)` + `drawFrame(#game)`)。把现有 `main.js` 主体收进 stub 分支(或经 game 工厂的 stub 适配,但 stub 务必行为不变——逐像素门⑤兜)。 +4.3 **real 分支**(仿 `host.js:897-928`;控制流=实质重构,不是局部插桩——§12-M4): + - `LJS.setGLEnable(false)` / `setShowSplashScreen(false)` / `setDebugWatermark(false)` / `setCanvasClearColor(LJS.BLACK)` / `setCanvasFixedSize(vec2(390*DPR, 844*DPR))`(全在 engineInit 前)。 + - 建独立 `engineRoot` div 挂 body(不复用 `#wrap`,引擎会覆盖容器样式,`host.js:911-915`)。 + - `engineMath = buildEngineMath(LJS)`;`engineFactory = () => createEngineCaps({ LJS, engineMath, engineMode:'real', recHook })`(取证时注 recHook,渠道不注 —— 见步骤 7)。 + - `engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], engineRoot).then(() => setupReal(LJS.mainContext)).catch(e => { window.__refBootError=String(e&&e.message||e); window.__refBootedReal=false; ... })`。 + - **`setupReal(mainContext)` —— 一切 bundle/game/输入/就绪标记都在此(engineInit().then 内)**: + - **【M4 硬约束·bundle 必须延后到此建】** `bundle = createHostDevContext({ context2d: mainContext, seed, audioFactory, clock: () => mockNowMs, engineFactory })`。**context2d 必为 mainContext**——受控面 getContext2d 一次性快照闭包捕获的 ctx(`host.js:323-324` 注释明确「读闭包捕获值,故必须就绪后建 bundle」);**绝不可在顶层先用 `#game` ctx 建 bundle 再改**,否则 getContext2d 快照到 stub `#game` ctx,受控面 context2d 与游戏 render 面分裂(= spec 要消除的渲染面分裂,会换形式复现)。可直接引 `host.js:353-371`(buildBundle)+ `host.js:379-381`(setupAfterEngine 在 then 内建 bundle)作样板。 + - 建 game 工厂 → `game.init({ ctx: bundle.context, mainContext, canvas: LJS.mainCanvas, seed })`(异步,`init` 内 await loadAssets)。 + - 挂 `window.__refHost`、`window.__refForensics`、`window.__refChecklist`(**全部移进 then 内挂,real 下顶层不可早挂**——顶层时 bundle/game 尚不存在)。 + - `LJS.mainCanvas.id='game-engine'`(CDP 取证选择器,仿 `host.js:830`);暴露 `window.__engine` 双时钟(仿 `host.js:840`)。 + - **【M1 硬约束·real 专属就绪标记】** game.init 完成后置 `window.__refBootedReal = true`(仿 host-dev `__hostBootedReal`,`host.js:920`)。**这是 real 通道唯一就绪信号**——必须在 engineInit().then 内、game.init/资产/受控面全就绪后置。**stub 通道照旧用 `index.html` 的 `__refBooted`(bootRef().then 置,`index.html:81`),real 通道额外置 `__refBootedReal`**。driver 在 real 下改等 `__refBootedReal`(步骤 8 + §12-M1)。 + - 五回调:`engGameUpdate(){ game.update(FIXED_DT); }`;`engGameRender(){ game.render(mainContext); }`;其余空/留痕(`engGameInit(){ window.__refEngineInitFired=true; }`)。五回调函数定义在 bootRef 作用域(engineInit 须先拿到),实体经 holder 转发到 setupReal 内真实现(仿 `host.js:880-887` 的 `_engStep`/`_renderParticles` holder 范式)。 + - **mockNowMs**:real 下由 `game.update` 内 `mockNowMs += dt*1000` 累加(逻辑帧钟,非墙钟,同 `host.js:526`)。 + - **RAF 门控**:`startRaf/rafLoop`(`main.js:668-691`)仅 stub 调;real 分支不调(引擎内部 RAF 驱动五回调)。 +4.4 输入(real·控制流重构 §12-M4):real 下输入监听**改挂 `LJS.mainCanvas`**(不再硬绑传入的 `opts.canvas`=`#game`;坐标按引擎 canvas rect,仿 `host.js:536-538` `inputTarget = real ? LJS.mainCanvas : canvas`);`toCanvasXY` 的 `getBoundingClientRect` 改读 `LJS.mainCanvas`。监听挂载 + `inputBridge` 取用同样移进 `setupReal`(bundle 就绪后才有 `bundle.inputBridge`)。经 `bundle.inputBridge._emit` 灌受控面;游戏经 `boot.ctx.getInput().on` 订阅(同 `main.js:604-611`,订阅在 game 工厂 `init` 内)。**stub 通道输入逐行不变**(挂 `#game`,`main.js:583-602`)。 + +### 步骤 5:entry.js 引擎入包 +- `entry.js:15` 后增 `import * as LJS from 'littlejsengine';` + `export const Engine = LJS;`(仿 `host-dev/entry.js:21/28`)。**Q4**:引擎 import 只活 entry/host,game 工厂零引擎 import(构建后核 `game-shop.js` 编译产物经 LJS namespace 间接,不直 import)。 + +### 步骤 6:SIZES.md 口径 +- 增"游戏产物(引擎+包装层)"表:ref stub=197KB(不打引擎)/ ref real≈540KB raw min(引擎真入);标 gz 实测(`size.mjs`);注"插件增量法不覆盖游戏产物口径"。 + +### 步骤 7:探针绝不入渠道产物(物理保证) +- `createEngineCaps` 的 `recHook` **默认 no-op**;`engineFactory` 默认不传 recHook。 +- **王蓝莓真玩取证**:取证脚本经 URL/全局开关让 main.js real 分支注入 `recHook = id => window.__engineCalls.push(id)`(仅取证路径)。 +- **渠道产物(未来 P2/P3 出仓 bundle)**:esbuild 入口不引取证注入点 → recHook 恒 no-op → 探针代码无副作用、不挂 `window.__engineCalls`。**P1 阶段 ref-bundle 即取证用**,注 recHook 可接受(ref-bundle 非渠道产物,同 host-bundle 现状 `host.js:202`)。 + +### 步骤 8:e2e driver real 通道改造清单(范式切换·非增量 · §12-M1/M2/M3) + +> **背景(复审 major 2/1)**:现行 `e2e-input.cdp.cjs` driver 主体建在 **`window.__refHost.stepFrames(n)` 手摇逻辑帧** 上(`e2e-input.cdp.cjs:109`,贯穿 `:144` tapAt 内 `stepFrames(3)`、`:236/:277` 营业循环 `stepFrames(20)`),坐标 rect 写死 `document.getElementById('game')`(`:127`),就绪只等 `__refBooted/__refHost`(`:76`)。这三处在 real 通道**整体失效**——必须按下列清单改造,**不是「加个 engine=real 通道断言」的增量**。 + +real 通道 driver 改造(保 stub 通道路径逐行不变,real 走新分支): + +(a) **就绪标记(M1)**:`waitRefBoot` 在 `engine=real` 时改等 **`window.__refBootedReal`**(而非 `__refBooted`)。real 下 `__refBooted`(`index.html:81` bootRef().then 置)会在引擎/game 就绪前被置 true(bootRef 顶层同步段先 resolve),driver 据它驱动会打空/读 undefined → 假失败或间歇绿。host-dev 正是为此引入 `__hostBootedReal`(`host.js:920`,注释「CDP 取证须等 __hostBootedReal 再驱动」`host.js:851/931`)。新分支:`evaluate('({ b: !!window.__refBootedReal, e: window.__refBootError||null, h: !!window.__refHost })')`。 + +(b) **坐标 rect 选择器(M3)**:`initMapping` 的 `document.getElementById('game')`(`:127`)在 `engine=real` 时切到 **`document.getElementById('game-engine')`**(引擎自建可见画布,`host.js:830`;real 下 `#game` 不是可见画布)。否则取错画布/null → 坐标全错。stub 通道仍用 `#game`。 + +(c) **确定性序列范式切换(M2·硬·边界铁律)**:real 通道**禁用 `stepFrames` 手摇**——引擎内部 RAF 已掌帧(装载契约第 1 条「掌帧唯一源=引擎」),game 不暴露/不手摇 `stepFrames`,否则与引擎 RAF 双掌帧违铁律。原「tapAt 后 stepFrames(3)」「循环 stepFrames(20)」改为:**真触摸 → `delay(ms)` 等引擎自然推帧 → 轮询 `__refForensics.state()`/`measures()` 判相位推进**(轮询到目标态或超时)。这与 stub 的「手摇定量推帧」是**范式切换**(harness §5-坑6 已订:逐像素门交 stub 通道独占手摇,引擎通道只证掌帧+渲染落 2D+真玩闭环,不比逐像素哈希)。real 真玩去掉 `mode=evidence`(real 取证不靠 stepFrames,靠真触摸+轮询)。 + +(d) **同屏像素扫描**:合 gate0 G0-7 + a6 A6-5 像素扫描,CANVAS=`#game-engine`,判据见 §8 门②(暖色阈值已开工前坐实,见门②)。 + +--- + +## 7. 边界失败路径 + +| 失败 | 触发 | 处置 | +|---|---|---| +| 引擎 engineInit 失败 | WebGL/资源异常 | 镜像 `window.__refBootError`(仿 `host.js:925`),status 提示"可加 `?engine=stub` 回滚";门④/CDP 据此判 real 失败→回滚 stub | +| **driver 在引擎就绪前驱动(打空/读 undefined)** | real 下 `__refBooted` 早于引擎/game 就绪被置(§12-M1) | `__refBootedReal` 只在 engineInit().then 内 game.init 完成后置;driver real 分支等它(步骤 8a) | +| 资产装载缺失 | 图片/mp3 缺 | 游戏 init 走程序化回退(保 `main.js:165` 容错),照常启动;取证 `waitAssets` 超时如实记录续跑(`e2e-input.cdp.cjs:87`) | +| 粒子渲到分离画布(假阳性) | 绘制面未真改 mainContext | 门② 同屏像素判据逮(游戏画布粒子命中=0=fail),见 §8 门② | +| AudioContext headless suspended | 无手势 | `--autoplay-policy=no-user-gesture-required`(harness §5);门② 音频降软门(同 `a6-engine-wiring.cjs:89`) | +| engine-caps 提升回归 | 探针/短路语义漂移 | 步骤 2.3 门0 8/8 + real 门 6/6 回归卡住;不过即 revert engine-caps.js | +| 受控面被改 | 误扩 6 项 | code review + 契约 diff 卡死(硬约束) | + +--- + +## 8. 验证方法(harness 门) + +> 全在 mini-desktop 跑(6c6g 禁 chrome)。**部署口径=沿现行 `ref-e2e-runbook.sh` 的 git-clone-scratch(复审改·§12-A4)**:`git clone --depth 50 --branch dev/2.0.0 ssh://git@101.200.34.71:2222/...` → `git checkout <精确SHA>` 到 scratch(`ref-e2e-runbook.sh:20-21`)→ esbuild 锁参构建 → IP gate → systemd-run serve(`static-serve.cjs` 根=`games/wanglanmei-ref` 端口 4320,`:44`)+chrome 两单元(`:47`)→ 就绪 10×0.5s + serve 5×6s 在线 + CDP 10×2s 两段探活(`:55/:59/:64`)→ driver(`:69`)→ journal 归档 → 停单元。chrome 旗标含 `--autoplay-policy=no-user-gesture-required`(`ref-e2e-runbook.sh:48`,harness §5)。**real 版只需在 runbook 的 driver URL 加 `&engine=real` + 构建后 `grep -q ParticleEmitter dist/ref-bundle.js` 核引擎入包**。**严禁裸 `./` rsync / tar-pipe 整树**(6c6g cwd 易重置到仓根吞整树,harness §6-律4);git-clone-scratch 是 ref runbook 既有口径,勿改成 host-dev/a6 的 tar-pipe staging 配方。 + +**构建命令**(mini-desktop,cwd=`game-runtime`): +``` +node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef +# 此时引擎真入包 ~540KB;核对:grep -q ParticleEmitter dist/ref-bundle.js +``` +**真玩 URL**:`http://localhost:4320/?engine=real&seed=305441741`(取证模式 `&mode=evidence` 用 `__refHost` 确定性驱动;真人交互去掉 mode)。 + +| 门 | 判据(可执行阈值) | driver / 口径 | +|---|---|---| +| **① 掌帧** | `window.__engine.snapshot()` 两采样间隔 500ms,`frame` 增量 ∈ `[20,45]`(@60fps ~30,排单步假推进+失控空转) | 复用 gate0 G0-2(`gate0-engine-takeover.cjs:75`);ref driver 加同款断言(ref 暴露 `window.__engine`) | +| **② 同屏像素(堵假阳性)** | 截 `#game-engine`(引擎接管后 DOM 可见、=mainContext 的 canvas,`host.js:830`)。**两类命中均 >0**:(a) **游戏美术·暖色(开工前已坐实,非占位)**:店面暖色主色像素命中数 `warmHits > N`(N=200,按 780×1688 抽样网格调,详见下「门②(a) 暖色判据」);(b) **引擎粒子·亮色**:burst 白 `brightHits>0` ∧ `maxCh>80`(a6 A6-5 口径,`a6-engine-wiring.cjs:111`)。**为何这证合并非两画布**:若粒子渲到另一张分离 canvas,则截 `#game-engine` 时粒子命中=0(粒子不在这张)→ (b) fail;若游戏渲到 #game 旧画布、引擎渲 mainContext,则截 `#game-engine` 时游戏命中=0→ (a) fail。**两类同时命中 ⟺ 两者真在同一张 `#game-engine` 上** | ref driver 新增(合 gate0 G0-7 + a6 A6-5 像素扫描,CANVAS=`#game-engine`) | +| **③ 门②真接线** | 取证注 recHook 后,`window.__engineCalls` 含 `particles.spawnEmitter` call-ID(经 particles-juice 真实 spawnEmitter,非直调引擎) | 复用 a6 A6-2(`a6-engine-wiring.cjs:79`);ref driver 触发游戏内真粒子(serve_ok 飞行/burst)后读 `__engineCalls` | +| **④ CDP 真玩 + 四件套** | CDP 真触摸逐 zone 真玩闭环(开店→进货→营业→结算→升级),首日接待数>0、serve_ok>0、到达结算、升级 `upgrades.speed1=true`;四件套 evidence(输入轨迹 JSONL/截图+canvas 哈希/probe JSONL/checklist JSON)落 `evidence/round-N/`。**zone 名以 `main.js` computeZones 为唯一真相**(serve-/inc-/up-,`render.js:967/975/981`),驱动器猜语义名必踩坑(harness §2-律4) | 扩 `e2e-input.cdp.cjs` 加 real 通道(步骤 8:`&engine=real`、选择器 `#game-engine`、等 `__refBootedReal`、真触摸+轮询,harness §2-律2) | +| **⑤ stub 回归** | `?engine=stub&mode=evidence` 菜单帧双开哈希一致(清档 reload 后比,harness §5-坑6);ref 49 单测仍绿 | 现有 `e2e-input.cdp.cjs` 门 B(stub 通道不变) | +| **⑥ 提升回归** | engine-caps 提升后:host-dev 门0 8/8 + real 门(a6)6/6 逐项不变 | 步骤 2.3 | +| **⑦ console 干净** | `uncaught=[]`(real 通道,含未捕获 error/unhandledrejection) | ref driver(`main.js:90-96` uncaught 归集) | + +> **门④ driver 范式(复审 M2)**:real 通道 driver=真触摸+轮询(**禁 stepFrames 手摇**,否则双掌帧违铁律),坐标走 `#game-engine` rect,就绪等 `__refBootedReal`——见步骤 8。zone 名以 `main.js computeZones` 为唯一真相(`serve-`/`inc-`/`up-`,`render.js:967/975/981`),驱动器猜语义名必踩坑(harness §2-律4)。 + +### 门②(a) 暖色判据(开工前坐实 · 复审 major 3) + +> **背景**:gate0 G0-7 的 `coloredHits` 判据是 **青蓝**(`b>120 && b>r+30`,`gate0-engine-takeover.cjs:111`),a6 A6-5 是 **亮度**(`max(r,g,b)>80`,`:111`,注释明告青蓝口径对引擎白粒子失效)——二者均**不适配**王蓝莓暖色店面(R 高 B 低,与青蓝正相反)。harness §6-2 已订「换引擎能力后像素门颜色假设必同步重核」。故门②(a) 判据**开工前从 `palette.js` 坐实**,不留执行时占位。 + +**已坐实色域**(源:`games/wanglanmei-ref/src/palette.js:23-41` BASE 暖调家族,已读真源核对): + +| 用途 | 色名 | RGB | R−B | 命中 | +|---|---|---|---|---| +| 柜台/货架木带 | `wood` | (169,118,79) | 90 | ✓ | +| 深木描边 | `woodDark` | (124,84,55) | 69 | (R<140 不计,wood 覆盖) | +| 砖橙按钮/警示 | `brick` | (201,111,74) | 127 | ✓ | +| 金/钱强调 | `gold` | (232,169,66) | 166 | ✓ | +| 琥珀进度条 | `amber` | (240,196,117) | 123 | ✓ | +| 差评红 | `danger` | (178,72,54) | 124 | ✓ | +| **纸底(须排除)** | `paper` `#f6e7c9` | (246,231,201) | 45 | ✗ 反例 | +| 高亮纸(须排除) | `paperHi` | (255,246,227) | 28 | ✗ 反例 | +| 暖天光(须排除) | `sky` | (243,217,168) | 75 | ✗ 反例 | +| 奶油粒子亮色(须排除) | `cream` | (255,243,220) | 35 | ✗ 反例(近白,归引擎粒子域) | + +**判据(暖木/砖/金色域,排纸底与暖天光)**: +``` +warmHit ⟺ (r > 140) && (r - b > 85) && (g < r) +``` +- 命中:wood(R169,R−B90,118<169)✓ / brick(201,127,✓) / gold(232,166,✓) / amber(240,123,✓) / danger(178,124,✓)——店面木带/货架/按钮/HUD 金额(`render.js` 大量 `pal.c('wood'|'brick'|'gold'|'amber')`,已读真源:`render.js:120/165/179/200/253/308` 等); +- 排除:paper(R−B45<85)✗ / paperHi(28)✗ / sky(75<85)✗ / cream(35)✗——纸底/天光/近白粒子均落空,堵「把暖纸背景误判成店面美术」的假阳性; +- 鲁棒性:rush 压力态色板经 `shiftHsl(hue −10, sat 1.18, light 0.9)`(`palette.js:57`)把暖色**更暖**(R−B 进一步拉大、饱和升),仍稳在色域内,不会因 rush 漂出; +- 阈值 `warmHits > 200`:扫 780×1688 backing(或抽样网格,与 gate0/a6 同扫法),店面木带+货架+按钮+招牌占屏面积远超 200 抽样点;过窄(真渲也判 0=假阴性)与过宽(纸底误命中=假阳性)两侧均已由上面色域不等式+反例锚定。**N 在 driver 落地时按实际抽样网格密度微调,但色域不等式不动**(不等式是堵假阳性的客观锚,开工前定死)。 + +--- + +## 9. 完成条件 + +- 装载契约 **`game-runtime/src/core/game-host.d.ts`** 落盘(§12-A1)+ `contracts/README.md` 指针登记(不折进跨仓 8 类);只 `import type { PluginContext } from './api.d.ts'`、`api.d.ts` 零改(diff 证)。 +- game 工厂 `game-shop.js` 为**独立文件、默认导出 `GameHostFactory`、零引擎 import**(非 main.js 内联,§12-A3 硬约束)。 +- `host-dev/engine-caps.js` 提升完成;host.js import 它;**门0 8/8 + real 门 6/6 回归逐项不变**(evidence 证)。 +- ref `?engine=real` 通道:门① 掌帧 ∈[20,45] ∧ 门② 同屏(`#game-engine` 上游戏美术 `warmHits>200`(§门②(a) 坐实暖色判据)+ 引擎粒子 `brightHits>0∧maxCh>80` 双命中)∧ 门③ `particles.spawnEmitter` call-ID ∧ 门④ CDP 真玩闭环(**真触摸+轮询范式,非 stepFrames 手摇**)+四件套 ∧ 门⑦ console 零未捕获——**全绿**。 +- real 通道就绪标记 `__refBootedReal` 接线正确(driver real 分支等它,§12-M1);bundle 在 engineInit().then 内以 `context2d=mainContext` 建(§12-M4)。 +- ref `?engine=stub` 门⑤ 逐像素双开哈希一致 + 49 单测绿(stub 通道行为逐行不变)。 +- SIZES.md 增游戏产物口径(gz 实测)。 +- 受控面 6 项契约零改(硬约束守住)。 +- 人在 mini-desktop 浏览器 `?engine=real` 真玩一局(开店→营业→结算→升级),截图归档。 + +--- + +## 10. 回滚 + +- **代码**:`?engine=stub` 与今天 `main.js` 逐行等价;real 过不了→用户/取证走 stub,既有交付零连累。 +- **契约**:`game-runtime/src/core/game-host.d.ts` additive(落 src/core,§12-A1),移除不破存量;受控面零改。 +- **模块**:engine-caps.js 提升若回归→git revert 该文件 + host.js import 行(host.js 退内联闭包),ref real 自带 engineFactory 经 createEngineCaps 不连累。 +- **构建**:ref-bundle stub 版(197KB,不打引擎)随时可重出,回退引擎入包。 + +--- + +## 11. 风险与假设(诚实标注) + +- **[verified]** 装载契约全仓不存在(grep 零命中);ref 游戏层 file:line(`main.js:112/583-602/618/621/648/668-691`,`stepFrames` 手摇 `:664/:109`);host.js real 范式(`host.js:323-324/353-371/379-381/488-490/536-538/830/840/904/917-920/930-932`,缺省 real `:340`);index.html `__refBooted` 置位时机(`index.html:80-81`);driver 现状(`e2e-input.cdp.cjs:76/109/127/144/236/277`);makeEngineCaps 闭包(`host.js:197-318`)+ engineFactory 注入(`host.js:359`);burst colorStart 白(`particles-juice/impl.js:217`,gate0 青蓝 `:111` / a6 亮度 `:111`);palette 暖色 BASE(`palette.js:23-41`,rush shiftHsl `:57`);zone kind 枚举(`render.js:967/975/981`);ref runbook git-clone-scratch(`ref-e2e-runbook.sh:20-21/44/47/48`);contracts 跨仓 SSOT 定位(`contracts/README.md:1-3` §1,含拆仓计划);sdk-interface.d.ts 自洽无 game-runtime import(先例)。 +- **[verified·复审降级]** ref entry 加引擎 import 能被 esbuild 解析入包——host-dev/entry.js 末行已 `import * as LJS from 'littlejsengine'` 且 host-dev real 门 6/6 已过(同 monorepo node_modules),已被既有事实强力支撑;作常规构建核对(`grep -q ParticleEmitter`,步骤 0),非阻断门(§12-A5)。 +- **[inferred]** ref real ~540KB 不触 B1 入场券——基于 host-dev entry 544KB 实测 + gz 量级;P1 用 `size.mjs` 实测游戏产物 gz 坐实(步骤 6)。 +- **[已裁·非待确认]** game 工厂**必须**独立文件 `game-shop.js`、默认导出、零引擎 import(§12-A3 硬约束,不再留「可 main.js 内联」口子)。 +- **[已裁·非占位]** 门②(a) 暖色判据已开工前从 `palette.js` 坐实具体色域(`r>140 && r−b>85 && g 复审口径:execution 是落地契约 + 高风险(立新契约 + 改真游戏主循环),deep 校准全程读真源码、凡引代码事实带 file:line。无 fatal;4 major 全纳入、5 项不明假设逐条处置。下表为各条裁决与依据,正文已落地。 + +| 编号 | 复审条目 | 裁决 | 落地位置 / 依据 | +|---|---|---|---| +| **M1** | real 就绪标记缺失(`__refBooted` 早于引擎/game 就绪被置,driver 据它打空/flaky) | **纳入·硬约束**:新增 real 专属 `window.__refBootedReal`(只在 `engineInit().then` 内、game.init 完成后置);driver real 分支改等它 | 步骤 4.3(setupReal 末置 `__refBootedReal`)+ 步骤 8a(driver 等它)+ §7 失败路径;依据 `index.html:81`/`host.js:920/851/931`/`e2e-input.cdp.cjs:76` | +| **M2** | stepFrames 手摇范式在引擎掌帧下失效(且沿用=双掌帧违铁律);改造被低估为「加通道断言」 | **纳入·范式切换**:real driver 禁 stepFrames,改真触摸+`delay`等引擎自然推帧+轮询状态;明列这是相对 stub 的范式切换非增量 | 步骤 8c(确定性序列范式切换)+ §8 门④范式注 + IDL 不可破约定 1 推论;依据 `e2e-input.cdp.cjs:109/144/236/277`、harness §5-坑6 | +| **M3** | 门②(a) 暖色阈值与 gate0/a6 青蓝/亮度口径相反,却留执行时占位 | **纳入·开工前坐实**:从 `palette.js` BASE 取定暖色色域不等式 `r>140 && r−b>85 && g **状态**:review 版 · 2026-06-13 · 给创始人决策(结论先行,不写代码级执行细节,多用 Mermaid)。**已过对抗复审**(2026-06-13 deep·全程读真源码):**无致命项**;4 重大项 + 5 项不明假设已全部纳入 execution 版钉死(逐条裁决见 execution §12)。readiness = **ready-with-caveats**(仅 1 个非阻断 open item:契约工件命名,见 §8)。 +> **定位**:本文是 Runner v2 全弧 **P1 相**(第一相)的评审版。母梁见 [`2026-06-13-runner-v2-arc-review.md`](./2026-06-13-runner-v2-arc-review.md) §5.1。P1 的任务 = **把"游戏宿主装载契约"(arc 上层契约)用王蓝莓 spike 到真跑在引擎上**;P2/P3 归并入母 spec(见 §7)。 +> **前提(创始人已拍)**:① 推进 P1(全弧第一相);② **O1 装载契约 = 新立 additive 第 9 类契约**(不污染既有插件契约 `PluginContext`),生命周期 = `init/update/render/onInput` + `destroy`,boot 时把引擎 `mainContext` 注入游戏层;③ O2 = P2 合并进母 spec Phase D;④ 不用 fable,一律 opus。 +> **复审改点(结论级,详见 execution §12)**:契约工件**落 `game-runtime/src/core/game-host.d.ts`**(与 `api.d.ts` 同目录,**非** contracts/——contracts 是跨三仓五工位业务契约 SSOT,本契约是 game-runtime 内部 host↔游戏工厂接线面,让 SSOT 反向依赖业务源码且拆仓即断);real 通道补 `__refBootedReal` 就绪标记 + driver 范式从「stepFrames 手摇」切到「真触摸+轮询」(引擎掌帧下手摇=双掌帧违铁律);同屏门暖色阈值开工前坐实(不留占位);bundle 延后到 engineInit().then 内以 mainContext 建(防渲染面分裂)。 +> **关联**:边界铁律 [[engine-plugin-boundary-model]]、A0 地基 [[a0-engine-takeover-done]]、harness 配方 `.agents/skills/game-e2e-cdp-harness.md`(§5 门0 / §6 门②)、A0 双通道(`?engine=stub` 回滚)。 + +--- + +## 1. 结论先行 + +**P1 = 给 `wanglanmei-ref` 加 `?engine=real` 通道,用它把"游戏宿主装载契约"(新立 additive 第 9 类契约)spike 实,证三件事并人在 mini-desktop 浏览器真玩:** +1. **引擎真掌帧**(`engineInit` 五回调驱动游戏 `update/render`,废 `main.js` 自管的浏览器 RAF); +2. **游戏美术与引擎粒子同屏可玩**(游戏绘制面从自带 `#game` 2D 画布改引擎 `mainContext`,两面合并成一张画布); +3. **回滚保真**(`?engine=stub` 通道逐行保旧行为 + 逐像素确定性,A0 双通道范式)。 + +**同时把已存在但仍是闭包的 `makeEngineCaps`(`host.js:197-318`)提升为可 import 模块 `host-dev/engine-caps.js`**,host-dev / ref / 未来生成游戏共用同一份引擎能力包装层——**这是"模块化已有件"(搬运),非从零造**(arc §0-F2 已订正原稿"engine-caps.js 不存在=真新增工作")。 + +**装载契约的本质(O1 已拍形态)**:一款游戏 = 一个**纯模块工厂**(零引擎 import,Q4 铁律),暴露稳定生命周期 `init(ctx, mainCtx)` / `update(dt)` / `render(g)` / `onInput(ev)` / `destroy()`;其中 `render(g)` 的 `g` = 引擎 `mainContext`(boot 时注入),插件能力仍经下层已冻结的 `ctx.getEngine()` 拿。该契约落 **`game-runtime/src/core/game-host.d.ts`**(与既有 `api.d.ts` 同目录,复审改点;理由见上「复审改点」),与 `api.d.ts` 是 **additive 关系**——**受控面 6 项契约零改**(`getContext2d/getInput/getAudioContext/getEngine`,`api.d.ts:269/281/286/303`)。**game 工厂必须是独立、可被 P3 复制的实现**(独立文件默认导出、零引擎 import,禁 main.js 内联糊过——这是 P3 生成 agent 的真实参照靶,复审钉死的硬边界)。 + +> ⚠️ **为何必须新立、不能借 `PluginContext`**:ref 的游戏层(`core.update`/`drawFrame(g)`,`main.js:621/648`)**不是插件**、不走 `PluginContext`;今天全仓没有"游戏整体↔引擎 `mainContext`"的约定面(`grep GameModule/装载契约` 在 `contracts/`+`src/` **零命中**,已复证)。`getEngine()`(`api.d.ts:303`)是**插件**拿引擎能力的面,凑不上"游戏 update/render 主体怎么挂引擎"。这正是装载契约要补的缺口——它与 `PluginContext` 一个都不能替另一个(边界模型"同一职不留两条并行路")。 + +--- + +## 2. 装载契约决策(O1 已拍) + +| 决策点 | 已拍结论 | 理由 / 证据 | +|---|---|---| +| **归属** | **新立 additive 第 9 类契约**,不折进 `api.d.ts` 插件面 | 避免污染插件契约语义,与边界模型"一职一路"一致;`PluginContext`=插件面、`GameHost`=游戏面,两者正交(arc O1 建议①)。 | +| **落点(复审改)** | **`game-runtime/src/core/game-host.d.ts`**(与 `api.d.ts` 同目录),**非** `contracts/` | contracts/ 是跨三仓五工位的**业务契约** SSOT(`contracts/README.md` §1,含拆仓计划);本契约是 game-runtime 内部 host↔游戏工厂接线面,消费方只在 game-runtime,落同目录 import 方向顺(`./api.d.ts`);落 contracts 会让 SSOT 反向相对 import `../game-runtime/src`,违「契约即 SSOT」且拆仓即断(execution §12-A1)。 | +| **生命周期粒度** | `init(ctx, mainCtx)` / `update(dt)` / `render(g)` / `onInput(ev)` / `destroy()` 五件 | `init/update/render/onInput` 对齐引擎五回调(`engGameInit/Update/Render`,`host.js:883-887`);`destroy` 补卸载卫生(P2 装载多游戏时复位)。其余(`resize/onVisibility`)additive 后补。 | +| **绘制面约定** | `render(g)` 的 `g` = 引擎 `mainContext`,**boot 时注入游戏层** | A0 已证 `setGLEnable(false)` 后引擎纯 2D 渲进 `mainContext`(`host.js:904/919`);游戏绘制面改它即与引擎粒子同屏(消除渲染面分裂)。**不借 `PluginContext`**。 | +| **能力获取** | 插件能力经下层已冻结 `ctx.getEngine()`;游戏绝不 `import littlejsengine` | Q4 铁律:引擎 import 只活 entry/host(`entry.js:18-21`/`host.js:44`)。 | + +**装载关系图(游戏 ↔ host ↔ 引擎)**: + +```mermaid +flowchart TB + subgraph host["集成段 host 层(唯一 import 引擎处·Q4 铁律)"] + EI["LJS.engineInit 五回调
(引擎内部 RAF 驱动)"] + EC["engine-caps.js 模块
(提升自 host.js:197 闭包·非从零)
particles/audio.synth/math 三面薄包装"] + BOOT["bootRef real 分支
setGLEnable(false)+建 engineFactory
+ 注入 mainContext 给游戏层"] + end + subgraph game["游戏侧(装载契约·零引擎 import·新立第9契约)"] + GH["game.init(ctx, mainCtx)
game.update(dt)
game.render(g=mainContext)
game.onInput(ev)
game.destroy()"] + end + subgraph low["下层契约(已冻结·复用·零改)"] + PC["PluginContext.getEngine()
受控面 6 项 + 引擎能力面"] + end + EI -->|gameUpdate→| GH + EI -->|gameRender→ 引擎 mainContext| GH + BOOT -->|注入 mainCtx| GH + EC -->|engineFactory| BOOT + PC -. 插件能力 ctx.getEngine() .-> GH + GH -. 禁 .->|import littlejsengine| X1["❌ Q4 违例"] +``` + +--- + +## 3. 同屏方案与最大风险 + +### 3.1 同屏方案(real 通道改造主干) + +| 改造点 | stub 通道(保旧,回滚锚) | real 通道(新增,引擎掌帧) | +|---|---|---| +| **掌帧源** | 浏览器 RAF + `mockNowMs`(墙钟近似),`main.js:668-691` | 引擎 `engineInit` 五回调;RAF 旁置/门控;`mockNowMs` 改逻辑帧钟(每逻辑步 +FIXED_DT×1000) | +| **逻辑段** | `frame(dtReal)` 内 `core.update/bundle.tick/applyEvents/fx.step` | 同段移入 `game.update(dt)`,由 `gameUpdate` 回调驱动 | +| **绘制段** | `drawFrame(g)`+`palettePost.renderPost(g)`,g=`#game` 2D | 同段移入 `game.render(g)`,g=引擎 `mainContext`(boot 注入),由 `gameRender` 回调驱动 | +| **插件受控面 context2d** | host `prepareCanvas(#game)` 的 ctx | 快照引擎 `mainContext`(`createHostDevContext` 的 `context2d` 改它) | +| **粒子能力** | `engineFactory` 未传 → `getEngine()`=null → 粒子 `NOOP_HANDLE`(无粒子) | 经 `engine-caps.js` 建 `engineFactory` → 粒子真经引擎 `ParticleEmitter` 自渲落 `mainContext` | + +### 3.2 最大风险(先标在最前) + +| 风险 | 等级 | 缓解 / 回滚 | +|---|---|---| +| **真渲同屏过不了 / 假阳性(粒子渲到另一张分离画布,游戏画布无粒子命中)** | **P0** | 这是 P1 第一道真门,也是全弧第一道门。判据必须堵死假阳性:**截取引擎接管后 DOM 可见那张 canvas(=`mainContext` 的 canvas,A0 已给它打 id `#game-engine`,`host.js:830`),断言同一张画布上既有游戏美术像素命中(店面暖色主色)又有引擎粒子像素命中(burst 白 maxCh>80)**。**复审改(M3)**:店面暖色阈值**开工前已从 `palette.js` 坐实具体色域**(`r>140 && r−b>85 && g200`,色域 `r>140 && r−b>85 && g80`、`brightHits>0`);粒子渲到另一画布则游戏画布命中=0=fail | a6 A6-5(`maxCh/brightHits`)+ 暖色域自 `palette.js:23-41`(execution §8「门②(a)」) | +| **③ 真接线门②** | `window.__engineCalls` 含 `particles.spawnEmitter` call-ID(插件运行时真调引擎) | a6 A6-2(`a6-engine-wiring.cjs:79`) | +| **④ CDP 真玩** | CDP 真触摸逐 zone 真玩(开店→进货→营业→结算→升级)+ 四件套 evidence;**driver 范式=真触摸+轮询,禁 stepFrames 手摇·M2**;rect 选择器 `#game-engine`、就绪等 `__refBootedReal`;zone 名以 `main.js` `computeZones` 为唯一真相 | ref e2e 扩 real 通道(execution 步骤 8)+ harness §2-律4 | +| **⑤ stub 回归** | `?engine=stub` 逐像素确定性回归仍绿(双开哈希一致) | ref e2e 门 B + harness §5-坑6 | +| **⑥ 提升回归** | engine-caps 提升后门0 8/8 + real 门 6/6 逐项不变 | gate0 + a6 | +| **⑦ console 干净** | console 零未捕获错误 | ref e2e(`uncaught=[]`) | + +--- + +## 5. 回滚 + +- **代码回滚**:`?engine=stub` 通道与今天 `main.js` 行为逐行等价(RAF + `#game` 2D 画布 + `mockNowMs` 墙钟)。real 通道过不了 → 用户/取证一律走 stub,stub 既有交付(T1b-α 49 单测 + e2e 四件套)零连累。 +- **契约回滚**:装载契约 `game-host.d.ts` 是 additive 新增工件,受控面 6 项零改 → 移除新增不破存量。 +- **模块回滚**:engine-caps.js 提升若引入回归(门0/real 门红),可临时让 host.js 退回内联闭包(git revert 该文件 + host.js import 行),不连累 ref real 通道(ref 自带 engineFactory 经 createEngineCaps)。 + +--- + +## 6. 爆炸半径 + +| 触及 | 文件 | 性质 | +|---|---|---| +| **ref 主循环重做** | `games/wanglanmei-ref/{entry.js:15, src/main.js:112/583-602/617-691/648}` | real 通道改造(stub 保旧);含输入改挂 mainCanvas(M4) | +| **装载契约新工件** | **`game-runtime/src/core/game-host.d.ts`**(新增,复审改落点)+ `contracts/README.md`(仅指针登记) | additive 第 9 类契约(game-runtime 内部) | +| **engine-caps 模块化** | `host-dev/engine-caps.js`(新增,搬 `host.js:197-318`)+ `host-dev/host.js`(改 import) | 搬运已有件,语义零变 | +| **ref 装载适配** | **`games/wanglanmei-ref/src/game-shop.js`(新增·硬独立文件,A3)**:拆出符合装载契约的 game 工厂(默认导出、零引擎 import,禁 main.js 内联);entry import 引擎 | real 通道接线 | +| **ref 就绪标记** | `games/wanglanmei-ref/index.html`(real 通道接 `__refBootedReal`,M1) | 就绪信号(stub 仍用 `__refBooted`) | +| **SIZES 口径** | `SIZES.md` 增"游戏产物(引擎+包装层)"新表 | 文档(与插件增量分离) | +| **受控面 6 项契约** | `api.d.ts:269/281/286/303` | **零改(硬约束)** | +| **harness driver** | `e2e-input.cdp.cjs` real 通道**范式切换**(真触摸+轮询/选择器 `#game-engine`/等 `__refBootedReal`/同屏暖色判据);非「加断言」增量(M1/M2/M3) | 取证范式重写 | + +**P1 不触及**:后端发布主链 / game-studio / feed / 生成管线(那些是 P2/P3,归母 spec)。 + +### 双通道全景(real / stub) + +```mermaid +graph TB + URL{"?engine="} + URL -->|real(新增·引擎掌帧)| R0 + URL -->|stub(保旧·回滚锚)| S0 + subgraph R["real 通道"] + R0["entry import LJS
引擎真入 ref-bundle ~540KB"] --> R1["setGLEnable(false)
engineInit 五回调"] + R1 --> R2["createEngineCaps 建 engineFactory
注入 createHostDevContext"] + R2 --> R3["game.update←gameUpdate
game.render(mainContext)←gameRender
mockNowMs=逻辑帧钟·废 RAF"] + R3 --> R4["验收:门①掌帧/②同屏/③call-ID/④真玩/⑦console"] + end + subgraph S["stub 通道"] + S0["不 import 引擎
ref-bundle 197KB"] --> S1["prepareCanvas(#game) 2D
浏览器 RAF + mockNowMs 墙钟"] + S1 --> S2["frame(dtReal) 原样
drawFrame(g=#game)"] + S2 --> S3["验收:门⑤逐像素双开哈希一致"] + end + R4 -.过不了回滚.-> S3 +``` + +--- + +## 7. P2 / P3 归并(O2 已拍) + +- **P2(泛化宿主:装载任意 GamePackage)合并进母 spec `2026-06-13-T1b-β引擎真接线与Runner-v2-execution.md` Phase D**(O2 已拍)。P1 验实的装载契约 = P2 的产品化基底:母 spec Phase D 的"ref 经发布主链入 feed"升级为"engine-aware 包(按装载契约 + `packageUrl` 加载,`game-package.schema.json:66`)入 feed 跑引擎真玩"。P2 不在本 spec 展开。 +- **P3(W-G1 生成游戏按同一装载契约产出)** 归 W-G1 自身 spec:agent 写码于插件库、产装载契约 bundle、20 基准 bake-off。P1 的装载契约 IDL 是 P3 生成 agent 的**唯一产出靶**。 + +--- + +## 8. 待确认 / 风险与假设(诚实标注) + +> **对抗复审结论(2026-06-13·deep·全程读真源码)**:**无致命项**。装载契约 IDL 五件(init/update/render/onInput/destroy)形态合理、与引擎五回调对齐准确(`host.js:883-887`);additive 关系成立(受控面 6 项零改 `api.d.ts:269/281/286/303/405`);双通道回滚有 A0 实证。**4 重大项已全部纳入 execution 版钉死**(real 就绪标记 M1 / driver 范式切换 M2 / 暖色阈值坐实 M3 / bundle 延后建 M4),均为「会致执行 agent 临场返工或门假绿/假红的硬装配问题」,不翻盘但必须在 execution 钉死。**5 项不明假设逐条处置**(落点改 game-runtime/src·缺省分叉显式·game 工厂硬独立·部署 git-clone-scratch·步骤0 降常规核对)。逐条裁决见 execution §12。 +> **唯一 open item(非阻断)**:装载契约工件命名(见末条)。 + +**缺省值分叉(复审 A2·显式声明此分叉有意为之)**:host-dev 缺省 `engine=real`(`host.js:340`),ref 缺省 `engine=stub`(`main.js:82` play 模式走 stub 通道)。**有意不统一**:真玩取证显式带 `&engine=real` 更稳、stub 兜逐像素回归;但须知 P3 复制 ref 作模板时,缺省 stub=「新游戏默认不上引擎」是过渡现状,**引擎是正路**(boot 注入装载契约)——这点 P3 spec 须承接。 + +- **[verified]** 装载契约全仓不存在(`grep GameModule/装载契约/engineInit` 在 `contracts/`+`src/` 零命中);既有契约 = `PluginContext`(`api.d.ts:269/281/286/303`,6 受控面 + `getEngine()`)+ `packageUrl`(`game-package.schema.json:66`)。 +- **[verified]** `engine-caps.js` 文件不存在,但内容已大半存在:`makeEngineCaps` 闭包(`host.js:197-318`)已包装 particles/audio.synth/math 三面并已注入 `engineFactory`(`host.js:359`);`engine-math.js` 已独立成模块(`host.js:47` import)。→ 提升=搬运。 +- **[verified]** ref 游戏层 `core.update`(`main.js:621`)/`drawFrame(g)`(`main.js:648`,g=`prepareCanvas(#game)`)/RAF(`main.js:668-691`)/`mockNowMs` 步进钟(`main.js:618`);`createHostDevContext` 仅 4 键无 `engineFactory`(`main.js:112`)→ 粒子今天全 `NOOP_HANDLE`。 +- **[verified]** real 通道引擎掌帧范式已在 host.js 跑通:`engineInit` 五回调(`host.js:917`)+ `setupAfterEngine(LJS.mainContext)`(`host.js:919`)+ **就绪标记 `__hostBootedReal` 在 then 内 game 就绪后置(`host.js:920`,注释明告 CDP 须等它再驱动 `:851/931`)**+ bundle 在 then 内建(`host.js:323-324/353-371/379-381`)+ 输入挂 mainCanvas(`host.js:536-538`)+ `setGLEnable(false)`(`host.js:904`)+ 双时钟 `window.__engine`(`host.js:840`)+ `mainCanvas.id='game-engine'`(`host.js:830`)。**这些 host-dev 已踩并解决的坑(M1/M4),ref 必须继承(execution §12)。** +- **[verified]** burst 粒子 `colorStart` 缺省白 `{r:1,g:1,b:1,a:...}`(`toEngineSpec`,`particles-juice/impl.js:217`),故引擎粒子像素门判 `brightHits/maxCh`(青蓝口径已对引擎渲染失效,harness §6-2);游戏美术暖色门另判暖色域(M3,自 `palette.js:23-41`)。 +- **[verified·复审降级]** ref 接引擎 import 能入包——host-dev/entry.js 末行已 import 引擎且 real 门 6/6 已过(同 monorepo node_modules);常规构建 `grep ParticleEmitter` 核对(A5),非阻断门。 +- **[inferred]** ref ~540KB 不触 B1 入场券——基于 host-dev entry 544KB 实测 + gz 量级;P1 用 `size.mjs` 实测游戏产物 gz 坐实。 +- **[assumption]** "好玩基线 v2 五要素"精确判据不在本 spec 范围(P3 验收门事);P1 只证装载契约可让真游戏真跑在引擎上同屏可玩。 +- **[open item·留创始人·非阻断]** 装载契约工件命名 `game-host.d.ts`(暂名,arc 称"游戏宿主装载契约");若创始人偏好其它名(如 `game-loadable.d.ts`),执行版改名即可,**形态/落点(game-runtime/src/core)不变**。改名零成本,不阻断执行。 diff --git a/docs/agent-specs/2026-06-13-WG1-20经典轻游戏基准-review.md b/docs/agent-specs/2026-06-13-WG1-20经典轻游戏基准-review.md new file mode 100644 index 00000000..230d4523 --- /dev/null +++ b/docs/agent-specs/2026-06-13-WG1-20经典轻游戏基准-review.md @@ -0,0 +1,184 @@ +# 《W-G1 · 20 经典轻游戏基准》设计稿(review 版) + +> **状态**:review 版 · 2026-06-13 · 只读调研产出(未改任何运行时代码)。 +> **定位**:本基准是"**便宜模型(M3/M2.7/DS-V4)+ 当前引擎(LittleJS)/插件库**"能产出什么的**实测靶**。20 款经典轻游戏铺满机制谱系,既验生成管线打通,又压测能力上限、**暴露引擎/插件库短板**。 +> **调研口径**:覆盖判定**只认源码导出**(api.d.ts 手写门面 + impl.js 实际 return 面 + host.js `makeEngineCaps()`),不认"我以为有"。证据锚点全部带 file:line。 +> **关联**:边界模型见 memory `engine-plugin-boundary-model`;本基准是 Runner v2 全弧 P3(W-G1)的靶集,见 `2026-06-13-runner-v2-arc-review.md`。 + +--- + +## 结论先行(给创始人的三句话) + +1. **能打的多数**:20 款里约 **12 款 ✅当前插件/引擎已直接覆盖**(井字棋/2048/Flappy/打砖块/贪吃蛇/Pong/扫雷/Simon/Snake 类…),便宜模型在现有能力面上应能产出可玩件——这批先做冒烟,验管线。 +2. **缺口集中在 4 类能力**(本基准核心产出):**① 文本/分数 HUD 渲染**(无插件门面)、**② 网格/棋盘状态抽象**(无 grid/tilemap)、**③ 连续碰撞 CCD**(collision 仅离散 MTV,高速小球会穿透)、**④ 通用状态机 FSM**(gamefeel 仅 3 个特化计时器)。这 4 类决定 Tetris/Asteroids/Breakout-高速/Match-3 的产出质量。 +3. **真短板不在"造不出",在"agent 要自己补层"**:上述①②④引擎/插件其实有底层原语或属 agent 生成域(LJS.drawText 存在但未包装、网格状态属 agent 生成域),但**没有插件门面=每款游戏的便宜模型都要重造一遍轮子**→ 一致性差、token 贵、易错。**建议:W-G1 实测后,把高频缺口(文本 HUD / 网格)补成插件门面**,而非让 20 个 prompt 各自硬写。 + +--- + +## A. 事实基:当前能力面盘点(verified,带证据锚点) + +### A.1 插件库实际导出(7 个业务插件 + 1 example) + +| 插件 | 公开能力(源码实际 return 面) | 证据锚点 | +|---|---|---| +| **collision** | 离散几何相交:`circleVsCircle`/`circleVsAabb`/`aabbVsAabb`/**`satVsPolygon`(凸多边形+MTV)**;射线 `raycastCircle`/`raycastAabb`(RayHit);**`SpatialHash`(均匀网格 broadphase 粗筛)** | `collision/impl.js:268/295/337/395/462/500`、`SpatialHash:112-264`;api.d.ts:`Manifold`/`RayHit:80/97` | +| **physics-lite** | 离散运动数学:`integrateProjectile`/`Projectile`(抛体+重力)、`springStep`/`criticalDamping`、`moveWithConstraint`、`clampSpeed`/`applyFriction` | `physics-lite/impl.js:104/127/181/194/241/268/286` | +| **particles-juice** | `spawnEmitter`(preset/config)、`hitStop`、`shakeScreen`、`flashScreen`、`pulseScale`、`getPreset`;渲染输出经受控面偏移 | `particles-juice/impl.js:438/504/524/544/562`、`getPreset:163`;api.d.ts façade `:204-240` | +| **gamefeel** | `InputBuffer`、`CoyoteTimer`、`ComboWindow`、**easing 标准库 11 条**(纯函数,经引擎门面) | `gamefeel/impl.js: InputBuffer:232/CoyoteTimer:319/ComboWindow:390/makeEasingFacade:205`;api.d.ts `:108/154/196` | +| **audio-music** | `playSfx`(blip/thud/chime)、`play`/`loop`/`stop`(BGM)、`setIntensity`/`setIntensityLayering`、`loadSong` | `audio-music/impl.js:438/455/463/467/471/481`;preset `:71-75` | +| **palette-post** | 调色板映射 `applyPaletteMap`/`lerpPaletteMap`、HSL `shiftHsl`/`rgbToHsl`/`hslToRgb`、后处理 `setPost`(vignette/dither/scanline)/`renderPost` | `palette-post/impl.js:142/210/238/62/90/392/412`;dither `:435` | +| **save-progress** | KV 持久化 `get`/`set`/`remove`/`clear`(命名空间隔离+损坏容错+单条上限) | `save-progress/impl.js:219/243/285/294` | +| **runtime-probe** | 取证埋点 `getRecords`/`toJSONL`/`verify`(哈希链)— 非玩法能力,e2e 证据用 | `runtime-probe/impl.js:330/335/340` | + +### A.2 `makeEngineCaps()` 引擎透传真实粒度(host.js,只此三面) + +| 面 | 真实粒度 | 证据锚点 | +|---|---|---| +| **particles** | `spawnEmitter(spec)` 薄包装引擎 `ParticleEmitter`(27 参全映射),像素↔世界阻抗换算 | `host.js:213-273`(构造 `:243-266`) | +| **audio.synth** | `synthSfx`(包 `zzfxG`)/`synthSong`(包 `zzfxM`)→ PCM 样本(不播放,×0.3 播放层补) | `host.js:279-316` | +| **math** | `lerp`/`smoothStep` + `easing` 11 条等价曲线(POWER/BACK/ELASTIC 族),纯函数 | `host.js:204-209` + `engine-math.js:56-87` | + +> 引擎本体(LittleJS)远不止这三面(有 `drawText`/`drawTile`/`tileCollision`/相机等),但**插件墙内只透出这三面**;其余引擎能力**不构成 agent 的能力词汇**(一职一路,见边界 memory)。 + +### A.3 受控面 6 项(api.d.ts:264-304,引擎可换边界) + +`getContext2d()` / `onFrame()` / `getInput()`(归一化输入:pointer 三态+key 两态)/ `getAudioContext()` / `time`(受控时钟)/ `random`(确定性随机)。**输入只有 5 类事件**(`api.d.ts:218`),**无手势识别**(swipe/拖拽需 agent 在生成域自合成 pointer 序列)。 + +### A.4 模板哲学(决定"什么算覆盖") + +`tech-decisions.md:38` 终裁:**玩法/美术/关卡/UI = agent 写码生成域**;插件=能力 API。故"覆盖判定"口径:**该机制所需能力,是否有插件门面或引擎透传面兜底**;纯玩法逻辑(回合制、计分、棋盘状态机)是 agent 自写域,**不算缺口**——除非它需要的底层原语(文本渲染、网格抽象)无门面。 + +--- + +## B. 20 款经典轻游戏基准表 + +> 覆盖判定:**✅** 插件/引擎透传面已覆盖 | **🟡** 引擎有底层原语/属生成域但无门面(agent 须自写补层,高频则建议补门面)| **❌** 缺口(无门面且 agent 自写成本高/易错) +> 难度档:**易**=单机制+静态/网格 | **中**=2-3 机制叠加+实时 | **难**=多机制+连续物理/程序化/高频碰撞 + +| # | 游戏 | 核心机制 | 所需能力清单 | 当前覆盖判定 | 难度 | 一句话生成种子 | +|---|---|---|---|---|---|---| +| 1 | **井字棋** | 3×3 落子棋/胜负判定 | 网格状态、tap、文本"X/O"、胜负 AI | 🟡 网格/文本🟡、输入✅、AI=纯逻辑 | 易 | 3×3 棋盘点击落子 X,AI 落 O,三连判胜 | +| 2 | **2048** | 网格滑动合并/数字 | 4×4 网格、swipe、文本数字、合并逻辑 | 🟡 网格+文本🟡(高频缺口)、swipe=合成、合并=纯逻辑 | 中 | 4×4 数字方块,滑动合并同值,生成新块,显分 | +| 3 | **贪吃蛇** | 网格移动/自增长/自碰撞 | 网格、方向键、自碰撞(网格等值)、计分 | 🟡 网格=agent 自管、输入✅、碰撞=网格逻辑、计分文本🟡 | 易 | 蛇沿网格吃食物变长,撞墙/撞自己结束 | +| 4 | **Flappy Bird** | 重力跳跃/管道避障/无尽 | 重力(抛体)、tap、AABB 碰撞、程序化管道、计分 | ✅ physics 抛体`:104`、collision `aabbVsAabb:337`、输入✅;文本🟡 | 中 | 点击让小鸟上跳,重力下落,穿随机高度管道 | +| 5 | **打砖块 Breakout** | 弹球反弹/挡板/砖块网格 | 弹球物理、AABB/圆碰撞、挡板输入、砖块网格、反弹角 | ✅ collision `circleVsAabb:295`+MTV 反弹、physics 限速;高速球🟡 | 中 | 挡板接弹球击碎砖阵,球随挡板位置改反弹角 | +| 6 | **Pong** | 双挡板/弹球/简单 AI | 弹球、AABB 碰撞、挡板输入、AI 追球 | ✅ collision/physics/输入✅;AI=纯逻辑 | 易 | 左玩家右 AI 各控挡板,漏球对方得分 | +| 7 | **扫雷** | 网格揭示/数字提示/标记 | 网格、左右键 tap、文本数字、洪水填充 | 🟡 网格+文本🟡、输入✅、洪水填充=纯逻辑 | 中 | 点击揭示显周围雷数,右键标记,踩雷结束 | +| 8 | **Simon 记忆** | 序列记忆/颜色按钮/渐进 | 序列状态机、tap、音效反馈、计时 | ✅ audio `playSfx:481`、gamefeel 计时、输入✅;序列=纯逻辑 | 易 | 四色按钮按序闪烁播音,玩家复现,逐轮加长 | +| 9 | **记忆翻牌** | 翻牌配对/网格 | 网格、tap、翻转动画(easing)、配对逻辑 | ✅ easing`:205`/输入✅;网格🟡、图案=agent 绘 | 易 | 牌面朝下,翻两张配对,全配对获胜 | +| 10 | **太空侵略者** | 波次敌阵/射击/碰撞 | 敌阵网格移动、射击 tap、子弹 AABB、波次、HUD | ✅ collision AABB+SpatialHash`:112`、particles 爆炸;文本🟡 | 中 | 炮台左右移动射击,敌阵整体平移下压,清屏过关 | +| 11 | **俄罗斯方块 Tetris** | 下落方块/网格堆叠/消行 | 网格(堆叠态)、方向键旋转移动、消行、计时下落、文本 | 🟡 网格堆叠=agent 自管(**高频痛点**)、输入✅、文本🟡 | 难 | 七种方块下落,旋转堆叠,满行消除,逐级加速 | +| 12 | **小行星 Asteroids** | 惯性飞船/任意角度/程序化碎裂 | 惯性物理、旋转射击、多边形碰撞、程序化分裂、环绕 | ✅ collision `satVsPolygon:395`+RayHit、physics `applyFriction:286`;高速子弹 CCD❌ | 难 | 飞船惯性漂移射击,大陨石击中裂小块,屏幕环绕 | +| 13 | **消消乐 Match-3** | 网格交换/三连消除/下落填充 | 网格、swipe 交换、三连检测、重力下落、连锁、HUD | 🟡 网格+文本🟡(**高频痛点**)、swipe=合成、消除=逻辑、easing✅ | 难 | 8×8 宝石网格,交换凑三连消除,上方填充连锁 | +| 14 | **跳跃者 Doodle Jump** | 垂直无尽/平台跳/重力 | 重力跳(抛体)、左右倾斜、平台 AABB、程序化平台、相机 | ✅ physics 抛体`:104`、collision AABB、coyote`:319`;相机=受控面、文本🟡 | 中 | 持续上跳踩平台,左右移动,平台程序化生成 | +| 15 | **愤怒小鸟(简版)** | 抛射弹道/拖拽蓄力/碰撞倒塌 | 抛体弹道、**拖拽手势**、多体碰撞、弹簧/冲量 | 🟡 physics 抛体✅/弹簧✅、collision MTV✅;**拖拽=agent 合成 pointer 链** | 难 | 拖拽弹弓蓄力发射,抛物线击中结构使其倒塌 | +| 16 | **跑酷 Runner** | 横向无尽/跳跃滑铲/障碍 | 自动横移、tap/swipe 跳铲、AABB、程序化障碍、加速、HUD | ✅ physics 跳、collision AABB、buffer`:232`+coyote;swipe=合成、文本🟡 | 中 | 自动向右跑,点击跳跃避障,距离计分,逐渐加速 | +| 17 | **打地鼠** | 随机出现/限时 tap/计分 | 随机定时生成、tap 命中(点 vs 圆)、计时、计分、反馈 | ✅ random✅、collision 点/圆判、particles 命中;文本🟡 | 易 | 地鼠随机洞口冒出,限时点中得分,超时消失 | +| 18 | **见缝插针** | 旋转/时机 tap/碰撞检测 | 旋转角积分、tap 时机、角度/扇区碰撞、渐进 | ✅ math 角度、collision `raycastCircle:462`、particles;文本🟡 | 中 | 中心圆持续转,点击射针,针不能相撞,逐根加针 | +| 19 | **节奏点击** | 时间窗判定/节奏/连击 | 时间窗(combo)、tap 精度、音效、连击计分、easing | ✅ `ComboWindow:390`+`InputBuffer`、audio、easing;文本🟡 | 易 | 音符落判定线时点击,perfect/good 分级,连击加成 | +| 20 | **弹球变体(多球)** | 多球/道具掉落/物理反弹 | 多体弹球、圆/AABB 碰撞、道具 AABB、屏震闪白 juice、HUD | ✅ collision 圆+AABB、physics 限速、particles `shakeScreen:524`/`flashScreen:544`;高速多球 CCD🟡 | 难 | 挡板接多球击砖,砖落道具(增球/扩板),命中屏震闪白 | + +--- + +## C. 能力覆盖审计(本基准核心价值) + +### C.1 20 款合计考到的能力维度 × 谁兜 + +| 能力维度 | 由谁兜 | 覆盖状态 | 涉及款数 | +|---|---|---|---| +| 离散几何碰撞(圆/AABB/多边形 MTV) | **collision** `:268-431` | ✅ | 4,5,6,10,12,15,17,18,20 | +| 射线命中(RayHit) | **collision** `:462/500` | ✅ | 12,18 | +| 空间粗筛(broadphase) | **collision** `SpatialHash:112` | ✅ | 10,20 | +| 抛体/重力 | **physics-lite** `:104` | ✅ | 4,12,14,15,16 | +| 摩擦/限速/惯性 | **physics-lite** `:286/268` | ✅ | 12,16 | +| 弹簧阻尼 | **physics-lite** `:194` | ✅ | 15 | +| 输入缓冲/郊狼/连击窗 | **gamefeel** `:232/319/390` | ✅ | 8,14,16,19 | +| easing 标准库(11 条) | **gamefeel/math 门面** `:205` | ✅ | 9,11,13,18,19 | +| 粒子/屏震/闪白/命中停顿 | **particles-juice** `:438/504/524/544`+引擎透传 | ✅ | 10,12,17,20 | +| 音效/BGM/强度分层 | **audio-music** `:455-481`+引擎透传 | ✅ | 8,19,20 | +| 调色/后处理 | **palette-post** `:392/412` | ✅(可选) | 全部可选 | +| 存档/进度 | **save-progress** `:219-294` | ✅ | 高分榜可选 | +| tap/方向键输入 | **受控面 getInput** `:218` | ✅ | 全部 | +| 确定性随机/程序化 | **受控面 random** `:35` | ✅ | 4,7,10,11,13,14,16,17 | +| **文本/分数 HUD 渲染** | **无插件门面**(引擎 drawText 未透出) | **🟡 缺门面** | 几乎全部 | +| **网格/棋盘状态抽象** | **无 grid/tilemap 门面**(agent 生成域自管) | **🟡 缺门面** | 1,2,3,7,9,11,13 | +| **连续碰撞 CCD(swept)** | **collision 仅离散 MTV**(grep swept/ccd 全空) | **❌ 缺口** | 5,12,20(高速小目标) | +| **通用状态机 FSM** | **无**(gamefeel 仅 3 特化计时器) | **🟡 agent 自写** | 8,11、回合制类 | +| **手势识别(swipe/拖拽)** | **无门面**(agent 合成 pointer 序列) | **🟡 自合成** | 2,13,15,16 | +| **寻路 A*/导航** | **无**(grep astar/pathfind 全空) | **❌ 缺口(已规避)** | (完整 Pacman 鬼 AI——故未入选) | + +### C.2 点名"会暴露能力缺口"的款 + 缺口性质 + +**❌ 真缺口(无门面+agent 自写成本高/易错):** + +1. **连续碰撞 CCD(swept)** — 暴露款 **#5 高速球/#12 子弹/#20 多球**。 + - 性质:collision 只有离散 MTV/穿透判定(`circleVsAabb:295`),无 swept/toi(grep `swept|ccd|continuous|toi|tunnel` 全空;`impl.js:21` 自述能力止于"MTV/穿透/RayHit")。 + - 后果:**高速小球穿砖/穿墙(tunneling)**,便宜模型多半产出"球偶尔卡进/穿透"的 bug 件。 + - 绕法:agent 用 `raycastAabb:500` 沿速度向量手做 toi(可行但每款重写、易错)→ 高频则补 collision swept 门面。 + +2. **寻路 A*/导航网格** — 本基准**已主动规避**(完整 Pacman 鬼寻路未入选)。 + - 性质:grep `astar|pathfind|navmesh` 全空;`SpatialHash` 只做粗筛非寻路。 + - 结论:**任何需要敌人智能寻路的品类当前造不出**=引擎/插件库明确边界。需要时须新增寻路插件。 + +**🟡 半缺口(底层原语存在/属生成域,但无门面→每款重造轮子):** + +3. **文本/分数 HUD 渲染** — 暴露款 **几乎全部**(2/3/7/10/11/13/16…)。 + - 性质:无任何文本渲染门面(grep `drawText|renderText` 全空);引擎 `LJS.drawText` 存在但未经插件透出,`makeEngineCaps()` 三面不含文本(`host.js:210-317`)。 + - 后果:每个游戏的便宜模型都得自己 `ctx.getContext2d().fillText` 手画 HUD——风格各异、字体/对齐易错。 + - **最高频缺口**:20 款里 ~15 款需文本 → **强烈建议补 text/HUD 门面插件(W-G1 实测后第一优先)**。 + +4. **网格/棋盘状态抽象** — 暴露款 **#1/2/3/7/9/11/13**。 + - 性质:无 grid/tilemap 门面(grep `tilemap|gridMap` 全空);属 agent 生成域,但坐标↔索引/邻接/遍历/合并每款重写。 + - 后果:2048/Tetris/Match-3 网格状态机是核心难度,便宜模型自管易出"合并方向/消行/连锁"bug。 + - 建议:W-G1 观察便宜模型自写网格出错率;若高,补 grid 工具门面。 + +5. **手势识别(swipe/拖拽)** — 暴露款 **#2/13(swipe)/#15(拖拽)/#16**。 + - 性质:受控面 `getInput` 只给 5 类原始事件(`api.d.ts:218`),无 swipe/drag 合成。 + - 后果:agent 须自缓存 pointerdown→move→up 算方向/距离;愤怒小鸟拖拽蓄力尤其易错。 + - 建议:swipe/drag 是触屏轻游戏刚需 → 可补进 gamefeel(已管输入缓冲,自然归属)。 + +6. **通用状态机 FSM** — 暴露款 **#8 Simon/#11 Tetris** 及回合制类。 + - 性质:gamefeel 仅 3 个特化计时器,无通用 FSM 原语。 + - 后果:多状态玩法自写状态切换易乱;属生成域,优先级低于文本/网格(纯逻辑模型相对擅长)。 + +### C.3 一句话审计结论 + +**20 款里 ~12 款能力面齐备(✅),可立即冒烟;余 8 款集中暴露 4 类短板。最痛的不是"造不出",而是"文本 HUD + 网格状态"两个高频能力没有门面——便宜模型 20 个 prompt 各造一遍轮子,一致性、token、出错率三重受损。真·造不出的硬边界只有两条:连续碰撞 CCD(高速穿透)与寻路(已规避)。** + +--- + +## D. 分批/分档执行建议 + +> 原则:先用能力面齐备的易档验管线打通(冒烟),再用中/难档+缺口款压测能力上限、量化便宜模型在缺口处的产出质量。 + +### 批次 1 · 冒烟批(验管线打通)— 6 款 · 全 ✅ · 难度易 +**#6 Pong / #8 Simon / #17 打地鼠 / #9 记忆翻牌 / #3 贪吃蛇 / #19 节奏点击** +- 理由:能力面 100% 齐备,机制单一。目标=证明"便宜模型+插件库+new-api+harness 门"端到端跑通,产可玩件。 +- 验收:管线零阻塞 + ≥4/6 过好玩基线评估门。 + +### 批次 2 · 主力批(验常见机制覆盖)— 8 款 · ✅ 为主 · 难度中 +**#4 Flappy / #5 打砖块 / #10 太空侵略者 / #14 Doodle Jump / #16 跑酷 / #7 扫雷 / #18 见缝插针 / #1 井字棋** +- 理由:覆盖物理/碰撞/波次/程序化/网格主流机制,带少量 🟡(文本/网格/swipe)。 +- 核心产出:**量化文本 HUD/网格/swipe 三个高频 🟡 的便宜模型自写出错率**——"是否要补门面"的决策数据。 +- 验收:≥6/8 可玩;逐款记录 agent 在 🟡 缺口处的补层方式与 bug。 + +### 批次 3 · 压测批(测能力上限+暴露缺口)— 6 款 · 含 ❌/难 🟡 · 难度难 +**#11 Tetris / #13 Match-3 / #12 Asteroids / #15 愤怒小鸟 / #20 多球弹球 / #2 2048** +- 理由:每款压一/多个短板——Tetris/Match-3/2048=网格状态机上限;Asteroids/多球弹球=CCD 缺口;愤怒小鸟=拖拽手势缺口。 +- 核心产出:**证实 C.2 缺口判定**——便宜模型在 CCD/网格/拖拽处的产出质量(穿透 bug?消行 bug?蓄力 bug?),给创始人"引擎短板实测画像"。 +- 验收:**不强求高通过率**(本批测上限);**重点产出缺口实证报告**→ 喂"是否补 text/grid/swipe/swept 门面"的决策。 + +### 跨批观测项(W-G1 真正要回答的问题) +1. 便宜模型(M3/M2.7/DS-V4)在三档的产出质量梯度 → 哪档开始崩,定能力天花板。 +2. 四类短板的实测影响:文本/网格/CCD/拖拽各拖垮多少款 → 排补门面优先级(预判:文本 HUD > 网格 > swipe/拖拽 > CCD swept)。 +3. harness 门的判定有效性:能否稳定逮住"穿透/消行错/不可玩"——门兜底是便宜模型路线的命门。 + +--- + +## 风险与假设(诚实标注) + +- **[verified]** 全部覆盖判定基于源码导出(api.d.ts + impl.js return 面 + host.js makeEngineCaps),证据锚点见 A/B/C 各 file:line。缺口判定用"源码证伪法"(grep 关键原语全空+impl.js 自述能力边界),非按预期名臆断(遵 memory 红线 `engine-plugin-boundary-model` §1.5)。 +- **[inferred]** "便宜模型能产出 ✅ 款的可玩件"是推断非实测——这正是 W-G1 要验的;本基准只确证"能力面齐备",不预断模型产出质量。 +- **[inferred]** 难度档(易/中/难)是按"机制数 × 实时性 × 缺口数"的工程推断,非用户体感难度。 +- **[assumption]** 好玩基线 v2 的具体五要素未在本次检索中定位到原文(`add-game-template.md:7` 仅言"改挂评估门"),验收门精确判据需在 W-G1 开工前从评估门 spec 取齐。 +- **[未决]** 20 款最终取舍可由创始人按"想优先验证的机制谱系"调整;CCD/寻路两条硬边界是否本期补门面,建议**等批次 3 实测数据**再拍,不预先投入。 diff --git a/docs/agent-specs/2026-06-13-runner-v2-arc-review.md b/docs/agent-specs/2026-06-13-runner-v2-arc-review.md new file mode 100644 index 00000000..f8dfe29d --- /dev/null +++ b/docs/agent-specs/2026-06-13-runner-v2-arc-review.md @@ -0,0 +1,296 @@ +# Runner v2 全弧 · 顶层评审版(HJ-RUNNER-ARC-001) + +> **状态**:review 版 · 2026-06-13(**对抗复审整改稿 r2**)· 给创始人决策(结论先行,不写代码级执行细节,多用 Mermaid)。 +> **定位**:本文不是执行 spec。它确立 **一块基石("游戏挂引擎"装载契约)解锁三相(P1 spike → P2 泛化 → P3 生成)** 的顶层主梁,并给方案 A/B 收敛裁决。落地时各相另备 review+execution 双 spec。 +> **前提(创始人已拍)**:① 推进范围 = **P1→P2→P3 全弧**,顺序解锁、我连续驱动;② **W-G1 前置 DeepSeek key 已就绪**,W-G1 排进 P3。 +> **关联**:A0 地基 [[a0-engine-takeover-done]]、边界模型 [[engine-plugin-boundary-model]]、20 经典基准 `2026-06-13-WG1-20经典轻游戏基准-review.md`、母 spec `2026-06-13-T1b-β引擎真接线与Runner-v2-execution.md`、三契约冻结提交 `28a57b8`。 + +> ### 复审整改说明(r2,2026-06-13) +> 本轮收到的对抗复审结论体为空(JSON=null)。为不空转,主笔架构师**亲自对照 spec 所引源码做了一轮对抗核验**(全 verified,证据带 file:line),逮出 3 条致命 + 2 条重大并就地整改;下列整改均可被源码复证,整改清单见 §0。**这不替代一轮独立外部对抗复审**——本稿主梁已重锚,建议仍走一次外部复审确认再冻结分相 execution。 +> +> **整改的主干裁决(一句话)**:原稿把"统一基石"命名为一份**尚不存在的 `GameModule` 契约**,且与既有 `PluginContext` 混为一谈——这本身就是它声称要避免的"双词汇/缝合"。整改把主梁**重锚到已冻结的真契约**(`PluginContext.getEngine()` + `packageUrl`,母 spec P2 三契约,`28a57b8`),把"游戏怎么挂引擎"明确为**一份仍待起草的 additive 装载契约**(暂名"游戏宿主装载契约"),升级为创始人待拍 open item,**不再当作既成事实断言**。 + +--- + +## 0. 对抗复审整改清单(r2) + +> 自核 5 项(3 致命 F1-F3 + 2 重大 M1-M2)全部纳入。证据列均为可复证 file:line(路径相对 `game-runtime/`,契约相对仓根 `contracts/`)。 + +| # | 级别 | 原稿问题 | 整改 | 证据(已复证) | +|---|---|---|---|---| +| **F1** | 致命 | 把统一基石命名为 `GameModule` 契约并当作"已统一的单一事实源",但**全仓不存在该契约工件**,且与既有 `PluginContext`(插件契约)混淆 → 实为"另造一套词汇",违背它自己声称的"勿缝合/同一职不留两条路"。 | 主梁重锚:**统一基石 = 母 spec P2 已冻结的真契约**(`PluginContext.getEngine()` + GamePackage `packageUrl`)。"游戏如何挂引擎"作为**仍缺的一块 additive 装载契约**("游戏宿主装载契约")显式立项,**升级为 open item O1,不再断言已成**。改 §1/§4。 | `grep GameModule` 在 `contracts/`+`src/core/`+`host-dev/` **零命中**;既有契约 = `api.d.ts:264-303 PluginContext`(6 受控面 + `getEngine()`);母 spec L163「P2 三契约✅冻结」(`28a57b8`) | +| **F2** | 致命 | 断言"`engine-caps.js` 当前不存在=真新增工作"、"`makeEngineCaps` 仍是闭包待抽"。**已过时**:A1/A4 已落地共享数学门面 + caps 工厂 + 注入。 | 修正基线:`engine-math.js` 已是共享模块、`makeEngineCaps` 已包装 particles/audio/math 且已注入 `engineFactory`。真实剩余工作**收窄为**"把 `makeEngineCaps` 闭包**提升为可 import 模块**供 ref/生成游戏共用",非从零造。改 §3.1/§4.3/§5.1/§7。 | `host-dev/engine-math.js` 存在(`buildEngineMath`,`host.js:47` import);`makeEngineCaps` 已包装三面(`host.js:197-318`)且已注入(`host.js:359 engineFactory: makeEngineCaps`);提交 `e591e9b`/`3e5cab4` | +| **F3** | 致命 | "ref 与引擎同屏"被描述为"改主循环+换画布"。**漏了契约缺口**:ref 的 `core.update`/`drawFrame(g)` 是**游戏层**(非插件),不经 `PluginContext`,今天没有"游戏层↔引擎 mainContext"的约定面——这正是 F1 缺的那块装载契约。 | 把"同屏"明确为**装载契约的核心待解项**:游戏层取引擎 `mainContext` 的方式必须由 O1 的装载契约定义,**不能借 `PluginContext`**(那是插件面)。P1 的真正产出 = **把这块装载契约 spike 实**,不止"换几行画布"。改 §1 风险/§4.1/§5.1。 | ref 插件经 `createHostDevContext`(`main.js:112`=PluginContext);但 `drawFrame(g,…)`(`main.js:648`)/`core.update` 是游戏层,画在 `#game`(`main.js:83 prepareCanvas`),在契约面之外 | +| **M1** | 重大 | A/B"收敛"裁决建立在 F1 的虚构契约上,收敛性无法验证。 | 收敛裁决重新落在真契约:A=P1 把"装载契约 spike 实(含 caps 模块化)",B=P2 复用同一装载契约 + `packageUrl` 加载链。收敛=共用 caps 模块 + 同一装载契约 + 同一 `packageUrl` 端点,**可被 import 图与端点验证**。改 §4.3。 | §4.3 重写 | +| **M2** | 重大 | O1(生命周期粒度)默认了"GameModule 是新契约"这一未明示假设。 | 把"装载契约是新立 additive 契约 / 还是折进既有面"本身列为 O1 主问题;生命周期粒度降为 O1 子问题。改 §8。 | §8 O1 重写 | + +--- + +## 1. 结论先行 + +**一块基石 = 一套"游戏挂引擎"的统一约定,由两层构成**: +- **下层(已冻结、复用)= 母 spec P2 三契约**:`PluginContext.getEngine()`(受控引擎能力面,`api.d.ts:264-303`)+ GamePackage `manifest.packageUrl`(交付/加载锚,`game-package.schema.json:66`)+ SDK storage 根契约。这层是**插件如何拿引擎能力 + 包如何交付**的单一事实源,`28a57b8` 已冻结。 +- **上层(仍缺、本弧核心)= "游戏宿主装载契约"**:定义"一款**游戏整体**(不止插件)如何挂到 LittleJS 引擎宿主上"——游戏暴露稳定生命周期、绘制面取引擎 `mainContext`、经 `engineInit` 五回调驱动。**这层契约今天全仓不存在**(§0-F1 复证),是 P1 要 spike 实的真东西。其形态(新立 additive 契约 / 折进既有面 / 生命周期粒度)是创始人待拍项 **O1**,本稿不预断。 + +> ⚠️ **不要把上层叫成已存在的契约**。原稿用 `GameModule` 命名它并当作"已统一",但它既不在 `contracts/` 也不在 `api.d.ts`;而 `PluginContext` 是**插件**契约,不是**游戏宿主**契约。两者一个都不能替另一个。Q4 铁律(引擎 `import` 只活集成段 host,禁泄进游戏/插件)对两层都成立。 + +这套约定一次设计,P1/P2/P3 三相共用: +1. **P1 用王蓝莓把上层装载契约 spike 出来**(方案 A:给 `wanglanmei-ref` 加 `?engine=real` 通道,主循环从浏览器 RAF 改挂 `engineInit` 五回调,**游戏层绘制面**从 `#game` 改引擎 `mainContext`,保 `?engine=stub` 回滚=A0 双通道);并把**已存在但仍是闭包**的 `makeEngineCaps` 提升为可 import 模块(§0-F2)。 +2. **P2 把宿主泛化成"装载任意 GamePackage"**(同一装载契约 + 复用 `packageUrl` 加载链:`packageUrl→iife bundle`;后端写包、`game-studio` 增"引擎包"分支取代掏空的 `startRuntime`)。 +3. **P3 W-G1 生成游戏按同一装载契约产出**(agent 写码于插件库,20 基准做 bake-off,经 P1 宿主 + e2e harness 评测)。 + +**为何"三相同一基石"**:四路侦察确证地面有一道结构性裂缝——真引擎宿主(`host-dev` real)**有掌帧无游戏**,真游戏(`wanglanmei-ref`)**有游戏无引擎**,生成管线产物喂给**已掏空的 1.x runtime**(既不接引擎、也被 W-CLEAN 停用)。三处断口的**同一个根**是"缺上层那份'游戏整体↔引擎'的装载约定"(下层 `getEngine()` 已解决'插件↔引擎',但游戏的 `update/render` 主体仍在契约面之外)。先把这份装载约定 spike 实、再泛化、再让生成按它产出,是唯一不缝合、不返工的收敛路径。**反面**:若 P1/P2/P3 各自造一套接引擎方式(ref 一套、Runner 一套、生成 agent 一套),三套漂移 = 边界模型"同一职不留两条并行路"被破,agent 在多套词汇间犯迷糊,历史游戏兼容性失锚。 + +**最大风险(先标在最前)**:**P1 的"真渲同屏"= 把上层装载契约从零 spike 实,不是补几行**——关键难点是:① ref 的游戏层(`core.update`/`drawFrame(g)`,**非插件**)今天没有"取引擎 `mainContext`"的约定面,不能借 `PluginContext`(§0-F3);② 游戏美术画在自带 `#game` 2D 画布、引擎粒子自渲进引擎 `mainContext`,**两面分裂**待合并;③ `main.js` 用浏览器 RAF + `mockNowMs` 自掌帧,与 `engineInit` 掌帧**直接冲突**。这是全弧的第一道真门,过不了则 P2/P3 全部悬空。 + +--- + +## 2. 背景与问题(四路侦察的地面真相) + +四路只读侦察(全 verified,证据带 file:line)拼出的真相:**"N 款游戏跑在引擎上、人在浏览器真玩、进 feed"这条链今天是断的,断在四处**。 + +### 2.1 四处断口 + +| 侦察 | 断口 | 关键证据(file:line) | +|---|---|---| +| **a1/a4** | **王蓝莓上引擎≠补几行**:渲染面分裂 + 掌帧冲突 | `main.js:112` `createHostDevContext` 未传 `engineFactory`(仅 4 键 context2d/seed/audioFactory/clock)→ `plugin.js` 返 null → 粒子全 `NOOP_HANDLE`;游戏美术画在 `main.js:83` `prepareCanvas(#game)` 的 2D `g` 上(`drawFrame(g,…):648`),引擎粒子自渲进 `mainContext`(`host.js:243` `new ParticleEmitter`)=两张分离画布;`main.js:617-691` 自管 `frame(dtReal)`+RAF,与 `host.js:884` 引擎掌帧不兼容 | +| **a4** | **host-dev real 有掌帧无游戏**:只挂九插件演示能力 | `host.js:428/874` 留痕"host-dev 无真游戏";`bootHostDev` 只收 `{canvas,statusEl}`(`host.js:150-156`),**零"装载任意游戏"约定**;引擎五回调 `engGameInit/Update/Render` 转发到插件演示(`host.js:883-887`) | +| **a2** | **生成管线是断的**:执行器被停用 + 产物喂掏空 host | `AigcTemplateConstants.java:46` `SUPPORTED_TEMPLATE_IDS = List.of()`(W-CLEAN 主动停用)→ 任何任务判 `NO_TEMPLATE_MATCH` 零产出;M-b 产 `GameConfig` JSON → 喂 `game-studio/src/host/runtime/index.ts` 的 `startRuntime`,该 host W-CLEAN 后只画"模板体系重构中"+发 `game_error`(`index.ts:56-113`),**完全非 engine-aware** | +| **a3** | **feed 真空 + studio 是 mock** | 真后端 `feed/stream` 返 `list:[]`(W-CLEAN 清掉存量游戏);`:4173` 是 mock 构建(取到 mock "霓虹躲避"假数据,未接真 staging);基建全活(new-api `:3000` healthy / MySQL / cloud `:48080` 跑 1 天+ / admin `:4174`) | + +### 2.2 母 spec(T1b-β execution)已覆盖什么、缺什么 + +母 spec `2026-06-13-T1b-β…execution.md` 已**冻结开工**并覆盖:Phase A(A0-A6 引擎真接线,**已收口** `e591e9b`+`3e5cab4`)、Phase B(Runner v2 = 复用后端 `packageUrl` 走 DB-manifest 降级)、Phase C(1.x 退役 srcdoc→URL)、Phase D(参考件经发布主链入 feed)。 + +**但母 spec 的 Phase D = "ref 经发布主链入 feed",其产物仍是跑在 stub 插件上的 ref(`main.js:15` 不 import 引擎)**——**它不含"ref 跑在引擎上"**。a4 把这道缝明确标为"结构缺口、归属波次待确认"。**Runner v2 全弧正是补这道缝、并把它与生成主线收敛成一条主梁的波次**:母 spec 完成"引擎能 spike / 包能交付 / feed 能翻态"的零件,本弧负责"用一份契约把这些零件串成'游戏真跑在引擎上、可泛化、可生成'的整链"。 + +```mermaid +graph LR + subgraph 今天["地面真相(四处断口)"] + E["引擎宿主
host-dev real
有掌帧·无游戏"] + G["真游戏
wanglanmei-ref
有游戏·无引擎"] + P["生成管线 M-b
停用·产物喂掏空 host"] + F["feed 真空
studio=mock"] + E -. 不交汇 .- G + P -. 喂 .-> X["1.x runtime
掏空·非 engine-aware"] + F -. 接 .-> M["mock 假数据"] + end + 今天 ==Runner v2 全弧:两层基石==> 明天 + subgraph 明天["收敛后"] + L0["下层·已冻结(复用)
PluginContext.getEngine()
+ packageUrl"] + C["上层·仍缺(本弧 spike)
游戏宿主装载契约
形态待拍 O1"] + L0 -.托底.-> C + C --> A1["P1 ref spike
装载契约验实·真渲同屏"] + C --> A2["P2 泛化宿主
装载任意 GamePackage"] + C --> A3["P3 生成游戏
按同一装载契约产出"] + end +``` + +> **图读法**:下层(`getEngine()`/`packageUrl`)已冻结、三相直接复用;上层"游戏宿主装载契约"是本弧 P1 要 spike 实的真新增物,其形态由 O1 拍定后才进 `contracts/`。 + +--- + +## 3. 目标 / 非目标 + +### 3.1 目标 +1. **钉死一份"游戏宿主装载契约"(上层)**,三相共用、单一事实源;**复用**下层已冻结的 `PluginContext.getEngine()` + `packageUrl`,不另造一套引擎能力面。装载契约形态(新立 additive 契约 / 折进既有面)按 **O1** 拍定。 +2. **把 `makeEngineCaps` 从闭包提升为可 import 模块**(暂名 `host-dev/engine-caps.js`),host-dev / ref / 未来生成游戏共用同一份引擎能力包装层,物理上杜绝"两份漂移"。**注意(§0-F2)**:数学门面 `engine-math.js`、caps 工厂 `makeEngineCaps`、`engineFactory` 注入**均已存在并已接线**(A1/A4,`e591e9b`/`3e5cab4`);本目标剩余工作只是把闭包搬成独立文件,非从零造。 +3. **P1**:用 `wanglanmei-ref` 把该装载契约 spike 到"**引擎真掌帧 + 游戏美术与引擎粒子同屏可玩**",人在浏览器真玩,保 `?engine=stub` 回滚。 +4. **P2**:把宿主泛化为"**装载任意 GamePackage 跑引擎**",复用 `packageUrl` 打通 `packageUrl→bundle→GamePlayer` 链,一款 engine-aware 游戏经发布主链进 feed、真人试玩。 +5. **P3**:W-G1 用便宜模型(M3/M2.7/DS-V4)按同一装载契约 **agent 写码于插件库**产出游戏,20 基准 bake-off,经 P1 宿主 + e2e harness 评测、金丝雀入 feed N 款。 + +### 3.2 非目标 +- **不动受控面 6 项契约**(`getContext2d/onFrame/getInput/getAudioContext/time/random`,`api.d.ts:264-304`)——引擎可换边界守住。 +- **不动 feed 终裁引擎选型**(LittleJS feed 侧不动;渠道线小游戏引擎另行竞标,HJ-CH-001)。 +- **不重做 M-b 执行器的全部范式细节**(P3 只定"生成按上层装载契约产 bundle"的接口边界,模型竞标/评估门判据细节归 W-G1 自身 spec)。 +- **本稿不冻结上层装载契约的精确 IDL**(仅给 §4.1 草案形态供 O1 决策;拍定后由分相 execution spec 落进 `contracts/`,本稿不预冻字段)。 +- **不接 OSS/MinIO**(Runner v2 沿母 spec 默认降级 = DB-manifest 带版本 + immutable 头;切 OSS 留上线前)。 +- **不新增寻路/CCD swept 等引擎缺件**(20 基准已标两条硬边界;是否补门面等 P3 实测数据再拍)。 + +--- + +## 4. 推荐方案:两层契约(主梁) + +> **主梁 = 下层复用 + 上层 spike**。下层(`getEngine()`/`packageUrl`)已冻结,照搬;上层"游戏宿主装载契约"是本弧真正要立的东西,形态待 O1 拍。本节给出装载契约的**草案形态**(仅供 O1 决策参考,未拍前不进 `contracts/`)。 + +### 4.1 上层装载契约的草案形态(待 O1 拍) + +一款游戏 = 一个**纯模块工厂**(不 import 引擎、不碰裸 DOM),暴露稳定生命周期;由集成段 host 经 `engineInit` 五回调驱动;**插件能力**经下层 `ctx.getEngine()` 拿,**游戏整体的绘制面**经装载契约约定为引擎 `mainContext`。 + +> ⚠️ **下层 vs 上层的职责切割(F1/F3 的解法)**:`getEngine()`(`api.d.ts:303`)是**插件**拿引擎能力的面,已冻结;它**解决不了**"游戏的 `update/render` 主体怎么挂引擎、怎么拿 `mainContext`"——因为游戏层(ref 的 `core.update`/`drawFrame`)不是插件、不走 `PluginContext`。这正是上层装载契约要补的缺口,**不能拿 `getEngine()` 硬凑**。下图的 `game.*` 生命周期与"`render(g)` 的 `g` 来自引擎 `mainContext`"都属待拍的装载契约草案,非既有契约。 + +```mermaid +flowchart TB + subgraph host["集成段 host(唯一 import 引擎处·Q4 铁律)"] + EI["LJS.engineInit(五回调)"] + EC["engine-caps.js 模块(提升自 host.js:197 闭包)
particles/audio.synth/math 三面薄包装
=复用下层 getEngine() 背书"] + CTX["createHostDevContext
受控面 6 项 + engineFactory(均已存在)"] + end + subgraph game["游戏侧(装载契约·零引擎 import·形态待 O1)"] + GM["game.init(ctx)
game.update(dt)
game.render(g)
game.onInput(ev)"] + end + EI -->|gameUpdate 回调| GM + EI -->|gameRender 回调→引擎 mainContext| GM + EC -->|插件能力经 ctx.getEngine()【下层·已冻】| GM + CTX -. 游戏绘制面=引擎 mainContext【上层·待 O1】 .-> GM + GM -. 禁 .->|直接 import littlejsengine| X1["❌ Q4 违例"] +``` + +**四条不可破的约定**: +1. **掌帧唯一源 = 引擎**:游戏不自起 RAF;`update/render` 由 `engineInit` 五回调驱动(消除 a1/a4 的掌帧冲突)。 +2. **绘制面唯一 = 引擎 `mainContext`**(`setGLEnable(false)` 纯 2D):游戏美术与引擎粒子渲到**同一张画布**(消除 a1 的渲染面分裂)。**此条是装载契约的核心待解项**(F3)。 +3. **能力唯一面(插件)= `ctx.getEngine()` 受控面【下层已冻】**:粒子/音频/easing 经包装层(边界模型"引擎有→薄包装");引擎缺件(collision/gamefeel)经插件补层。 +4. **引擎 import 唯一活点 = host**:游戏/插件零直接 import(Q4 铁律,`entry.js:19-20`)。 + +### 4.2 三相复用同一套契约 + +```mermaid +graph TD + L0["下层契约【已冻·复用】
PluginContext.getEngine() + packageUrl"] + C["上层装载契约【本弧 spike·形态待 O1】"] + EC["engine-caps.js 模块
(提升自 host.js:197 闭包·非从零)"] + L0 -.托底.-> C + L0 -.托底.-> EC + C --> P1 + C --> P2 + C --> P3 + EC -.共用.-> P1 + EC -.共用.-> P2 + EC -.共用.-> P3 + subgraph P1["P1·ref spike 装载契约"] + direction LR + P1a["?engine=real 通道
main.js RAF→engineInit 五回调
drawFrame(g)→mainContext"] + end + subgraph P2["P2·泛化宿主(同契约产品化)"] + direction LR + P2a["GamePlayer 增'引擎包'分支
复用 packageUrl→iife bundle
取代掏空的 startRuntime"] + end + subgraph P3["P3·生成游戏(同契约产出)"] + direction LR + P3a["agent 写码于插件库
产装载契约 bundle
20 基准 bake-off"] + end +``` + +### 4.3 方案 A / B 收敛裁决(重锚到真契约) + +侦察给了两条缝合方向:**A = 给 ref 加 `?engine=real` 通道**(改 `main.js` 主循环挂 `engineInit`);**B = 把 `host-dev` 泛化为"游戏宿主壳"**(`bootHostDev` 加 game 入参,游戏作被装载模块)。 + +**裁决:A 与 B 不是两套并行入口,而是同一主梁的两个相位——A=P1 用王蓝莓 spike 上层装载契约,B 的"泛化宿主"=P2 对同一装载契约的产品化。** 收敛性**可被验证**(非口头断言):A 与 P2 必须 ① import 同一个 `engine-caps.js` 模块、② 实现同一份装载契约生命周期、③ 经同一个 `packageUrl` 端点交付/加载——这三点都是 import 图 + 端点可查的硬约束。理由: + +| 维度 | 方案 A(P1 先做) | 方案 B(P2 承接) | +|---|---|---| +| **改动面** | 小:动 ref `main.js` 主循环 + entry import 引擎 + 复用既有 `engineFactory` 键;保 stub 回滚 | 大:动 host 装载约定 + studio GamePlayer/inject + 后端写包 + 发布主链 | +| **取证链** | 复用 ref 现成 CDP 真触摸 + 四件套 evidence(`e2e-input.cdp.cjs`) | 复用 staging 发布链 + feed 走查 | +| **风险** | 验"装载契约能不能让一款真游戏真跑在引擎上同屏" | 验"装载契约能不能装载任意包 + 经产品链入 feed" | +| **依赖** | A0 地基(已收口)+ O1 装载契约形态拍定 | 依赖 P1 装载契约定稿 + 母 spec Phase B/C/D 零件 | + +**结论**:先做 A(P1)用最小改动、最强取证链把装载契约**验实**;再把验实的契约做成 B(P2)的"泛化宿主"产品化。**装载契约=主干,A/B 是它的两个相位,不是两个竞争方案。** 这正是"勿另造一套"的兑现:A 与 P2 共用 `engine-caps.js` 与同一份装载契约接口,B 只是把"单一 ref 游戏"换成"任意 GamePackage 包加载"。 + +**共享模块的真实状态(§0-F2 纠正)**:`engine-math.js`(数学门面)已是独立共享模块,`makeEngineCaps`(粒子/音频/math 三面包装)已实现并已经 `engineFactory: makeEngineCaps` 注入(`host.js:359`)——**但 `makeEngineCaps` 仍是 `host.js:197` `bootHostDev` 内的闭包**(依赖 `engineMode`/顶层 `LJS` import/`window.__engineCalls` 探针),ref/生成游戏**无法直接 import**。剩余工作 = **把这个已存在的闭包提升为可 import 模块** `host-dev/engine-caps.js`(保 `engineMode` 短路 / `__engineCalls` 探针 / null-engine 回归语义逐行不变),host-dev / ref / 生成游戏共用——否则三相各自重建一份等价工厂 = 与"勿另造"直接冲突。**这是"模块化已有件",不是"从零造新件"**(原稿"engine-caps.js 不存在=真新增工作"已订正)。 + +--- + +## 5. 分相 P1 / P2 / P3 + +### 5.1 P1 — 用王蓝莓 spike 装载契约(方案 A) + +| 项 | 内容 | +|---|---| +| **目标** | `wanglanmei-ref` 加 `?engine=real` 通道:引擎真掌帧 + 游戏美术与引擎粒子**同屏可玩**,人在浏览器真玩;保 `?engine=stub` 回滚。同时把已存在的 `makeEngineCaps` 闭包**提升为可 import 模块** `engine-caps.js`(§0-F2,非从零造)。**前置依赖 O1**:装载契约形态(游戏层怎么取 `mainContext`)须先拍定。 | +| **关键设计** | ① `entry.js` 增 `import * as LJS`(引擎真入 ref-bundle,Q4 只活 entry/host);② `main.js` 把 `frame(dtReal)` 拆成逻辑段(`core.update/bundle.tick/applyEvents/fx.step`)挂 `gameUpdate`、绘制段(`drawFrame/palettePost.renderPost`)挂 `gameRender`,**游戏层目标画布从 `#game` 改引擎 `mainContext`**(`setGLEnable(false)` 纯 2D;此口径由 O1 装载契约定义,非借 `PluginContext`);③ 插件受控面 `context2d` 改快照引擎 `mainContext`;④ 粒子/音频/easing 经**提升后的** `engine-caps.js` 包装层(复用下层 `getEngine()` 背书);⑤ `mockNowMs` 在 real 下改逻辑帧钟语义,删/旁置 `main.js:668-690` 浏览器 RAF(对齐 A0 双通道)。 | +| **验收门** | ① mini-desktop 真渲门:引擎掌帧帧增量区间断言(对齐 G0-2)+ **游戏层美术与引擎粒子同屏**像素命中(非两张分离画布,即装载契约的 `mainContext` 约定真生效);② 真接线门②:`window.__engineCalls` 含 `particles.spawnEmitter` 等 call-ID(插件运行时真调引擎);③ CDP 真触摸逐 zone 真玩(开店→进货→营业→结算→升级)+ 四件套 evidence;④ `?engine=stub` 回滚通道逐像素确定性回归仍绿;⑤ `engine-caps.js` 提升后既有回归(门0 8/8 + real 门 6/6)逐项不变;⑥ console 零未捕获错误。 | +| **爆炸半径** | `games/wanglanmei-ref/{entry.js:15, src/main.js:112/617-691/648}`(主循环重做);**模块化已有件**:把 `host.js:197-318` `makeEngineCaps` 提升为 `host-dev/engine-caps.js` + `host-dev/host.js` 改为 import 它(保 `engineMode` 短路/探针语义不变;`engine-math.js` 已是独立模块无需动);`SIZES.md` 增"游戏产物(引擎+包装层)"新口径(ref 197KB→~540KB,见 §6)。受控面 6 项契约**零改**(`engineFactory` 是既有可选入参 `api.d.ts:405`);**新增的是上层装载契约工件**(形态待 O1)。 | + +### 5.2 P2 — 泛化宿主:装载任意 GamePackage(方案 B) + +| 项 | 内容 | +|---|---| +| **目标** | 同一装载契约 + 包加载:复用 `packageUrl→iife bundle`;后端写包;`game-studio` GamePlayer/inject 增"引擎包"分支取代掏空的 `startRuntime`;studio 改 staging 重建。产出≥1 款 engine-aware 游戏(先用 ref 级)经发布主链入 feed、真人试玩。 | +| **关键设计** | ① **包加载形态**:GamePackage `manifest.packageUrl` 指向 iife bundle(沿母 spec §5 = 复用后端 `packageUrl` 走 DB-manifest 端点 `/app-api/runtime/package/{versionId}/manifest`,天然含 versionId 版本化);② **GamePlayer 分支**:`GamePlayer.vue:128-168` 取包后,inject 不再硬绑掏空的 `startRuntime`(`inject.ts:104`),改"加载引擎 bundle + 经装载契约 boot"分支——**这是取代 `runtime/index.ts` 掏空壳的关键**;③ CSP 须放行 script-src(Runner origin)+connect-src(bundle 取源)(母 spec §8 四项兼容矩阵);④ studio `npm run build -- --mode staging` 重建,消除 mock 中间件掩盖(a3 阻断 B)。 | +| **验收门** | ① 后端 feed/stream 出现该游戏(`status=PUBLISHED(4)`);② 真人浏览器开 feed→点卡→**引擎 bundle 真渲非"重构中"**+`game_loaded`+`game_play_end`;③ 防 demo 兜底假阳性(母 spec 门4:checksum=发布版本 ∧ gameId/versionId 匹配 ∧ 兜底触发即 fail);④ 链路③⑤真走查解封。 | +| **爆炸半径** | `game-studio/src/host/{GamePlayer.vue, inject.ts, runtime/index.ts(掏空壳→引擎包分支), contract.ts}`;后端发布主链(`ProjectServiceImpl/PublishOrchestrationServiceImpl/FeedServiceImpl`,被动验证);`contracts/game-package.schema.json:66` `packageUrl`(契约已留位,additive)。**与母 spec Phase B/C/D 高度重叠**——P2 是把母 spec 的"ref 入 feed(跑 stub)"升级为"engine-aware 包入 feed(跑引擎)",须与母 spec owner 对齐避免双写。 | + +### 5.3 P3 — W-G1 生成游戏按同一装载契约产出 + +| 项 | 内容 | +|---|---| +| **目标** | 便宜模型(M3/M2.7/DS-V4)按上层装载契约 **agent 写码于插件库**产出游戏 bundle(非 GameConfig JSON),20 基准做 bake-off,经 P1 宿主 + e2e harness 评测、过门金丝雀入 feed N 款。 | +| **关键设计** | ① **范式切换**:从"模板填参出 GameConfig JSON"(M-b 现状)改为"agent 写装载契约代码→esbuild 打 iife bundle"(HJ-GEN-001 终裁);② **白名单/契约前置**:新模板体系(插件库 templateId 或新生成包类型)注册进 `AigcTemplateConstants.SUPPORTED_TEMPLATE_IDS`(解 a2 阻断①)+ prompt registry 适配(`contracts/prompts`);③ **靶集**:20 经典基准三批(冒烟 6 易 / 主力 8 中 / 压测 6 难),暴露 4 类能力短板(文本 HUD / 网格 / CCD / 拖拽);④ **评测**:复用 agent-loop-v1 工具箱(judge/ledger/预算闸/CDP 真玩),过 P1 宿主真渲 + harness 门兜底。 | +| **验收门** | ① 黄金集批跑 acceptRate≥0.8 分模板(HJ-GEN-001 口径);② 金丝雀 N 款 feedVisible 真渲;③ new-api `logs.quota` 成本分解对照 L1 门(¥0.15);④ harness 门稳定逮"穿透/消行错/不可玩"(便宜模型路线命门)。 | +| **爆炸半径** | `game-cloud` aigc(`AigcGenerateExecutor`/`AigcTemplateConstants`/生成-构建-取证管线);`contracts/{prompts, templates 或新包类型}`;agent-loop-v1 工具箱。**前置硬卡已解(DeepSeek key 就绪)**;生成范式从 JSON→bundle 是 P3 最大改动面(产物形态变 = host 消费方式变,靠 P2 的引擎包分支兜)。 | + +### 5.4 三相依赖与解锁顺序 + +```mermaid +graph LR + A0["A0 地基
✅ 已收口"] --> P1 + O1["O1 装载契约形态
⚠️ 待创始人拍"] --> P1 + EC["engine-caps.js
模块(P1 提升·非从零)"] --> P1 + P1["P1 ref spike
装载契约验实"] --> P2["P2 泛化宿主
包加载+入 feed"] + P1 --> P3["P3 W-G1 生成
按契约产出"] + P2 --> P3 + KEY["DeepSeek key
✅ 就绪"] --> P3 + style P1 fill:#fff3cd + style P2 fill:#d1ecf1 + style P3 fill:#d4edda + style O1 fill:#f8d7da +``` + +> P1 解锁前先解 **O1**(装载契约形态拍定)。P3 双依赖 P1(契约验实)+ P2(引擎包加载链):生成的 bundle 要既"按装载契约挂引擎"(P1 验实)、又"经包加载进 feed 试玩"(P2 打通)。 + +--- + +## 6. 关键权衡 + +### 6.1 体积:ref 197KB→~540KB 是否触 Tier1 体积口径? +- **事实**:ref 现 197KB(不打引擎,esbuild dry-check 实测 `has zzfxG:false`);接引擎后 ~540KB(host-dev entry 实测 544KB 含 `engineInit+ParticleEmitter`)。SIZES.md 当前是**插件增量法**(合计插件 gz≈12KB,自律线 60K),**不覆盖"游戏产物=引擎+包装层"口径**。 +- **判定**:~540KB raw 是 **min 未 gz** 量级;LittleJS 完整版 raw≈55KB gz(v1.18.19),加游戏代码后游戏产物 gz 远在 B1 入场券 **gz≤350KB / raw≤1.5MB**(tiered-engine 记忆)之内——**不触红线**。**但须给 SIZES.md 增"游戏产物口径"新表**(与"插件增量"分离),并按"引擎+包装层"重锚(母 spec 裁决①已要求重锚,但仅针对 host-dev,未覆盖游戏产物——本弧补上)。**勿再引"13KB"**(js13k 极限分支,已废,tiered-engine 记忆)。 + +### 6.2 确定性逐像素如何与引擎 RAF 共存? +- **事实**:引擎一个 RAF 内追帧步数 N 由墙钟抖动决定(`littlejs.esm.js:256` `frameTimeBufferMS` 循环),外部轮询 `frameCount>=startFrame+frames` 会 OVERSHOOT → same-seed 重跑像素不一致(A0 已核验)。 +- **判定**:**real 通道不承担逐像素确定性,由 `?engine=stub` 通道独占兜底**(A0 既定双通道策略)。real 通道只证"引擎掌帧 + 同屏真渲 + call-ID 真接线";逐像素哈希回归走 stub。P1 验收门据此分两套(real=同屏可玩+call-ID / stub=逐像素回归),不强求 real same-pixel。 + +### 6.3 契约形态对生成 agent 的约束? +- **正向**:上层装载契约是 agent 的**唯一产出靶**(写 `init/update/render/onInput`,插件能力经下层 `ctx.getEngine()`),一职一路、词汇收敛(边界模型);20 基准里 ~12 款能力面齐备可直接产出。**前提是 O1 把装载契约形态拍死**,否则 agent 没有稳定产出靶。 +- **代价/风险**:4 类高频短板(文本 HUD / 网格 / CCD swept / 拖拽手势)**当前无插件门面**——便宜模型 20 个 prompt 各造一遍轮子,一致性/token/出错率三重受损(20 基准 C.2)。**这是 P3 实测要量化的核心数据**,据此决定"是否把高频缺口(文本 HUD/网格)补成插件门面"——**不预先投入,等批次 3 实测再拍**。 + +--- + +## 7. 风险 / 兼容性 + +| 风险 | 等级 | 缓解 / 回滚 | +|---|---|---| +| **装载契约缺口(F1/F3)未补就开工** | **P0** | 全仓无"游戏整体↔引擎"装载契约,`getEngine()` 是插件面凑不上(§0-F1/F3)。**必须 O1 先拍装载契约形态、再开 P1**;否则 P1 边做边发明接口 = 又一条平行路。本稿已把它从"既成事实"降为待拍 open item。 | +| **P1 真渲同屏过不了**(渲染面合并 + 掌帧重做难度被低估) | **P0** | 这是全弧第一道真门;A0 双通道已证引擎掌帧可行,ref 需把"自管画布+RAF"换成"引擎 mainContext+五回调"+按装载契约接游戏层绘制面。**回滚 = `?engine=stub`**(行为与 α 逐行等价,A0 已保留)。过不了则全弧暂停、不连累 stub 既有交付。 | +| **受控面被动了** | P0 | 受控面 6 项契约**零改**是硬约束(`engineFactory` 是既有可选入参);P1/P2/P3 任何相若需改受控面=触发架构复议,不得自行扩。**上层装载契约是 additive 新增,不得改下层既冻 6 项**。 | +| **P2 与母 spec Phase B/C/D 双写** | P1 | P2 高度重叠母 spec;须与母 spec owner 对齐——P2 = 把母 spec"ref 入 feed(stub)"升级为"engine-aware 包入 feed(引擎)",是承接非另起。 | +| **历史游戏兼容** | P1 | 插件公开 API(EmitterConfig 等)冻结不变 → 老游戏照跑(包装/补层是内部事,边界模型);引擎包装的能力改走引擎渲染、像素可能微异=可移植性提升(创始人最初诉求)。上层装载契约对存量 ref 而言是 additive 通道(`?engine=stub` 即旧行为)。 | +| **engine-caps 模块化引入回归** | P1 | 把**已存在的** `host.js:197-318` `makeEngineCaps` 闭包提升为模块须保 `engineMode` 短路 / `__engineCalls` 探针 / null-engine 回归语义逐行不变;门0 8/8 既有回归 + real 引擎门 6/6 作回归基线(§0-F2:这是搬运已有件,非新写)。 | +| **P3 生成范式 JSON→bundle 改动大** | P1 | 产物形态变 = host 消费方式变,靠 P2 引擎包分支兜底;harness 门兜底是便宜模型路线命门。P3 评估门判据细节归 W-G1 自身 spec,不在本弧预烤。 | +| **契约回滚** | — | 上层装载契约走 `?engine=stub` 通道 / Runner URL 复用 packageUrl / storage 均 additive,回滚=移除新增不破存量。 | + +--- + +## 8. 待创始人拍的 Open Items + +| # | 待拍项 | 选项 / 默认建议 | +|---|---|---| +| **O1**(升级·F1/M2) | **上层"游戏宿主装载契约"的归属与形态**——这是本弧第一待拍:①**归属**:新立第 9 类契约(`contracts/` 下独立工件)/ 还是折进既有 `core/api.d.ts` 作 `GameHostContract` additive 段?②**生命周期粒度**:`init/update/render/onInput` 四件够不够?是否要 `resize/destroy/onVisibility`?③**绘制面约定**:游戏层取引擎 `mainContext` 的口径(不能借 `PluginContext`)。 | 建议:①新立 additive 契约(避免污染插件契约语义,与边界模型"一职一路"一致);②取 `init/update/render/onInput`+`destroy`(卸载卫生),其余 additive 后补;③`mainContext` 经装载契约 boot 时注入游戏层。**P1 开工前必须拍 ①③,否则 spike 无稳定靶。** | +| **O2** | **P2 与母 spec T1b-β Phase D 的边界**:是把母 spec Phase D 直接升级为"engine-aware 包入 feed"(合并),还是 P2 作为 Phase D 之后的独立增量波次? | 建议:合并——母 spec Phase D 的"ref 入 feed"本就该是 engine-aware 才有意义(否则 feed 里跑 stub 无产品价值)。**待拍是否改母 spec 范围。** | +| **O3** | **P3 生成产物白名单注册口径**:新模板体系用"单一通用装载契约 templateId"还是"按品类多 templateId"注册进 `SUPPORTED_TEMPLATE_IDS`? | 建议:先单一通用 templateId 验管线,品类区分留 prompt 层;**待拍。** | +| **O4** | **高频缺口补门面时机**:文本 HUD / 网格门面是否在 P3 前预补,还是等批次 3 实测出错率再补? | 建议(20 基准既定):**不预补,等实测数据**;但若创始人想压 P3 一致性,可预补文本 HUD(最高频,~15/20 款需要)。**待拍。** | +| **O5** | **N(首批入 feed 款数)**:P2 先 1 款 ref 验链,P3 金丝雀 N 款——N 定多少、品类怎么选? | 建议:P3 先冒烟批 6 款过门、取 ≥3 款金丝雀入 feed;**待拍。** | +| **O6**(流程) | **本稿是否需补一轮独立外部对抗复审再冻结分相 execution?**(本轮对抗 JSON 为空,整改由主笔自核完成,§0) | 建议:主梁已重锚且自核证据可复证,但 F1 触主干契约裁决,**建议补一轮外部对抗复审确认 O1 归属裁决后再开 P1 execution**。**待拍。** | + +--- + +## 风险与假设(诚实标注·r2 已订正) +- **[verified·订正]** **上层"游戏宿主装载契约"全仓不存在**(`grep GameModule` 在 `contracts/`+`src/core/`+`host-dev/` 零命中)——这是 §0-F1 的根据。既有契约只有 `PluginContext`(插件面,`api.d.ts:264-303`)+ `packageUrl`(`game-package.schema.json:66`),二者均已冻结(母 spec L163 / `28a57b8`)。 +- **[verified·订正]** **`engine-caps.js` 文件确不存在,但其内容已大半存在并接线**:`engine-math.js`(数学门面)已独立成模块(`host.js:47` import);`makeEngineCaps`(粒子/音频/math 三面包装)已实现于 `host.js:197-318` 并已注入 `engineFactory: makeEngineCaps`(`host.js:359`)。故剩余工作 = 把闭包提升为可 import 模块(搬运),**非"从零造"**(原稿"真新增工作"已订正为 §0-F2)。 +- **[verified]** 四处断口、SIZES 口径、母 spec 覆盖范围——源码/spec 实证,证据锚点见 §2/§4/§5 各 file:line。 +- **[verified]** ref 游戏层(`core.update`/`drawFrame(g,648)`)画在 `#game`(`main.js:83`)、不走 `PluginContext`(`main.js:112` 仅插件)——§0-F3 据此:游戏层取 `mainContext` 须靠待立的装载契约。 +- **[inferred]** "便宜模型能产出 ✅ 款可玩件"是 P3 待验推断(20 基准 C 已标);本弧只确立契约与评测路径,不预断模型产出质量。 +- **[inferred]** ref 接引擎后 ~540KB 不触 B1 入场券——基于 host-dev entry 544KB 实测 + gz 量级推断;P1 须用 `size.mjs` 实测游戏产物 gz 坐实。 +- **[assumption]** 好玩基线 v2 五要素精确判据未在本次定位到原文(散见多 spec),P3 验收门精确判据须开工前从评估门 spec 取齐。 +- **[流程]** 本轮对抗复审结论体为空(JSON=null),§0 整改清单由主笔架构师自核源码生成(全可复证);是否补一轮外部对抗复审见 O6。 +- **[未决]** O1-O6 六项待创始人拍。 diff --git a/game-runtime/SIZES.md b/game-runtime/SIZES.md index 0c656180..7daf6df2 100644 --- a/game-runtime/SIZES.md +++ b/game-runtime/SIZES.md @@ -20,3 +20,19 @@ 合计插件 gz 增量 ≈ **12100B**(自律线 60K;超 60K 须创始人显式放行,执行版 §5)。 +--- + +## 游戏产物(引擎+包装层)——P1 王蓝莓上引擎口径 + +> **口径区别**:上表「增量法」量的是**插件净增**(基线壳→+core→逐插件叠加,不含引擎)。游戏产物(一款 game-shop bundle)= **引擎 + 能力插件库 + 游戏代码** 整包,是另一口径,不并入上表。 +> 同一 esbuild 锁参(`bundle+minify+iife+es2019+sourcemap=false`);gz=`zlib.gzipSync(buf,{level:9})`。 + +| 产物 | 引擎入包 | raw | gz | B1 入场券(gz≤350KB) | 备注 | +|---|---|--:|--:|:--|---| +| ref **stub** 通道 | 否 | ~197KB | — | ✔ | 不打引擎(回滚锚;引擎 import 经 engineMode 短路不触达运行时,但 esbuild 仍随 entry 引擎 import 打入——故 stub/real 同一份 bundle,差异在运行时通道,不在产物字节) | +| ref **real**(引擎真入) | 是(`littlejsengine.esm.js` 640KB 源经 tree-shake+min) | **264KB** | **88KB** | ✔(gz 88KB ≪ 350KB) | `entry.js`/`main.js` import 引擎(Q4:只 entry/host);`game-shop.js` 游戏工厂零引擎 import(metafile 实证)。`grep -q ParticleEmitter` 核入包 | + +> **实测来源**:6c6g `node scripts/build.mjs games/wanglanmei-ref/entry.js → /tmp` dry-build(raw=264476 / gz=89583,2026-06-13)。引擎入包凭据:metafile `inputs` 含 `node_modules/littlejsengine/dist/littlejs.esm.js`,且仅 `main.js`/`entry.js` import 之(Q4)。 +> **注**:插件增量法不覆盖游戏产物口径——勿把游戏产物字节并入上表「合计插件 gz 增量」。mini-desktop 可经 `size.mjs` 复测覆盖本节。 +> **勿引「13KB / 55KB」自研 Canvas 旧口径**(已废,见 [.agents tech-decisions §1.1]);引擎=LittleJS 完整版。 + diff --git a/game-runtime/games/wanglanmei-ref/entry.js b/game-runtime/games/wanglanmei-ref/entry.js index 8190da17..bd40f393 100644 --- a/game-runtime/games/wanglanmei-ref/entry.js +++ b/game-runtime/games/wanglanmei-ref/entry.js @@ -5,13 +5,38 @@ * 【构建】沿 scripts/build.mjs 锁参(bundle+minify+iife+es2019+sourcemap=false),见 BUILD.md: * node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef * - * 【定位】仿 host-dev/entry.js:把「core 协议 + 所用插件 + 游戏(逻辑/渲染/宿主)」汇成一个 - * iife 全局(WanglanmeiRef),index.html 调 bootRef 启动。α 不接 littlejsengine(裁定同 host-dev)。 - * 本文件 import-safe(node 可 import 不执行任何浏览器调用),boot 由页面显式触发。 + * 【定位】仿 host-dev/entry.js:把「core 协议 + 所用插件 + 游戏(逻辑/渲染/宿主)+ 引擎」汇成一个 + * iife 全局(WanglanmeiRef),index.html 调 bootRef 启动。 + * + * 【P1 · Option C:引擎 import 只落本入口,且经动态 import 延后求值(mustFix #1)】 + * 起因:本游戏的 import-graph.test.mjs(MODULES 表第 24 行)显式断言 `../entry.js` 可在 node 导入。 + * 而 littlejsengine 的 ESM 在**模块装载期**就触 window/AudioContext(esm.js:5556/6753), + * 顶层 `import * as LJS from 'littlejsengine'` 会让 node 一 import entry.js 即抛 ReferenceError → 该测红。 + * 修法:把引擎 import 从「顶层静态」改为「**包装层内动态 import**」: + * · node import entry.js 时**不**触发引擎装载(bootRef 未被调用)→ entry.js node import-safe → 过 import-graph。 + * · esbuild(bundle+iife,无 splitting)会把动态 import 的引擎**内联进单文件产物** + * (实测改写为 `Promise.resolve().then(()=>(引擎init(), 引擎命名空间))`,无 fetch/无外部 chunk) + * → 引擎仍真入 ref-bundle(metafile littlejsengine + 产物 engineInit 符号齐备,已 dry-build 亲验)。 + * · 浏览器运行期调 bootRef 时才求值引擎(window/AudioContext 已存在),拿到名字空间注入 main.js real 通道。 + * Q4 铁律不破:引擎 import 仍只活本入口;main.js(host 层)经注入用引擎、不直接 import;game-shop.js 游戏工厂零引擎 import。 */ 'use strict'; -import { bootRef } from './src/main.js'; +import { bootRef as bootRefCore } from './src/main.js'; -export { bootRef }; +/** + * 【Option C 包装层】对外 bootRef:动态 import 引擎名字空间后注入、委托 main.js 的真实现。 + * index.html 调的是 iife 全局 WanglanmeiRef.bootRef(即本包装)。 + * - 动态 import 延后求值:node import entry.js 不触引擎(保 entry.js node import-safe,过 import-graph 第 24 行); + * esbuild 把引擎内联进 ref-bundle(real 入包不丢,已 dry-build metafile 亲验)。 + * - stub 通道 main.js 不触达引擎,注入与否无副作用(保回滚锚行为逐行不变)。 + * - 调用方已显式给 engine 则尊重其值,不重复装载(便于 host-dev 等复用)。 + * @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement, engine?: any}} [opts] + * @returns {Promise} refHost 句柄 + */ +export async function bootRef(opts) { + // 动态 import:求值时机延后到 bootRef 被调用(浏览器运行期)。esbuild 内联,无外部请求。 + const engine = (opts && opts.engine) || (await import('littlejsengine')); + return bootRefCore(Object.assign({}, opts, { engine })); +} diff --git a/game-runtime/games/wanglanmei-ref/evidence/p1-real/checklist.json b/game-runtime/games/wanglanmei-ref/evidence/p1-real/checklist.json new file mode 100644 index 00000000..614b5f67 --- /dev/null +++ b/game-runtime/games/wanglanmei-ref/evidence/p1-real/checklist.json @@ -0,0 +1,75 @@ +{ + "checklist": { + "shougan": { + "tapFeedbackMaxFrames": 0, + "tapFeedbackWithin3": true, + "everyTapScalePulse": true, + "comboUiShown": false, + "flightEasingUsed": true, + "hitStopTriggered": false, + "bufferedServeReplays": 0 + }, + "meishu": { + "allDrawsViaPalette": true, + "vignetteEnabled": true, + "rushPaletteSeen": false, + "assetsLoaded": "13/13", + "noPrimaryPlaceholderRects": true + }, + "yinyue": { + "audioUnlocked": false, + "bgmReady": false, + "bgmCrossfadeSeen": false, + "sfxKindCount": 0, + "sfxKinds": [] + }, + "shendu": { + "loopReachable": true, + "loopOps": 25, + "failureReachable": true, + "failureReason": "reputation", + "priceTwoSidedLoss": true, + "upgradeTreeSize": 8, + "firstUpgradeDay": 2 + }, + "juese": { + "keeperSpriteResident": true, + "lineGroups": 14, + "lineTotal": 30, + "linesShownLive": 0 + } + }, + "measures": { + "particle": { + "gold": 0, + "cream": 0, + "smoke": 0 + }, + "audio": { + "sfxKinds": [ + "tick", + "shelve", + "arrive", + "coin", + "empty", + "upgrade" + ], + "bgm": { + "ready": true, + "failed": false, + "idleLevel": 0.23916264021400713, + "rushLevel": 5.689900643400063e-12 + } + }, + "assetsMissing": [], + "frames": 6152, + "taps": 517, + "tapFeedbackMaxFrames": 1, + "comboUiShown": 1, + "hitStopCount": 0, + "rushPaletteSeen": true, + "bgmCrossfadeSeen": true, + "linesShown": 20, + "bufferedServeReplays": 0 + } +} \ No newline at end of file diff --git a/game-runtime/games/wanglanmei-ref/evidence/p1-real/e2e-evidence.json b/game-runtime/games/wanglanmei-ref/evidence/p1-real/e2e-evidence.json new file mode 100644 index 00000000..844e176c --- /dev/null +++ b/game-runtime/games/wanglanmei-ref/evidence/p1-real/e2e-evidence.json @@ -0,0 +1,358 @@ +{ + "meta": { + "url": "http://127.0.0.1:4320/?engine=real&seed=305441741", + "seed": 305441741, + "startedAt": "2026-06-13T17:53:05.383Z", + "harness": "e2e-input.cdp.cjs(主会话)", + "engineMode": "real", + "coordMap": { + "rect": { + "l": 0, + "t": 0, + "w": 390, + "h": 844, + "vw": 390, + "vh": 844 + }, + "map": { + "l": 0, + "t": 0, + "sx": 1, + "sy": 1 + }, + "canvas": "#game-engine" + }, + "endedAt": "2026-06-13T17:54:49.277Z" + }, + "gates": { + "engineFrameDrive": { + "dFrame": 31, + "range": [ + 20, + 45 + ], + "s0": { + "frame": 41, + "time": 0.6666666666666666, + "timeReal": 0.7665380000000002, + "paused": false, + "timeScale": 1 + }, + "s1": { + "frame": 72, + "time": 1.1833333333333333, + "timeReal": 1.2832379999999994, + "paused": false, + "timeScale": 1 + } + }, + "engineWiringCallId": { + "hasSpawn": true, + "sample": [ + "audio.synth.synthSfx", + "audio.synth.synthSfx", + "audio.synth.synthSfx", + "audio.synth.synthSfx", + "audio.synth.synthSfx", + "audio.synth.synthSfx", + "audio.synth.synthSfx", + "audio.synth.synthSfx", + "audio.synth.synthSfx", + "audio.synth.synthSfx", + "particles.spawnEmitter", + "audio.synth.synthSfx", + "particles.spawnEmitter", + "audio.synth.synthSfx", + "particles.spawnEmitter", + "audio.synth.synthSfx", + "particles.spawnEmitter", + "audio.synth.synthSfx", + "particles.spawnEmitter", + "audio.synth.synthSfx" + ] + }, + "sameScreenPixels": { + "nonEmpty": true, + "width": 780, + "height": 1688, + "warmHits": 682, + "brightHits": 13028, + "maxCh": 254 + }, + "closedLoopRealInput": true, + "engineTakeover": true + }, + "failures": [], + "zonesSeenByPhase": { + "real-prep-day1": [ + "dec-soda", + "inc-soda", + "dec-snack", + "inc-snack", + "dec-bread", + "inc-bread", + "price-slider", + "up-speed1", + "up-shelf1", + "up-unlock_ice", + "up-decor1", + "up-unlock_toy", + "up-speed2", + "up-shelf2", + "up-decor2", + "openShop" + ], + "real-day2-prep": [ + "dec-soda", + "inc-soda", + "dec-snack", + "inc-snack", + "dec-bread", + "inc-bread", + "price-slider", + "up-speed1", + "up-shelf1", + "up-unlock_ice", + "up-decor1", + "up-unlock_toy", + "up-speed2", + "up-shelf2", + "up-decor2", + "openShop" + ] + }, + "stateSnapshots": [ + { + "tag": "real-menu", + "state": { + "phase": "title", + "day": 1, + "cash": 1200, + "reputation": 62, + "priceMul": 1, + "stock": {}, + "unlocked": [ + "soda", + "snack", + "bread" + ], + "upgrades": {}, + "customers": 0, + "combo": 0, + "today": { + "revenueJiao": 0, + "soldCostJiao": 0, + "served": 0, + "angry": 0, + "stockout": 0, + "walkaway": 0, + "rescued": 0, + "bigSales": 0, + "maxCombo": 0 + }, + "lifetime": { + "days": 0, + "served": 0, + "angry": 0, + "netJiao": 0, + "upgradesBought": 0 + }, + "gameoverReason": null + } + }, + { + "tag": "real-after-stock-day1", + "state": { + "phase": "prep", + "day": 1, + "cash": 840, + "reputation": 62, + "priceMul": 1, + "stock": { + "soda": 6, + "snack": 6, + "bread": 6 + }, + "unlocked": [ + "soda", + "snack", + "bread" + ], + "upgrades": {}, + "customers": 0, + "combo": 0, + "today": { + "revenueJiao": 0, + "soldCostJiao": 0, + "served": 0, + "angry": 0, + "stockout": 0, + "walkaway": 0, + "rescued": 0, + "bigSales": 0, + "maxCombo": 0 + }, + "lifetime": { + "days": 0, + "served": 0, + "angry": 0, + "netJiao": 0, + "upgradesBought": 0 + }, + "gameoverReason": null + } + }, + { + "tag": "real-after-serving-day1", + "state": { + "phase": "settle", + "day": 1, + "cash": 1239, + "reputation": 41.8, + "priceMul": 1, + "stock": { + "soda": 0, + "snack": 0, + "bread": 0 + }, + "unlocked": [ + "soda", + "snack", + "bread" + ], + "upgrades": {}, + "customers": 0, + "combo": 0, + "today": { + "revenueJiao": 799, + "soldCostJiao": 360, + "served": 18, + "angry": 0, + "stockout": 13, + "walkaway": 0, + "rescued": 0, + "bigSales": 0, + "maxCombo": 9 + }, + "lifetime": { + "days": 1, + "served": 18, + "angry": 0, + "netJiao": 39, + "upgradesBought": 0 + }, + "gameoverReason": null + } + } + ], + "hashes": {}, + "screenshots": [ + "real-menu.png", + "real-prep-day1.png", + "real-sameScreen.png", + "real-settle-day1.png", + "real-day2-upgraded.png" + ], + "boot": { + "anchors": [ + "t_boot", + "t_plugins_ready", + "t_game_init", + "t_first_paint", + "t_input_bound", + "t_game_start" + ], + "initOk": true, + "registeredPlugins": [ + "collision", + "physics-lite", + "gamefeel", + "particles-juice", + "palette-post", + "save-progress", + "audio-music", + "runtime-probe" + ] + }, + "checklist": { + "shougan": { + "tapFeedbackMaxFrames": 0, + "tapFeedbackWithin3": true, + "everyTapScalePulse": true, + "comboUiShown": false, + "flightEasingUsed": true, + "hitStopTriggered": false, + "bufferedServeReplays": 0 + }, + "meishu": { + "allDrawsViaPalette": true, + "vignetteEnabled": true, + "rushPaletteSeen": false, + "assetsLoaded": "13/13", + "noPrimaryPlaceholderRects": true + }, + "yinyue": { + "audioUnlocked": false, + "bgmReady": false, + "bgmCrossfadeSeen": false, + "sfxKindCount": 0, + "sfxKinds": [] + }, + "shendu": { + "loopReachable": true, + "loopOps": 25, + "failureReachable": true, + "failureReason": "reputation", + "priceTwoSidedLoss": true, + "upgradeTreeSize": 8, + "firstUpgradeDay": 2 + }, + "juese": { + "keeperSpriteResident": true, + "lineGroups": 14, + "lineTotal": 30, + "linesShownLive": 0 + } + }, + "measures": { + "particle": { + "gold": 0, + "cream": 0, + "smoke": 0 + }, + "audio": { + "sfxKinds": [ + "tick", + "shelve", + "arrive", + "coin", + "empty", + "upgrade" + ], + "bgm": { + "ready": true, + "failed": false, + "idleLevel": 0.23916264021400713, + "rushLevel": 5.689900643400063e-12 + } + }, + "assetsMissing": [], + "frames": 6152, + "taps": 517, + "tapFeedbackMaxFrames": 1, + "comboUiShown": 1, + "hitStopCount": 0, + "rushPaletteSeen": true, + "bgmCrossfadeSeen": true, + "linesShown": 20, + "bufferedServeReplays": 0 + }, + "softLive": { + "rushPaletteSeen": true, + "bgmCrossfadeSeen": true, + "sfxKindCount": 0, + "hitStopTriggered": false, + "audioUnlocked": false + }, + "pass": true +} \ No newline at end of file diff --git a/game-runtime/games/wanglanmei-ref/evidence/p1-real/input-trajectory.jsonl b/game-runtime/games/wanglanmei-ref/evidence/p1-real/input-trajectory.jsonl new file mode 100644 index 00000000..659879ee --- /dev/null +++ b/game-runtime/games/wanglanmei-ref/evidence/p1-real/input-trajectory.jsonl @@ -0,0 +1,517 @@ +{"seq":0,"wallMs":1781373187514,"kind":"tap","zoneId":"newGame","x":195,"y":588,"clientX":195,"clientY":588} +{"seq":1,"wallMs":1781373188147,"kind":"tap","zoneId":"inc-soda","x":329,"y":214,"clientX":329,"clientY":214} +{"seq":2,"wallMs":1781373188339,"kind":"tap","zoneId":"inc-soda","x":329,"y":214,"clientX":329,"clientY":214} +{"seq":3,"wallMs":1781373188626,"kind":"tap","zoneId":"inc-soda","x":329,"y":214,"clientX":329,"clientY":214} +{"seq":4,"wallMs":1781373188770,"kind":"tap","zoneId":"inc-soda","x":329,"y":214,"clientX":329,"clientY":214} +{"seq":5,"wallMs":1781373188903,"kind":"tap","zoneId":"inc-soda","x":329,"y":214,"clientX":329,"clientY":214} +{"seq":6,"wallMs":1781373189037,"kind":"tap","zoneId":"inc-soda","x":329,"y":214,"clientX":329,"clientY":214} +{"seq":7,"wallMs":1781373189171,"kind":"tap","zoneId":"inc-snack","x":329,"y":260,"clientX":329,"clientY":260} +{"seq":8,"wallMs":1781373189305,"kind":"tap","zoneId":"inc-snack","x":329,"y":260,"clientX":329,"clientY":260} +{"seq":9,"wallMs":1781373189454,"kind":"tap","zoneId":"inc-snack","x":329,"y":260,"clientX":329,"clientY":260} +{"seq":10,"wallMs":1781373189587,"kind":"tap","zoneId":"inc-snack","x":329,"y":260,"clientX":329,"clientY":260} +{"seq":11,"wallMs":1781373189720,"kind":"tap","zoneId":"inc-snack","x":329,"y":260,"clientX":329,"clientY":260} +{"seq":12,"wallMs":1781373189853,"kind":"tap","zoneId":"inc-snack","x":329,"y":260,"clientX":329,"clientY":260} +{"seq":13,"wallMs":1781373189987,"kind":"tap","zoneId":"inc-bread","x":329,"y":306,"clientX":329,"clientY":306} +{"seq":14,"wallMs":1781373190120,"kind":"tap","zoneId":"inc-bread","x":329,"y":306,"clientX":329,"clientY":306} +{"seq":15,"wallMs":1781373190254,"kind":"tap","zoneId":"inc-bread","x":329,"y":306,"clientX":329,"clientY":306} +{"seq":16,"wallMs":1781373190404,"kind":"tap","zoneId":"inc-bread","x":329,"y":306,"clientX":329,"clientY":306} +{"seq":17,"wallMs":1781373190538,"kind":"tap","zoneId":"inc-bread","x":329,"y":306,"clientX":329,"clientY":306} +{"seq":18,"wallMs":1781373190670,"kind":"tap","zoneId":"inc-bread","x":329,"y":306,"clientX":329,"clientY":306} +{"seq":19,"wallMs":1781373190820,"kind":"tap","zoneId":"openShop","x":195,"y":779,"clientX":195,"clientY":779} +{"seq":20,"wallMs":1781373191019,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":21,"wallMs":1781373191152,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":22,"wallMs":1781373191288,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":23,"wallMs":1781373191603,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":24,"wallMs":1781373191736,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":25,"wallMs":1781373191869,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":26,"wallMs":1781373192185,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":27,"wallMs":1781373192318,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":28,"wallMs":1781373192452,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":29,"wallMs":1781373192768,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":30,"wallMs":1781373192902,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":31,"wallMs":1781373193036,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":32,"wallMs":1781373193352,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":33,"wallMs":1781373193485,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":34,"wallMs":1781373193618,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":35,"wallMs":1781373193935,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":36,"wallMs":1781373194069,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":37,"wallMs":1781373194201,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":38,"wallMs":1781373194518,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":39,"wallMs":1781373194652,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":40,"wallMs":1781373194785,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":41,"wallMs":1781373195101,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":42,"wallMs":1781373195235,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":43,"wallMs":1781373195369,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":44,"wallMs":1781373195685,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":45,"wallMs":1781373195818,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":46,"wallMs":1781373195952,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":47,"wallMs":1781373196268,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":48,"wallMs":1781373196402,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":49,"wallMs":1781373196535,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":50,"wallMs":1781373196851,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":51,"wallMs":1781373196985,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":52,"wallMs":1781373197128,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":53,"wallMs":1781373197435,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":54,"wallMs":1781373197568,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":55,"wallMs":1781373197701,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":56,"wallMs":1781373198018,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":57,"wallMs":1781373198151,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":58,"wallMs":1781373198284,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":59,"wallMs":1781373198601,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":60,"wallMs":1781373198735,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":61,"wallMs":1781373198868,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":62,"wallMs":1781373199185,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":63,"wallMs":1781373199318,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":64,"wallMs":1781373199451,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":65,"wallMs":1781373199768,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":66,"wallMs":1781373199902,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":67,"wallMs":1781373200034,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":68,"wallMs":1781373200351,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":69,"wallMs":1781373200484,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":70,"wallMs":1781373200618,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":71,"wallMs":1781373200934,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":72,"wallMs":1781373201068,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":73,"wallMs":1781373201201,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":74,"wallMs":1781373201518,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":75,"wallMs":1781373201651,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":76,"wallMs":1781373201784,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":77,"wallMs":1781373202101,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":78,"wallMs":1781373202240,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":79,"wallMs":1781373202368,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":80,"wallMs":1781373202684,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":81,"wallMs":1781373202817,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":82,"wallMs":1781373202951,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":83,"wallMs":1781373203268,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":84,"wallMs":1781373203401,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":85,"wallMs":1781373203535,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":86,"wallMs":1781373203851,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":87,"wallMs":1781373203984,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":88,"wallMs":1781373204118,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":89,"wallMs":1781373204434,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":90,"wallMs":1781373204567,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":91,"wallMs":1781373204701,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":92,"wallMs":1781373205018,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":93,"wallMs":1781373205151,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":94,"wallMs":1781373205284,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":95,"wallMs":1781373205601,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":96,"wallMs":1781373205734,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":97,"wallMs":1781373205869,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":98,"wallMs":1781373206185,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":99,"wallMs":1781373206318,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":100,"wallMs":1781373206451,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":101,"wallMs":1781373206768,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":102,"wallMs":1781373206901,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":103,"wallMs":1781373207035,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":104,"wallMs":1781373207351,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":105,"wallMs":1781373207485,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":106,"wallMs":1781373207618,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":107,"wallMs":1781373207934,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":108,"wallMs":1781373208068,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":109,"wallMs":1781373208201,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":110,"wallMs":1781373208517,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":111,"wallMs":1781373208651,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":112,"wallMs":1781373208784,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":113,"wallMs":1781373209100,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":114,"wallMs":1781373209234,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":115,"wallMs":1781373209368,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":116,"wallMs":1781373209684,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":117,"wallMs":1781373209818,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":118,"wallMs":1781373209951,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":119,"wallMs":1781373210267,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":120,"wallMs":1781373210401,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":121,"wallMs":1781373210534,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":122,"wallMs":1781373210851,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":123,"wallMs":1781373210984,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":124,"wallMs":1781373211117,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":125,"wallMs":1781373211434,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":126,"wallMs":1781373211567,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":127,"wallMs":1781373211702,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":128,"wallMs":1781373212018,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":129,"wallMs":1781373212151,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":130,"wallMs":1781373212285,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":131,"wallMs":1781373212600,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":132,"wallMs":1781373212734,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":133,"wallMs":1781373212867,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":134,"wallMs":1781373213184,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":135,"wallMs":1781373213317,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":136,"wallMs":1781373213451,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":137,"wallMs":1781373213767,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":138,"wallMs":1781373213901,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":139,"wallMs":1781373214036,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":140,"wallMs":1781373214351,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":141,"wallMs":1781373214484,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":142,"wallMs":1781373214617,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":143,"wallMs":1781373214934,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":144,"wallMs":1781373215067,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":145,"wallMs":1781373215201,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":146,"wallMs":1781373215518,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":147,"wallMs":1781373215651,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":148,"wallMs":1781373215786,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":149,"wallMs":1781373216101,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":150,"wallMs":1781373216234,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":151,"wallMs":1781373216367,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":152,"wallMs":1781373216684,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":153,"wallMs":1781373216817,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":154,"wallMs":1781373216951,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":155,"wallMs":1781373217269,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":156,"wallMs":1781373217400,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":157,"wallMs":1781373217534,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":158,"wallMs":1781373217851,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":159,"wallMs":1781373217984,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":160,"wallMs":1781373218117,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":161,"wallMs":1781373218434,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":162,"wallMs":1781373218567,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":163,"wallMs":1781373218701,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":164,"wallMs":1781373219017,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":165,"wallMs":1781373219150,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":166,"wallMs":1781373219284,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":167,"wallMs":1781373219602,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":168,"wallMs":1781373219736,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":169,"wallMs":1781373219869,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":170,"wallMs":1781373220184,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":171,"wallMs":1781373220318,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":172,"wallMs":1781373220451,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":173,"wallMs":1781373220767,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":174,"wallMs":1781373220901,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":175,"wallMs":1781373221034,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":176,"wallMs":1781373221350,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":177,"wallMs":1781373221484,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":178,"wallMs":1781373221617,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":179,"wallMs":1781373221934,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":180,"wallMs":1781373222067,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":181,"wallMs":1781373222202,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":182,"wallMs":1781373222517,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":183,"wallMs":1781373222651,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":184,"wallMs":1781373222784,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":185,"wallMs":1781373223100,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":186,"wallMs":1781373223234,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":187,"wallMs":1781373223368,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":188,"wallMs":1781373223684,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":189,"wallMs":1781373223817,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":190,"wallMs":1781373223951,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":191,"wallMs":1781373224268,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":192,"wallMs":1781373224401,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":193,"wallMs":1781373224534,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":194,"wallMs":1781373224851,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":195,"wallMs":1781373224985,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":196,"wallMs":1781373225117,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":197,"wallMs":1781373225434,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":198,"wallMs":1781373225567,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":199,"wallMs":1781373225700,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":200,"wallMs":1781373226017,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":201,"wallMs":1781373226151,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":202,"wallMs":1781373226284,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":203,"wallMs":1781373226601,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":204,"wallMs":1781373226735,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":205,"wallMs":1781373226867,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":206,"wallMs":1781373227183,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":207,"wallMs":1781373227317,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":208,"wallMs":1781373227451,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":209,"wallMs":1781373227769,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":210,"wallMs":1781373227901,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":211,"wallMs":1781373228034,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":212,"wallMs":1781373228350,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":213,"wallMs":1781373228484,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":214,"wallMs":1781373228620,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":215,"wallMs":1781373228955,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":216,"wallMs":1781373229084,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":217,"wallMs":1781373229217,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":218,"wallMs":1781373229536,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":219,"wallMs":1781373229667,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":220,"wallMs":1781373229801,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":221,"wallMs":1781373230117,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":222,"wallMs":1781373230251,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":223,"wallMs":1781373230387,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":224,"wallMs":1781373230701,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":225,"wallMs":1781373230840,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":226,"wallMs":1781373230970,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":227,"wallMs":1781373231285,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":228,"wallMs":1781373231419,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":229,"wallMs":1781373231550,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":230,"wallMs":1781373231867,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":231,"wallMs":1781373232000,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":232,"wallMs":1781373232134,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":233,"wallMs":1781373232451,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":234,"wallMs":1781373232584,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":235,"wallMs":1781373232717,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":236,"wallMs":1781373233034,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":237,"wallMs":1781373233168,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":238,"wallMs":1781373233300,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":239,"wallMs":1781373233617,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":240,"wallMs":1781373233755,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":241,"wallMs":1781373233884,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":242,"wallMs":1781373234200,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":243,"wallMs":1781373234334,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":244,"wallMs":1781373234467,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":245,"wallMs":1781373234784,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":246,"wallMs":1781373234919,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":247,"wallMs":1781373235052,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":248,"wallMs":1781373235362,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":249,"wallMs":1781373235485,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":250,"wallMs":1781373235619,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":251,"wallMs":1781373235934,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":252,"wallMs":1781373236068,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":253,"wallMs":1781373236200,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":254,"wallMs":1781373236517,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":255,"wallMs":1781373236650,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":256,"wallMs":1781373236783,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":257,"wallMs":1781373237100,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":258,"wallMs":1781373237234,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":259,"wallMs":1781373237367,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":260,"wallMs":1781373237685,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":261,"wallMs":1781373237817,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":262,"wallMs":1781373237952,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":263,"wallMs":1781373238267,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":264,"wallMs":1781373238400,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":265,"wallMs":1781373238534,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":266,"wallMs":1781373238850,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":267,"wallMs":1781373238984,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":268,"wallMs":1781373239117,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":269,"wallMs":1781373239435,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":270,"wallMs":1781373239568,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":271,"wallMs":1781373239700,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":272,"wallMs":1781373240017,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":273,"wallMs":1781373240150,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":274,"wallMs":1781373240283,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":275,"wallMs":1781373240601,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":276,"wallMs":1781373240733,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":277,"wallMs":1781373240867,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":278,"wallMs":1781373241184,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":279,"wallMs":1781373241317,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":280,"wallMs":1781373241450,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":281,"wallMs":1781373241766,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":282,"wallMs":1781373241900,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":283,"wallMs":1781373242033,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":284,"wallMs":1781373242350,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":285,"wallMs":1781373242484,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":286,"wallMs":1781373242619,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":287,"wallMs":1781373242933,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":288,"wallMs":1781373243066,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":289,"wallMs":1781373243201,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":290,"wallMs":1781373243517,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":291,"wallMs":1781373243650,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":292,"wallMs":1781373243784,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":293,"wallMs":1781373244100,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":294,"wallMs":1781373244234,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":295,"wallMs":1781373244367,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":296,"wallMs":1781373244683,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":297,"wallMs":1781373244816,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":298,"wallMs":1781373244950,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":299,"wallMs":1781373245267,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":300,"wallMs":1781373245400,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":301,"wallMs":1781373245533,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":302,"wallMs":1781373245850,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":303,"wallMs":1781373245984,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":304,"wallMs":1781373246117,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":305,"wallMs":1781373246433,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":306,"wallMs":1781373246566,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":307,"wallMs":1781373246701,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":308,"wallMs":1781373247016,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":309,"wallMs":1781373247150,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":310,"wallMs":1781373247284,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":311,"wallMs":1781373247601,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":312,"wallMs":1781373247734,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":313,"wallMs":1781373247868,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":314,"wallMs":1781373248183,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":315,"wallMs":1781373248316,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":316,"wallMs":1781373248449,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":317,"wallMs":1781373248766,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":318,"wallMs":1781373248900,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":319,"wallMs":1781373249033,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":320,"wallMs":1781373249349,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":321,"wallMs":1781373249501,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":322,"wallMs":1781373249638,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":323,"wallMs":1781373249949,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":324,"wallMs":1781373250083,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":325,"wallMs":1781373250217,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":326,"wallMs":1781373250534,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":327,"wallMs":1781373250666,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":328,"wallMs":1781373250800,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":329,"wallMs":1781373251116,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":330,"wallMs":1781373251250,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":331,"wallMs":1781373251383,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":332,"wallMs":1781373251699,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":333,"wallMs":1781373251833,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":334,"wallMs":1781373251966,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":335,"wallMs":1781373252283,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":336,"wallMs":1781373252416,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":337,"wallMs":1781373252550,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":338,"wallMs":1781373252866,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":339,"wallMs":1781373253000,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":340,"wallMs":1781373253133,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":341,"wallMs":1781373253452,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":342,"wallMs":1781373253582,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":343,"wallMs":1781373253718,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":344,"wallMs":1781373254032,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":345,"wallMs":1781373254166,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":346,"wallMs":1781373254300,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":347,"wallMs":1781373254616,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":348,"wallMs":1781373254749,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":349,"wallMs":1781373254884,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":350,"wallMs":1781373255201,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":351,"wallMs":1781373255333,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":352,"wallMs":1781373255466,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":353,"wallMs":1781373255782,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":354,"wallMs":1781373255916,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":355,"wallMs":1781373256050,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":356,"wallMs":1781373256366,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":357,"wallMs":1781373256499,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":358,"wallMs":1781373256635,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":359,"wallMs":1781373256950,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":360,"wallMs":1781373257082,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":361,"wallMs":1781373257216,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":362,"wallMs":1781373257533,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":363,"wallMs":1781373257667,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":364,"wallMs":1781373257799,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":365,"wallMs":1781373258117,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":366,"wallMs":1781373258249,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":367,"wallMs":1781373258375,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":368,"wallMs":1781373258683,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":369,"wallMs":1781373258819,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":370,"wallMs":1781373258950,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":371,"wallMs":1781373259266,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":372,"wallMs":1781373259400,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":373,"wallMs":1781373259533,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":374,"wallMs":1781373259849,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":375,"wallMs":1781373259982,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":376,"wallMs":1781373260116,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":377,"wallMs":1781373260432,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":378,"wallMs":1781373260566,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":379,"wallMs":1781373260700,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":380,"wallMs":1781373261015,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":381,"wallMs":1781373261149,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":382,"wallMs":1781373261282,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":383,"wallMs":1781373261599,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":384,"wallMs":1781373261732,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":385,"wallMs":1781373261865,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":386,"wallMs":1781373262182,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":387,"wallMs":1781373262315,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":388,"wallMs":1781373262448,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":389,"wallMs":1781373262768,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":390,"wallMs":1781373262898,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":391,"wallMs":1781373263032,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":392,"wallMs":1781373263348,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":393,"wallMs":1781373263482,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":394,"wallMs":1781373263615,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":395,"wallMs":1781373263932,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":396,"wallMs":1781373264066,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":397,"wallMs":1781373264199,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":398,"wallMs":1781373264515,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":399,"wallMs":1781373264649,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":400,"wallMs":1781373264782,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":401,"wallMs":1781373265099,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":402,"wallMs":1781373265232,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":403,"wallMs":1781373265365,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":404,"wallMs":1781373265682,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":405,"wallMs":1781373265815,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":406,"wallMs":1781373265949,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":407,"wallMs":1781373266265,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":408,"wallMs":1781373266398,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":409,"wallMs":1781373266532,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":410,"wallMs":1781373266848,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":411,"wallMs":1781373266982,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":412,"wallMs":1781373267115,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":413,"wallMs":1781373267432,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":414,"wallMs":1781373267566,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":415,"wallMs":1781373267700,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":416,"wallMs":1781373268015,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":417,"wallMs":1781373268148,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":418,"wallMs":1781373268284,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":419,"wallMs":1781373268598,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":420,"wallMs":1781373268732,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":421,"wallMs":1781373268865,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":422,"wallMs":1781373269182,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":423,"wallMs":1781373269318,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":424,"wallMs":1781373269449,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":425,"wallMs":1781373269766,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":426,"wallMs":1781373269917,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":427,"wallMs":1781373270049,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":428,"wallMs":1781373270366,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":429,"wallMs":1781373270499,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":430,"wallMs":1781373270633,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":431,"wallMs":1781373270948,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":432,"wallMs":1781373271082,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":433,"wallMs":1781373271215,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":434,"wallMs":1781373271532,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":435,"wallMs":1781373271665,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":436,"wallMs":1781373271798,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":437,"wallMs":1781373272115,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":438,"wallMs":1781373272248,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":439,"wallMs":1781373272383,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":440,"wallMs":1781373272700,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":441,"wallMs":1781373272832,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":442,"wallMs":1781373272967,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":443,"wallMs":1781373273282,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":444,"wallMs":1781373273416,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":445,"wallMs":1781373273549,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":446,"wallMs":1781373273866,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":447,"wallMs":1781373273998,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":448,"wallMs":1781373274132,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":449,"wallMs":1781373274448,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":450,"wallMs":1781373274582,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":451,"wallMs":1781373274715,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":452,"wallMs":1781373275032,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":453,"wallMs":1781373275165,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":454,"wallMs":1781373275298,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":455,"wallMs":1781373275616,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":456,"wallMs":1781373275749,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":457,"wallMs":1781373275882,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":458,"wallMs":1781373276198,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":459,"wallMs":1781373276332,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":460,"wallMs":1781373276465,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":461,"wallMs":1781373276781,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":462,"wallMs":1781373276915,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":463,"wallMs":1781373277048,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":464,"wallMs":1781373277365,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":465,"wallMs":1781373277498,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":466,"wallMs":1781373277634,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":467,"wallMs":1781373277966,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":468,"wallMs":1781373278100,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":469,"wallMs":1781373278233,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":470,"wallMs":1781373278548,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":471,"wallMs":1781373278682,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":472,"wallMs":1781373278815,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":473,"wallMs":1781373279134,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":474,"wallMs":1781373279265,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":475,"wallMs":1781373279398,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":476,"wallMs":1781373279716,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":477,"wallMs":1781373279848,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":478,"wallMs":1781373279982,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":479,"wallMs":1781373280299,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":480,"wallMs":1781373280432,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":481,"wallMs":1781373280565,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":482,"wallMs":1781373280881,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":483,"wallMs":1781373281015,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":484,"wallMs":1781373281148,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":485,"wallMs":1781373281464,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":486,"wallMs":1781373281598,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":487,"wallMs":1781373281731,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":488,"wallMs":1781373282048,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":489,"wallMs":1781373282181,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":490,"wallMs":1781373282315,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":491,"wallMs":1781373282632,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":492,"wallMs":1781373282765,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":493,"wallMs":1781373282900,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":494,"wallMs":1781373283214,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":495,"wallMs":1781373283348,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":496,"wallMs":1781373283481,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":497,"wallMs":1781373283798,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":498,"wallMs":1781373283932,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":499,"wallMs":1781373284064,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":500,"wallMs":1781373284382,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":501,"wallMs":1781373284515,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":502,"wallMs":1781373284649,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":503,"wallMs":1781373284965,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":504,"wallMs":1781373285098,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":505,"wallMs":1781373285231,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":506,"wallMs":1781373285548,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":507,"wallMs":1781373285681,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":508,"wallMs":1781373285814,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":509,"wallMs":1781373286131,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":510,"wallMs":1781373286264,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":511,"wallMs":1781373286397,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":512,"wallMs":1781373286714,"kind":"tap","zoneId":"serve-soda","x":74,"y":618,"clientX":74,"clientY":618} +{"seq":513,"wallMs":1781373286866,"kind":"tap","zoneId":"serve-snack","x":198,"y":618,"clientX":198,"clientY":618} +{"seq":514,"wallMs":1781373286999,"kind":"tap","zoneId":"serve-bread","x":322,"y":618,"clientX":322,"clientY":618} +{"seq":515,"wallMs":1781373288600,"kind":"tap","zoneId":"confirmSettle","x":195,"y":594,"clientX":195,"clientY":594} +{"seq":516,"wallMs":1781373288794,"kind":"tap","zoneId":"up-speed1","x":108,"y":442,"clientX":108,"clientY":442} diff --git a/game-runtime/games/wanglanmei-ref/evidence/p1-real/probe.jsonl b/game-runtime/games/wanglanmei-ref/evidence/p1-real/probe.jsonl new file mode 100644 index 00000000..b6fd1d3a --- /dev/null +++ b/game-runtime/games/wanglanmei-ref/evidence/p1-real/probe.jsonl @@ -0,0 +1,6 @@ +{"seq":0,"anchor":"t_boot","tMono":0,"buildSha":"","pluginSet":["audio-music","collision","gamefeel","palette-post","particles-juice","physics-lite","runtime-probe","save-progress"],"prevHash":"428bfee0","hash":"aaca40e4","extra":{"by":"ref-host","stage":"pre-init","engineMode":"real"},"warning":null} +{"seq":1,"anchor":"t_plugins_ready","tMono":0,"buildSha":"","pluginSet":["audio-music","collision","gamefeel","palette-post","particles-juice","physics-lite","runtime-probe","save-progress"],"prevHash":"aaca40e4","hash":"71ff4c61","extra":{"initialized":7,"ok":true},"warning":null} +{"seq":2,"anchor":"t_game_init","tMono":0,"buildSha":"","pluginSet":["audio-music","collision","gamefeel","palette-post","particles-juice","physics-lite","runtime-probe","save-progress"],"prevHash":"71ff4c61","hash":"3c4297be","extra":{"assetsLoaded":13,"assetsTotal":13,"loopProofOk":true},"warning":null} +{"seq":3,"anchor":"t_first_paint","tMono":0,"buildSha":"","pluginSet":["audio-music","collision","gamefeel","palette-post","particles-juice","physics-lite","runtime-probe","save-progress"],"prevHash":"3c4297be","hash":"9f2c7a57","extra":{"frame":1},"warning":null} +{"seq":4,"anchor":"t_input_bound","tMono":16.667,"buildSha":"","pluginSet":["audio-music","collision","gamefeel","palette-post","particles-juice","physics-lite","runtime-probe","save-progress"],"prevHash":"9f2c7a57","hash":"321fb8ee","extra":{"stage":"registered","type":"pointerdown"},"warning":null} +{"seq":5,"anchor":"t_game_start","tMono":16.667,"buildSha":"","pluginSet":["audio-music","collision","gamefeel","palette-post","particles-juice","physics-lite","runtime-probe","save-progress"],"prevHash":"321fb8ee","hash":"470c61d2","extra":{"mode":"play","engineMode":"real"},"warning":null} diff --git a/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-day2-upgraded.png b/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-day2-upgraded.png new file mode 100644 index 00000000..f1a891d1 Binary files /dev/null and b/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-day2-upgraded.png differ diff --git a/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-menu.png b/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-menu.png new file mode 100644 index 00000000..2d5ea9ee Binary files /dev/null and b/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-menu.png differ diff --git a/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-prep-day1.png b/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-prep-day1.png new file mode 100644 index 00000000..cd69a01c Binary files /dev/null and b/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-prep-day1.png differ diff --git a/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-sameScreen.png b/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-sameScreen.png new file mode 100644 index 00000000..a5b6ac6f Binary files /dev/null and b/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-sameScreen.png differ diff --git a/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-settle-day1.png b/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-settle-day1.png new file mode 100644 index 00000000..dd77eff1 Binary files /dev/null and b/game-runtime/games/wanglanmei-ref/evidence/p1-real/real-settle-day1.png differ diff --git a/game-runtime/games/wanglanmei-ref/index.html b/game-runtime/games/wanglanmei-ref/index.html index 6f85a005..f7e34924 100644 --- a/game-runtime/games/wanglanmei-ref/index.html +++ b/game-runtime/games/wanglanmei-ref/index.html @@ -5,8 +5,11 @@ · 集成段(mini-desktop)锁参构建:见 BUILD.md(产物 dist/ref-bundle.js,iife 全局 WanglanmeiRef); · serve 本目录(test/harness/static-serve.cjs),浏览器打开; · ?seed=12345 注入主随机种子(缺省固定种子,确定可复现); - · ?mode=evidence 取证模式:不起 RAF,由 window.__refHost.stepFrames/tap 确定性驱动; - · 取证导出:window.__refForensics / window.__refChecklist(字段说明见 FORENSICS.md)。 + · ?engine=stub(缺省·回滚锚)/ ?engine=real(引擎掌帧·游戏美术与引擎粒子同屏,P1); + · ?mode=evidence 取证模式:stub 下不起 RAF,由 window.__refHost.stepFrames/tap 确定性驱动; + · 取证导出:window.__refForensics / window.__refChecklist(字段说明见 FORENSICS.md); + · 就绪标记:__refBooted(bootRef().then 置,两通道通用);real 通道额外置 __refBootedReal(main.js setupReal 内 game.init 完成后置, + CDP 取证 real 分支须等它再驱动——见 e2e-input.cdp.cjs;M1)。 注意:页面对外文本走 IP gate(零受保护字样);本页标题即对外名「暖阳小卖部」。 --> diff --git a/game-runtime/games/wanglanmei-ref/src/game-shop.js b/game-runtime/games/wanglanmei-ref/src/game-shop.js new file mode 100644 index 00000000..c7545b23 --- /dev/null +++ b/game-runtime/games/wanglanmei-ref/src/game-shop.js @@ -0,0 +1,729 @@ +/** + * game-shop.js — 暖阳小卖部 游戏工厂(装载契约 GameHostFactory 实现 · 硬独立文件 · 零引擎 import) + * owner:Runner v2 P1 | 消费方:main.js(host boot 调本工厂建 GameInstance)+ P3 生成 agent(复制参照靶) + * + * ════════════════════════════════════════════════════════════════════════════ + * 【本文件是什么·A3 硬约束】 + * 这是「游戏宿主装载契约」(game-runtime/src/core/game-host.d.ts,第 9 类契约)的 ref 参照实现: + * · 默认导出一个 GameHostFactory(createShopGame(opts) → GameInstance); + * · 零引擎 import(不 `import littlejsengine`;引擎能力一律经 boot.ctx.getEngine(),Q4 铁律); + * · 必须是独立、可被 P3 生成 agent 直接复制的工厂模块(禁 main.js 内联闭包糊过——P3 需真实参照)。 + * GameInstance 生命周期对齐引擎五回调(init/update/render/onInput?/destroy,见契约): + * · init(boot):建游戏对象(shop-core/line-picker/fx/audio)、装资产、订阅 boot.ctx.getInput();保存 boot.mainContext 为绘制面。 + * · update(dt):逻辑步(core.update / bundle.tick / applyEvents / fx.step / 联动)。 + * · render(g):绘制段(drawFrame(g,...) + palettePost.renderPost(g));g=host 注入的绘制面(real=引擎 mainContext / stub=#game ctx)。 + * · destroy():卸载卫生(P1 最小实现,须声明)。 + * + * 【host↔游戏 接线 seam(为何 opts 携带 runtime)】 + * 装载契约 boot 只承「契约脊柱」{ctx, mainContext, canvas, seed}(跨游戏稳定面)。 + * 而本游戏复用的能力插件实例(collision/gamefeel/juice/palettePost/audioMusic/save)+ host 私有 bundle + * (deriveContextFor / frameCount,非 PluginContext 成员)由 host 拥有并经**工厂 opts** 注入(host↔本游戏配对的实现细节)。 + * GameHostFactory 的 opts 形参正为此 additive 扩展位预留(契约 §5)。这样:契约面(boot)保持纯净, + * host wiring(具体 runtime 句柄)走 opts,P3 生成游戏由其 host 按同形提供 runtime。 + * + * 【红线遵守】 + * - 零裸 Date.now/Math.random/AudioContext —— 全部经 boot.ctx 受控面 / host 注入的 audio director; + * - 不 import littlejsengine(引擎能力经 boot.ctx.getEngine()); + * - 游戏逻辑与 stub/real 通道无关(同一份工厂;绘制面差异由 host 注入的 g 吸收,DPR 补偿由 host 在 render 外层处理)。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +import { aabbVsAabb } from '../../../src/plugins/collision/impl.js'; +import { createShopCore } from './shop-core.js'; +import { createLinePicker, LINES } from './shopkeeper-lines.js'; +import { createPalette } from './palette.js'; +import { loadAssets, PRODUCT_IMAGE_KEY } from './assets.js'; +import { createHostFx } from './hostfx.js'; +import { createAudioDirector } from './audio-director.js'; +import { drawFrame, computeZones, shelfSlotRect, W as VIEW_W, H as VIEW_H, LAYOUT } from './render.js'; +import { proveCoreLoop, proveFailure } from './loop-proof.js'; +import { estimateDayOutcome, ECON, fmtYuan, productOf } from './balance.js'; + +/** 固定逻辑步(与 host FIXED_DT 对齐;引擎 real 通道恒定步长,stub evidence 同值)。 */ +const FIXED_DT = 1 / 60; + +/** + * 【装载契约 GameHostFactory】建暖阳小卖部游戏实例。 + * @param {Object} [opts] 工厂选项(host wiring + additive 扩展位)。 + * @param {Object} opts.runtime host 注入的运行时句柄(host 拥有,本游戏复用): + * - plugins:已注册并 init 的能力插件实例 { collision, physics, gamefeel, juice, palettePost, audioMusic, save }; + * - bundle:host 受控面 bundle(deriveContextFor/frameCount 等 host 私有编排成员,非 PluginContext); + * - getRealAudioCtx:取真实 AudioContext 的回调(audio director 用,host 管解锁时机)。 + * @returns {import('../../../src/core/game-host.d.ts').GameInstance} + */ +export default function createShopGame(opts) { + const o = opts || {}; + const runtime = o.runtime || {}; + const plugins = runtime.plugins || {}; + const bundle = runtime.bundle; // host 私有 bundle(deriveContextFor/frameCount);本游戏复用 host 编排面 + const getRealAudioCtx = runtime.getRealAudioCtx || (() => null); + + // 能力插件实例(host 已注册+init;本游戏经其公开 API 用,一职一路)。 + const collision = plugins.collision; + const juice = plugins.juice; + const palettePost = plugins.palettePost; + const audioMusic = plugins.audioMusic; + const save = plugins.save; + + /* ── 以下闭包态在 init(boot) 内填充(boot 提供受控面/绘制面/种子/可见画布)── */ + /** @type {import('../../../src/core/game-host.d.ts').GameHostBootContext|null} */ + let boot = null; + /** @type {import('../../../src/core/api.d.ts').PluginContext|null} */ + let ctx = null; + let seed = 0; + // 【mustFix #2:并轨成单一推进时钟】步进时钟读数(毫秒)。 + // 原缺陷:本游戏曾持私有 mockNowMs 并在 update 内 += dt*1000 自增,与 host 喂 bundle.clock 的钟分裂 → + // bundle.clock 恒 0 → 输入事件 tMs=0 → InputBuffer 记 0 → 接待缓冲补打窗口判恒假(measures.bufferedServeReplays 恒 0)。 + // 修法:删私有自增,本钟改为「host 受控时钟(boot.ctx.time.nowMs(),即 host 唯一推进的 mockNowMs)的读数」。 + // - update→applyEvents→render 同步流:在 update 首行刷新本快照(host 已在调 update 前自增,值=本帧逻辑钟), + // 供本流内一切相对时间计算(say/banner/ghosts 过期/idle 节拍)逐行读同一帧值(与原 update 首行自增后逐行一致)。 + // - onTap(输入回调,帧间触发):另读 ctx.time.nowMs() 取**当刻**值(见 onTap 内 pendingServe.tMs),保与 + // InputBuffer 同次 _emit 盖的 tMs 同钟同刻 → 补打窗口判真实生效。 + let mockNowMs = 0; + + // 游戏对象(init 内建)。 + let core = null, pickLine = null, pal = null, fx = null, audio = null, assets = null; + // boot 期纯逻辑证明(init 内跑,挂 forensics)。 + let loopProof = null, failureProof = null, priceProof = null; + // 输入订阅句柄(destroy 时取消)。 + let subDown = null, subMove = null, subUp = null; + // 是否已 init(destroy 幂等 + update/render 在 init 前空跑保护)。 + let inited = false; + // 音频是否已解锁(host 拥有 AudioContext 解锁时机,经 _onAudioUnlock 通知本游戏;游戏据此起 BGM + 空点 tick 门控)。 + let audioUnlocked = false; + + /* ── 宿主 UI 态(init 内初始化,避免实例间共享)── */ + const ui = { + bubbleText: '', + bubbleUntil: 0, + bubbleShownAt: -9999, + keeperWaveUntil: 0, + tickerText: '小本生意,用心经营。', + banner: null, // {text,color,until,dur} + panelSlide: 1, // prep 面板滑入 0→1 + settleAnim: 0, // 日结计数 0→1 + /** @type {Array<{slot:number, avatar:number, emote:string, until:number}>} 离场幽灵(视觉余韵)。 */ + ghosts: [], + }; + /** @type {Record} 顾客出生时刻(走位动画)。 */ + const customerBornMs = {}; + + /* ── 度量(checklist 自动子项的数据源)── */ + const measures = { + frames: 0, + taps: 0, + tapFeedbackMaxFrames: 0, + comboUiShown: 0, + hitStopCount: 0, + rushPaletteSeen: false, + bgmCrossfadeSeen: false, + linesShown: 0, + bufferedServeReplays: 0, + }; + /** @type {Array} 最近逻辑事件环形缓冲(取证)。 */ + const recentEvents = []; + function pushRecent(e) { + recentEvents.push(e); + if (recentEvents.length > 240) recentEvents.shift(); + } + + /* ── 台词气泡 ── */ + /** + * 店主说一句(场景取词;普通优先级 700ms 内不顶替)。 + * @param {string} sceneKey @param {boolean} [force] 高优先级强插 + */ + function say(sceneKey, force) { + if (!force && mockNowMs - ui.bubbleShownAt < 700 && ui.bubbleUntil > mockNowMs) return; + const text = pickLine(sceneKey); + if (!text) return; + ui.bubbleText = text; + ui.bubbleUntil = mockNowMs + 2600; + ui.bubbleShownAt = mockNowMs; + ui.tickerText = text; + measures.linesShown += 1; + fx.springKick('bubble', 2.2); + } + + /** 横幅。 */ + function banner(text, color, durMs) { + ui.banner = { text, color, dur: durMs || 1500, until: mockNowMs + (durMs || 1500) }; + } + + function hasSave() { + return !!save.get('profile'); + } + + /* ── 命中区(视觉同源几何;collision broadphase + 窄相点测)── */ + /** @type {Array} 当前帧命中区。 */ + let zones = []; + function rebuildZones() { + zones = computeZones(core, { hasSave: hasSave() }); + collision.spatial.clear(); + for (const z of zones) collision.spatial.insert(z.id, { x: z.x, y: z.y, hw: z.hw, hh: z.hh }); + } + /** 点测:broadphase 粗筛 → aabbVsAabb 窄相 → 取 z 最高者。 */ + function hitZone(x, y) { + const candidates = collision.spatial.queryPoint(x, y); + let best = null; + for (const c of candidates) { + const z = zones.find((zz) => zz.id === c.id); + if (!z) continue; + if (!aabbVsAabb({ x, y, hw: 0, hh: 0 }, { x: z.x, y: z.y, hw: z.hw, hh: z.hh }).hit) continue; + if (!best || z.z > best.z) best = z; + } + return best; + } + + /* ── 接待缓冲重放(P4 输入缓冲:冷却期点击不丢,窗口内自动补打)── */ + /** @type {{productId:string, tMs:number}|null} */ + let pendingServe = null; + function maybeReplayBufferedServe() { + if (!pendingServe || core.state.phase !== 'open' || core.state.serveCooldown > 0) return; + const within = mockNowMs - pendingServe.tMs <= 260; + const gamefeel = plugins.gamefeel; + if (within && gamefeel.inputBuffer && gamefeel.inputBuffer.consume('pointerdown', mockNowMs)) { + const pid = pendingServe.productId; + pendingServe = null; + const r = core.serve(pid); + if (r.ok) measures.bufferedServeReplays += 1; + } else if (!within) { + pendingServe = null; + } + } + + /* ── 点击路由 ── */ + let sliderDrag = null; // {track} + /** 滑条 x → 定价。 */ + function priceFromX(x, track) { + const v = Math.max(0, Math.min(1, (x - track.x) / track.w)); + core.setPrice(ECON.priceMin + v * (ECON.priceMax - ECON.priceMin)); + } + + function onTap(e) { + measures.taps += 1; + const tapFrame = bundle.frameCount(); + fx.addTapRing(e.x, e.y); // 当帧反馈(≤1 渲染帧可见) + const z = hitZone(e.x, e.y); + if (!z) { + // 空点:极轻 tick(仍有听觉回执),不打扰。 + if (audioUnlocked) audio.sfx('tick'); + noteFeedback(tapFrame); + return; + } + fx.springKick('z:' + z.id, -2.6); // 按压脉冲(render 侧按钮读同名弹簧 'z:') + switch (z.kind) { + case 'serve': { + const pid = z.payload.productId; + fx.springKick('slot-' + pid, -3.2); + const r = core.serve(pid); + if (!r.ok) { + if (r.reason === 'cooldown') { + // 【mustFix #2】tMs 取**本次输入事件自带的受控时间戳 e.tMs**(= inputBridge._emit 一次性盖、 + // 并与 gamefeel InputBuffer 同次 _emit 记录的 tMs 完全同值)→ 补打窗口判(now−tMs<=260)与 + // InputBuffer.consume 的 windowMs 判读同一钟同一刻,真实生效(取代原读私有 mockNowMs 致的双钟错位)。 + pendingServe = { productId: pid, tMs: e.tMs }; // 输入缓冲补打 + } else if (r.reason === 'no_stock') { + audio.sfx('empty'); + const rect = shelfSlotRect(core.state.unlocked.indexOf(pid)); + fx.addFloater('缺货…', rect.x + rect.w / 2, rect.y, 'danger', 13); + } else if (r.reason === 'no_customer') { + audio.sfx('tick'); + const rect = shelfSlotRect(core.state.unlocked.indexOf(pid)); + fx.addFloater('没人要这个', rect.x + rect.w / 2, rect.y, 'inkSoft', 12); + } + } + break; + } + case 'stockInc': { + const r = core.buyStock(z.payload.productId, 1); + if (r.ok) audio.sfx('tick'); + else { + audio.sfx('empty'); + fx.addFloater(r.reason === 'no_cash' ? '钱不够了' : '货架满了', z.x, z.y - 22, 'danger', 13); + } + break; + } + case 'stockDec': { + const r = core.refundStock(z.payload.productId, 1); + audio.sfx(r.ok ? 'tick' : 'empty'); + break; + } + case 'slider': + sliderDrag = { track: z.payload.track }; + priceFromX(e.x, z.payload.track); + audio.sfx('tick'); + break; + case 'upgrade': { + const r = core.buyUpgrade(z.payload.upgradeId); + if (!r.ok && r.reason === 'no_cash') { + audio.sfx('empty'); + fx.addFloater('钱不够了', z.x, z.y - 24, 'danger', 13); + } else if (!r.ok && r.reason === 'requires') { + audio.sfx('empty'); + fx.addFloater('要先买前置', z.x, z.y - 24, 'inkSoft', 12); + } else if (!r.ok && r.reason === 'owned') { + audio.sfx('tick'); + } + break; + } + case 'btn': + audio.sfx('tick'); + handleAction(z.payload.action); + break; + default: + break; + } + noteFeedback(tapFrame); + } + + /** 反馈延迟度量:反馈在 tap 同帧注册,下一渲染帧可见 ⇒ 帧延迟 ≤1。 */ + function noteFeedback(tapFrame) { + const delay = Math.max(bundle.frameCount() - tapFrame, 0) + 1; // +1=下一渲染帧可见的保守口径 + measures.tapFeedbackMaxFrames = Math.max(measures.tapFeedbackMaxFrames, delay); + } + + /** 按钮动作。 */ + function handleAction(action) { + switch (action) { + case 'newGame': + core.startGame(null); + break; + case 'continue': + core.startGame(save.get('profile')); + break; + case 'openShop': + core.startDay(); + break; + case 'confirmSettle': + core.confirmSettle(); + break; + case 'confirmSummary': + core.confirmSummary(); + break; + case 'restart': + core.startGame(null); + break; + default: + break; + } + } + + /* ── 逻辑事件 → 表现(粒子/音效/台词/横幅/存档)── */ + /** 槽位中心坐标。 */ + function slotPos(slot) { + const s = LAYOUT.queue[slot] || LAYOUT.queue[0]; + return { x: s.x, y: s.y }; + } + + function applyEvents(events) { + for (const e of events) { + pushRecent(e); + switch (e.type) { + case 'customer_arrive': { + customerBornMs[e.id] = mockNowMs; + audio.sfx('arrive'); + break; + } + case 'serve_ok': { + const from = shelfSlotRect(core.state.unlocked.indexOf(e.productId)); + const to = slotPos(e.slot); + delete customerBornMs[e.id]; + // 满意幽灵(顾客拿到货的余韵)。 + ui.ghosts.push({ slot: e.slot, avatar: e.avatar, emote: e.rescued ? '!' : '♪', until: mockNowMs + 460 }); + // 商品飞行:货架 → 顾客(非线性缓动+弧线),到达再收钱爆金光。 + fx.addFlight({ + fx: from.x + 28, + fy: from.y + 28, + tx: to.x, + ty: to.y, + dur: 0.28, + imageKey: PRODUCT_IMAGE_KEY[e.productId], + label: (productOf(e.productId) || { name: '?' }).name[0], + onArrive: () => { + fx.burstCoin(to.x, to.y, e.big); + fx.addFloater(`+¥${fmtYuan(e.revenueJiao)}`, to.x, to.y - 30, 'gold', e.big ? 19 : 15); + audio.sfx('coin', { combo: e.combo }); + fx.springKick('hud-cash', 1.8); + if (e.rescued) { + fx.burstSparkle(to.x, to.y - 10, 14); + audio.sfx('rescue'); + } + }, + }); + if (e.combo >= 2) { + fx.springKick('combo', 2.4); + if (e.combo === 3) measures.comboUiShown += 1; + } + if (e.big) { + // 大额成交:hit-stop + 屏震 + 闪白(脆的打击感三件套)。 + juice.hitStop(0.09, 0.12); + juice.shakeScreen(0.22, 5, 26); + juice.flashScreen(0.12, 0.18); + measures.hitStopCount += 1; + audio.sfx('bigsale'); + ui.keeperWaveUntil = mockNowMs + 1300; + say('big_sale', true); + } else if (e.rescued) { + say('rescue', true); + } else if (e.combo >= 3 && e.combo % 3 === 0) { + say('combo_hot'); + } else if (e.combo === 1 && core.state.today.served % 4 === 1) { + say('serve_ok'); + } + break; + } + case 'angry_leave': { + const p = slotPos(e.slot); + delete customerBornMs[e.id]; + ui.ghosts.push({ slot: e.slot, avatar: e.avatar, emote: '怒', until: mockNowMs + 520 }); + fx.burstSmoke(p.x, p.y); + fx.addFloater('差评!', p.x, p.y - 36, 'danger', 15); + juice.shakeScreen(0.16, 3, 24); + audio.sfx('bad'); + say('angry', true); + break; + } + case 'stockout_leave': { + const p = slotPos(e.slot); + delete customerBornMs[e.id]; + ui.ghosts.push({ slot: e.slot, avatar: e.avatar, emote: '…', until: mockNowMs + 480 }); + fx.burstSmoke(p.x, p.y); + fx.addFloater('白跑一趟…', p.x, p.y - 36, 'danger', 13); + audio.sfx('empty'); + if (core.state.today.stockout % 3 === 1) say('stockout'); + break; + } + case 'customer_grace': + audio.sfx('tick'); + break; + case 'walkaway_full': + fx.addFloater('店里挤不下…', 372, 420, 'inkSoft', 12); + break; + case 'rush_start': + banner('高峰来了!', 'brick', 1600); + say('rush_start', true); + break; + case 'rush_end': + banner('缓过来了~', 'leaf', 1300); + break; + case 'closing': + banner('打烊咯~', 'wood', 1500); + break; + case 'day_start': { + say('day_start', true); + ui.keeperWaveUntil = mockNowMs + 1200; + // 进货上架弹道(P2 Projectile):开门瞬间把备货抛上架,落架压扁+闷响。 + let delay = 0; + for (let i = 0; i < core.state.unlocked.length; i++) { + const pid = core.state.unlocked[i]; + const n = Math.min(core.state.stock[pid] || 0, 3); + const rect = shelfSlotRect(i); + for (let k = 0; k < n; k++) { + const d = delay; + fx.addTween(Math.max(d, 0.001), () => {}, { + onDone: () => + fx.addToss({ + fx: -24, + fy: 600, + tx: rect.x + 28, + ty: rect.y + 28, + dur: 0.42, + imageKey: PRODUCT_IMAGE_KEY[pid], + onLand: () => { + fx.springKick('slot-' + pid, -3.4); + audio.sfx('shelve'); + }, + }), + }); + delay += 0.07; + } + } + break; + } + case 'day_end': { + ui.settleAnim = 0; + fx.addTween(1.1, (v) => { + ui.settleAnim = v; + }); + say(e.settle.netJiao >= 0 ? 'settle_win' : 'settle_lose', true); + break; + } + case 'fined': + audio.sfx('bad'); + break; + case 'upgrade_bought': { + fx.burstSparkle(VIEW_W / 2, 430, e.first ? 22 : 14); + fx.addFloater('升级!', VIEW_W / 2, 410, 'gold', 18); + audio.sfx('upgrade'); + ui.keeperWaveUntil = mockNowMs + 1300; + say('upgrade', true); + break; + } + case 'price_changed': + break; + case 'phase_changed': { + if (e.to === 'prep') { + ui.panelSlide = 0; + fx.addTween(0.45, (v) => { + ui.panelSlide = v; + }); + if (e.from === 'title' || e.from === 'gameover') say('open_shop', true); + } + ui.ghosts.length = 0; + break; + } + case 'gameover': + save.remove('profile'); // 失败清档(重开新局) + audio.sfx('bad'); + say('gameover', true); + break; + case 'save_point': + save.set('profile', core.snapshotForSave()); + break; + case 'segment_end': + break; + default: + break; + } + } + } + + /* ── 闲时台词节拍器 ── */ + let idleChatTimer = 7; + + /* ════════════════════════════════════════════════════════════════════ + * 装载契约 GameInstance 实现 + * ════════════════════════════════════════════════════════════════════ */ + + /** + * init:建游戏对象 + 装资产 + 订阅受控输入(boot.ctx.getInput)。异步(loadAssets 异步)。 + * @param {import('../../../src/core/game-host.d.ts').GameHostBootContext} b boot 上下文 + */ + async function init(b) { + boot = b; + ctx = b.ctx; + seed = b.seed; + + /* ── 游戏对象(逻辑随机=派生流;台词随机=另一派生流——互不推演)── */ + core = createShopCore({ random: bundle.deriveContextFor('shop-core', 'shop-core').random }); + pickLine = createLinePicker(bundle.deriveContextFor('shop-lines', 'shop-lines').random); + pal = createPalette(); + fx = createHostFx({ bundle, juice }); + audio = createAudioDirector({ getAudio: () => getRealAudioCtx(), p6: audioMusic }); + + /* ── 资产装载(容错:缺失走程序化回退,照常启动)── */ + assets = await loadAssets('./assets/'); + + /* ── boot 期纯逻辑证明(闭环/失败态/定价两头输——挂 forensics 供 e2e 读)── */ + loopProof = proveCoreLoop(); + failureProof = proveFailure(); + priceProof = (() => { + const lo = estimateDayOutcome({ priceMul: ECON.priceMin }); + const mid = estimateDayOutcome({ priceMul: 1.0 }); + const hi = estimateDayOutcome({ priceMul: ECON.priceMax }); + return { loProfit: lo.profitJiao, midProfit: mid.profitJiao, hiProfit: hi.profitJiao, twoSidedLoss: lo.profitJiao < 0 && hi.profitJiao < 0 && mid.profitJiao > 0 }; + })(); + + /* ── 游戏侧输入订阅:经受控输入面(与插件同一面,boot.ctx.getInput)── */ + const input = ctx.getInput(); + subDown = input.on('pointerdown', (e) => onTap(e)); + subMove = input.on('pointermove', (e) => { + if (sliderDrag) priceFromX(e.x, sliderDrag.track); + }); + subUp = input.on('pointerup', () => { + sliderDrag = null; + }); + + inited = true; + } + + /** + * update:逻辑步(引擎 gameUpdate 每逻辑步驱动;dt 恒定)。 + * @param {number} dt 逻辑步秒数 + */ + function update(dt) { + if (!inited) return; + // 【mustFix #2】刷新本帧逻辑钟快照=host 唯一推进时钟(host 已在调本 update 前自增 mockNowMs,故此读即本帧值)。 + // 取代原私有 `mockNowMs += dt*1000` 自增(双钟分裂根因);本流(update/applyEvents/render)逐行读此快照, + // 与原「update 首行自增后逐行读」语义逐行等价(确定性测状态终点不变)。 + mockNowMs = ctx.time.nowMs(); + const ts = juice.getTimeScale(); + const dtWorld = dt * ts; // hit-stop 冻结世界(手感锚点) + core.update(dtWorld); + bundle.tick(dt); // 驱动插件 onFrame(粒子 autoStep 等,按 PLUGIN.md 约定吃真实 dt;引擎粒子自渲与此解耦) + applyEvents(core.drainEvents()); + maybeReplayBufferedServe(); + fx.step(dt, dtWorld); + + // 高峰 → 色板/暗角/BGM 三联动。 + const rush = core.state.phase === 'open' ? core.state.rushFactor : 0; + pal.setRush(rush); + if (rush > 0.9) measures.rushPaletteSeen = true; + palettePost.setPost({ vignette: { enabled: true, strength: 0.22 + rush * 0.24 } }); + audio.step(dt, rush, core.state.phase === 'open'); + const ap = audio.probe(); + if (ap.bgm.ready && ap.bgm.idleLevel > 0.02 && ap.bgm.rushLevel > 0.02) measures.bgmCrossfadeSeen = true; + + // 闲时随口台词(营业中、不忙的时候偶尔来一句)。 + if (core.state.phase === 'open') { + idleChatTimer -= dt; + if (idleChatTimer <= 0) { + idleChatTimer = 8 + (rush > 0.5 ? 4 : 0); + if (ui.bubbleUntil < mockNowMs) say(rush > 0.5 ? 'rush_start' : 'idle_chat'); + } + } + // 幽灵过期清理。 + for (let i = ui.ghosts.length - 1; i >= 0; i--) if (ui.ghosts[i].until <= mockNowMs) ui.ghosts.splice(i, 1); + + rebuildZones(); + measures.frames += 1; + } + + /** + * render:绘制段(引擎 gameRender 清屏后驱动;g=host 注入的绘制面)。 + * 注:游戏美术与引擎粒子同屏靠「g=引擎 mainContext」(real,host 注入);stub 下 g=#game ctx。 + * DPR 变换补偿由 host 在调 render 前于外层处理(real 引擎 mainContext 无 DPR 变换,host save+setTransform; + * stub #game ctx 自带 DPR 变换,host 不补)——本工厂只按逻辑像素绘制,不感知通道差异。 + * @param {CanvasRenderingContext2D} g 绘制面(=boot.mainContext 的同一面 / stub=#game ctx) + */ + function render(g) { + if (!inited) return; + drawFrame(g, { + core, + pal, + assets, + fx, + ui, + nowMs: mockNowMs, + customerBornMs, + hasSave: hasSave(), + }); + palettePost.renderPost(g); // 暗角收口(绘制末尾叠加) + } + + /** + * destroy:卸载卫生(P1 最小实现)。取消输入订阅(幂等);游戏对象交由 host dispose 插件/bundle 统一回收。 + * P2 装载多游戏复位时此处补「重置 UI 态/清 fx 层」;P1 ref 单游戏不复用实例,最小即可(须声明,非空 no-op)。 + */ + function destroy() { + try { if (subDown && subDown.cancel) subDown.cancel(); } catch (_) { /* 幂等 */ } + try { if (subMove && subMove.cancel) subMove.cancel(); } catch (_) { /* 幂等 */ } + try { if (subUp && subUp.cancel) subUp.cancel(); } catch (_) { /* 幂等 */ } + subDown = subMove = subUp = null; + inited = false; + } + + /* ── 取证面(host 经此挂 window.__refForensics/__refChecklist;非装载契约成员,host wiring)── */ + /** + * 取证导出(host 拿去挂 window.__refForensics)。 + * @returns {object} + */ + function forensicsView() { + return { + seed, + /** 可交互几何(当前帧;与视觉同源)。 */ + geometry: () => zones.map((z) => ({ id: z.id, kind: z.kind, x: z.x, y: z.y, hw: z.hw, hh: z.hh, z: z.z })), + loopProof, + failureProof, + priceProof, + /** 游戏状态快照(JSON 安全)。 */ + state: () => JSON.parse(JSON.stringify({ + phase: core.state.phase, + day: core.state.day, + cash: core.state.cash, + reputation: core.reputation(), + priceMul: core.state.priceMul, + stock: core.state.stock, + unlocked: core.state.unlocked, + upgrades: core.state.upgrades, + customers: core.state.customers.length, + combo: core.state.comboCount, + today: core.state.today, + lifetime: core.state.lifetime, + gameoverReason: core.state.gameoverReason, + })), + /** 最近逻辑事件(环形 240 条)。 */ + recentEvents: () => recentEvents.slice(), + /** 实时度量。 */ + measures: () => Object.assign({ particle: fx.particleProbe(), audio: audio.probe(), assetsMissing: assets.missing }, measures), + /** 资产装载进度(host 取证用)。 */ + assetsLoaded: () => ({ loaded: assets.loaded, total: assets.total }), + }; + } + + /** + * checklist 刷新(host 逐帧调,刷新 window.__refChecklist 的数据源)。 + * @param {object} checklist host 持有的 checklist 对象(本函数原地填) + */ + function refreshChecklist(checklist) { + if (!inited) return; + const ap = audio.probe(); + checklist.shougan = { + tapFeedbackMaxFrames: measures.tapFeedbackMaxFrames, // 目标 ≤3 + tapFeedbackWithin3: measures.taps === 0 || measures.tapFeedbackMaxFrames <= 3, + everyTapScalePulse: true, // 构造保证:onTap 必加涟漪+弹簧(见 onTap) + comboUiShown: measures.comboUiShown > 0 || core.state.today.maxCombo >= 2, + flightEasingUsed: true, // serve_ok 飞行走 easing.quadOut+弧线(构造保证) + hitStopTriggered: measures.hitStopCount > 0, + bufferedServeReplays: measures.bufferedServeReplays, + }; + checklist.meishu = { + allDrawsViaPalette: true, // 构造保证:render.js 一切取色经 pal.c() + vignetteEnabled: palettePost.getPost().vignette.enabled, + rushPaletteSeen: measures.rushPaletteSeen, + assetsLoaded: `${assets.loaded}/${assets.total}`, + noPrimaryPlaceholderRects: true, // 构造保证:回退块也走调色(amber/sky 族) + }; + checklist.yinyue = { + audioUnlocked, + bgmReady: ap.bgm.ready, + bgmCrossfadeSeen: measures.bgmCrossfadeSeen, + sfxKindCount: ap.sfxKinds.length, // 目标 ≥5 + sfxKinds: ap.sfxKinds, + }; + checklist.shendu = { + loopReachable: loopProof.ok, + loopOps: loopProof.opCount, + failureReachable: failureProof.ok, + failureReason: failureProof.reason, + priceTwoSidedLoss: priceProof.twoSidedLoss, + upgradeTreeSize: 8, + firstUpgradeDay: loopProof.day, + }; + checklist.juese = { + keeperSpriteResident: true, // 构造保证:drawKeeper 每帧绘制 + lineGroups: Object.keys(LINES).length, + lineTotal: Object.values(LINES).reduce((s, a) => s + a.length, 0), + linesShownLive: measures.linesShown, + }; + } + + /* ── GameInstance + host wiring 扩展面(forensicsView/refreshChecklist/handleAction 给 host 取证与 __refHost.do 用)── */ + return { + // ── 装载契约 GameInstance 五件 ── + init, + update, + render, + destroy, + // ── host wiring 扩展(非契约成员;host 经此挂取证 + __refHost 驱动钩子)── + /** @internal host 取证:返回 forensics 视图。 */ + _forensicsView: forensicsView, + /** @internal host 取证:原地刷新 checklist 对象。 */ + _refreshChecklist: refreshChecklist, + /** @internal host wiring:__refHost.do(action) 透传到游戏逻辑公开 API。 */ + _handleAction: handleAction, + /** @internal host wiring:取当前逻辑帧钟读数(real 取证旁证)。 */ + _nowMs: () => mockNowMs, + /** + * @internal host wiring:首个用户手势解锁音频后,host 调本钩子通知本游戏(host 已建/恢复 AudioContext)。 + * 本游戏据此置 audioUnlocked + 起 BGM(BGM 行为是游戏域,AudioContext 解锁时机是 host 域,一职一路)。幂等。 + */ + _onAudioUnlock: () => { + if (audioUnlocked) return; + audioUnlocked = true; + if (audio && typeof audio.startBgm === 'function') audio.startBgm(); + }, + }; +} diff --git a/game-runtime/games/wanglanmei-ref/src/main.js b/game-runtime/games/wanglanmei-ref/src/main.js index 226062b4..d715e18d 100644 --- a/game-runtime/games/wanglanmei-ref/src/main.js +++ b/game-runtime/games/wanglanmei-ref/src/main.js @@ -1,24 +1,35 @@ /** - * main.js — wanglanmei-ref 宿主引导(浏览器层;受控面提供方) - * owner:wanglanmei-ref 轮 1 | 消费方:entry.js(esbuild 入口)→ index.html + * main.js — wanglanmei-ref 宿主引导(浏览器层;受控面提供方 + 装载契约 host) + * owner:wanglanmei-ref 轮 1 / Runner v2 P1 | 消费方:entry.js(esbuild 入口)→ index.html * - * 【职责】仿 host-dev/host.js 样板: + * 【职责】host 层(仿 host-dev/host.js 样板): * - Canvas 390×844 DPR2(取证固定环境)+ 步进时钟(?mode=evidence 完全确定性); * - createHostDevContext 受控面 → PluginRegistry 注册 7 件业务插件 + probe 宿主自管六锚链; * - DOM 输入 → 归一化 → HostDevInputBridge._emit(真实交互灌进受控输入面); - * - 游戏逻辑(shop-core 纯模块)只吃受控 dt/随机;渲染/资产/BGM 在宿主层; + * - 经【装载契约】game-shop.js 工厂建游戏实例,host 经引擎五回调(real)/ RAF(stub)驱动其 update/render; * - 取证导出:window.__refForensics / window.__refChecklist / window.__refHost(驱动钩子)。 * + * 【P1 双通道(创始人拍)】 + * - ?engine=stub(**缺省**,回滚锚):prepareCanvas(#game) 2D + 浏览器 RAF + mockNowMs 墙钟 → game.update/render(绘制面=#game ctx),行为与轮 1 逐行等价(逐像素门⑤兜)。 + * - ?engine=real(显式开,引擎掌帧):import 引擎、setGLEnable(false)、engineInit 五回调驱动 game.update/render(绘制面=引擎 mainContext,与引擎粒子同屏)。 + * * 【红线遵守】 - * - 游戏逻辑零裸 Date.now/Math.random/AudioContext —— 全部经受控面/注入; + * - 游戏逻辑零裸 Date.now/Math.random/AudioContext —— 全在 game-shop.js 经受控面/注入(host 是受控面提供方); * - 宿主层是受控面提供方:RAF/Image/fetch/AudioContext 工厂只在本层出现; - * - 不 import littlejsengine(α 裁定:发行版 v0 = core+插件,引擎桩=RAF+tick)。 + * - 引擎 import 只活 entry/host(Q4 铁律);游戏工厂 game-shop.js 零引擎 import。 */ 'use strict'; +// 【A0/Q4 · Option C:引擎 import 不落 main.js,改由 entry.js 经 opts 注入】 +// 起因(mustFix #1):顶层 `import * as LJS from 'littlejsengine'` 会在引擎 esm 装载期触 window/AudioContext, +// node 直接抛 ReferenceError → 击穿 import-graph(断言 main.js 可 node 导入)+ headless-boot(import ../src/main.js 跑冒烟)。 +// Option C 修法:引擎静态 import 只留 entry.js(esbuild 入口,负责把 littlejsengine 打进 ref-bundle); +// main.js 的 real 通道经 bootRef(opts.engine) 注入拿引擎名字空间(见下方 const LJS = opts.engine)。 +// 这样 main.js 不再顶层 import 引擎 → node 可安全 import → 两测原样通过(测试/MODULES 表零改动)。 +// Q4 铁律不破:引擎 import 仍只活 entry.js(host 层 main.js 经注入用引擎,不直接 import);game-shop.js 游戏工厂零引擎 import。 import { PluginRegistry, createHostDevContext, CORE_PROTOCOL_VERSION } from '../../../src/core/plugin.js'; -import { createCollisionPlugin, aabbVsAabb } from '../../../src/plugins/collision/impl.js'; +import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js'; import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js'; import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js'; import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js'; @@ -27,15 +38,17 @@ import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js'; import { createRuntimeProbePlugin } from '../../../src/plugins/runtime-probe/impl.js'; -import { createShopCore } from './shop-core.js'; -import { createLinePicker, LINES } from './shopkeeper-lines.js'; -import { createPalette } from './palette.js'; -import { loadAssets, PRODUCT_IMAGE_KEY } from './assets.js'; -import { createHostFx } from './hostfx.js'; -import { createAudioDirector } from './audio-director.js'; -import { drawFrame, computeZones, shelfSlotRect, W as VIEW_W, H as VIEW_H, LAYOUT } from './render.js'; -import { proveCoreLoop, proveFailure } from './loop-proof.js'; -import { estimateDayOutcome, ECON, fmtYuan, productOf } from './balance.js'; +// 【P1 提升】引擎能力背书工厂(EngineCapabilities 三面)—— host-dev / ref real 通道共用同一份(搬运·语义零变)。 +// 本模块不 import 引擎(经入参 LJS 注入);real 通道用它建 engineFactory。 +import { createEngineCaps } from '../../../host-dev/engine-caps.js'; +// 【A1】引擎数学门面构造(纯函数,零引擎 import):real 通道用它包装引擎 lerp/smoothStep/Ease。 +import { buildEngineMath } from '../../../host-dev/engine-math.js'; + +// 【装载契约 game 工厂】暖阳小卖部游戏实现(默认导出 GameHostFactory,零引擎 import,A3 硬独立文件)。 +import createShopGame from './game-shop.js'; + +// 渲染常量(视口宽高);render/balance 等仍由 game-shop.js 内部引用,这里只取 host 需要的视口尺寸。 +import { W as VIEW_W, H as VIEW_H } from './render.js'; /** 取证固定环境(与 host-dev / browser-evidence FIXED_ENV 对齐)。 */ const DPR = 2; @@ -59,7 +72,7 @@ function resolveSeed() { return Number.isFinite(n) ? n >>> 0 : 0x1234abcd >>> 0; } -/** Canvas 设为逻辑 390×844 / 物理 ×DPR,并把 2D 上下文缩放到逻辑坐标。 */ +/** Canvas 设为逻辑 390×844 / 物理 ×DPR,并把 2D 上下文缩放到逻辑坐标(stub 通道用)。 */ function prepareCanvas(canvas) { canvas.width = VIEW_W * DPR; canvas.height = VIEW_H * DPR; @@ -71,16 +84,23 @@ function prepareCanvas(canvas) { } /** - * 启动参考件。 - * @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement}} opts + * 启动参考件(host)。 + * @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement, engine?: any}} opts + * - engine:【Option C】引擎名字空间(littlejsengine),由 entry.js 包装层注入(main.js 不顶层 import 引擎,保 node-safe)。 + * 仅 ?engine=real 通道用;stub/node 通道不触达(缺省 undefined,real 缺它会 throw 提示,见下方分流)。 * @returns {Promise} refHost 句柄(同时挂 window.__refHost) */ export async function bootRef(opts) { const canvas = opts.canvas; const statusEl = opts.statusEl || null; + // 【Option C】引擎名字空间经 opts 注入(entry.js 包装层在调 bootRef 时传 engine: LJS)。 + // stub/node 通道为 undefined(不触达引擎,保 node import-safe);real 通道由 entry 注入真 LJS。 + const LJS = opts.engine; const seed = resolveSeed(); const mode = getParam('mode', 'play'); // 'play' | 'evidence' - const g = prepareCanvas(canvas); + // 引擎通道:?engine=real(引擎掌帧)| ?engine=stub(**缺省**,回滚桩,行为与轮 1 逐行等价)。 + // 注(§12-A2):host-dev 缺省 real,但 ref 缺省 stub 更稳——真玩取证显式带 &engine=real,stub 兜逐像素回归。 + const engineMode = getParam('engine', 'stub'); /** 状态行(仅人看)。 */ function setStatus(msg) { @@ -107,12 +127,18 @@ export async function bootRef(opts) { return realAudioCtx; } - /* ── 受控面 bundle:步进时钟 + 主种子 + 真 2D 上下文 ── */ + /* ── 步进/逻辑帧钟【mustFix #2:唯一推进时钟(single source of truth)】 ── + * 原缺陷:本 mockNowMs 只喂 bundle.clock 却永不自增(全文件无 +=),而游戏侧 game-shop.js 另有一份私有 + * mockNowMs 在其 update 内自增——两钟分裂致:bundle.clock 恒 0 → HostDevTime.nowMs() 恒 0 → + * inputBridge._emit 给输入事件盖 tMs=0 → gamefeel InputBuffer 记 tMs=0 → game-shop 接待缓冲补打 + * 用「推进的游戏钟 − 记录的 0」判窗口恒假 → P4 输入缓冲补打永不触发(measures.bufferedServeReplays 恒 0)。 + * 修法:本 mockNowMs 成唯一真相,在 game.update 驱动点自增(stub:stepOneFrame 内;real:_engUpdate 内); + * bundle.clock 读它(line ~219);game-shop.js 删私有钟、改读 boot.ctx.time.nowMs()(即读本钟)——并轨成单钟。 + * 语义等价回滚:原 game-shop 在 update 首行 `mockNowMs += dt*1000`,故此处亦在调 game.update **之前**自增, + * 保游戏逻辑在 update 内读到的钟值与原逐行一致(headless-boot 确定性测状态终点不变)。 */ let mockNowMs = 0; - const bundle = createHostDevContext({ context2d: g, seed, audioFactory, clock: () => mockNowMs }); - /* ── 注册 7 件业务插件(probe 宿主自管,保证六锚偏序——host-dev 同款编排)── */ - const registry = new PluginRegistry(); + /* ── 7 件业务插件实例(host 拥有;游戏经工厂 opts 复用,一职一路)── */ const collision = createCollisionPlugin({ cellSize: 96 }); const physics = createPhysicsLitePlugin(); const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] }); @@ -128,544 +154,229 @@ export async function bootRef(opts) { saveAdapter = createMemoryAdapter(); } const save = createSaveProgressPlugin({ namespace: 'wanglanmei-ref', adapter: saveAdapter }); - registry - .register(collision) - .register(physics) - .register(gamefeel) - .register(juice) - .register(palettePost) - .register(save) - .register(audioMusic); - registry.useContext(bundle.context); - const initRes = registry.initAll(); + const plugins = { collision, physics, gamefeel, juice, palettePost, audioMusic, save }; + + /* ── 受控面 bundle + 游戏实例(real:engineInit 就绪后建;stub:立即)。延后建避免快照错 ctx(§12-M4)。 ── */ + /** @type {ReturnType|null} */ + let bundle = null; + /** @type {import('../../../src/core/game-host.d.ts').GameInstance|null} */ + let game = null; + /** @type {ReturnType|null} */ + let probe = null; + let initRes = null; + let pluginSet = []; + /** checklist(LOOP-LOG 五要素表自动子项 → 布尔+数值,逐帧刷新;game 工厂原地填)。 */ + const checklist = {}; + /** @type {ReturnType['_forensicsView']>|null} */ + let gameForensics = null; - /* ── runtime-probe:宿主自管(六锚 canonical 顺序,t_input_bound 恰在 first_paint 后)── */ - const pluginSet = registry.list().concat(['runtime-probe']); - const probe = createRuntimeProbePlugin({ pluginSet, inputAnchorType: 'pointerdown', autoBoot: true }); - const probeCtx = bundle.deriveContextFor('runtime-probe', 'runtime-probe'); /** 安全打点(probe 异常不中断启动链)。 */ function mark(anchor, extra) { try { - probe.mark(anchor, extra); + if (probe) probe.mark(anchor, extra); } catch (e) { uncaught.push({ type: 'mark', message: String((e && e.message) || e) }); } } - mark('t_boot', { by: 'ref-host', stage: 'pre-init' }); - mark('t_plugins_ready', { initialized: initRes.initialized.length, ok: initRes.ok }); - /* ── 游戏对象(逻辑随机=派生流;台词随机=另一派生流——互不推演)── */ - const core = createShopCore({ random: bundle.deriveContextFor('shop-core', 'shop-core').random }); - const pickLine = createLinePicker(bundle.deriveContextFor('shop-lines', 'shop-lines').random); - const pal = createPalette(); - const fx = createHostFx({ bundle, juice }); - const audio = createAudioDirector({ getAudio: () => realAudioCtx, p6: audioMusic }); - - /* ── 资产装载(容错:缺失走程序化回退,照常启动)── */ - setStatus('装载资产…'); - const assets = await loadAssets('./assets/'); - - /* ── boot 期纯逻辑证明(闭环/失败态/定价两头输——挂 forensics 供 e2e 读)── */ - const loopProof = proveCoreLoop(); - const failureProof = proveFailure(); - const priceProof = (() => { - const lo = estimateDayOutcome({ priceMul: ECON.priceMin }); - const mid = estimateDayOutcome({ priceMul: 1.0 }); - const hi = estimateDayOutcome({ priceMul: ECON.priceMax }); - return { loProfit: lo.profitJiao, midProfit: mid.profitJiao, hiProfit: hi.profitJiao, twoSidedLoss: lo.profitJiao < 0 && hi.profitJiao < 0 && mid.profitJiao > 0 }; - })(); - - mark('t_game_init', { assetsLoaded: assets.loaded, assetsTotal: assets.total, loopProofOk: loopProof.ok }); - - /* ── 宿主 UI 态 ── */ - const ui = { - bubbleText: '', - bubbleUntil: 0, - bubbleShownAt: -9999, - keeperWaveUntil: 0, - tickerText: '小本生意,用心经营。', - banner: null, // {text,color,until,dur} - panelSlide: 1, // prep 面板滑入 0→1 - settleAnim: 0, // 日结计数 0→1 - /** @type {Array<{slot:number, avatar:number, emote:string, until:number}>} 离场幽灵(视觉余韵)。 */ - ghosts: [], + /* ── 取证导出(host 组装:游戏侧 forensics 视图 + probe 链 + 协议号)── */ + const forensics = { + protocol: CORE_PROTOCOL_VERSION, + seed, + mode, + /** 可交互几何(当前帧;与视觉同源)。 */ + geometry: () => (gameForensics ? gameForensics.geometry() : []), + /** 探针六锚链导出。 */ + probe: () => ({ + jsonl: probe ? probe.toJSONL() : '', + verify: probe ? probe.verify() : { ok: false, reason: 'no-probe' }, + anchors: probe ? probe.getRecords().map((r) => r.anchor) : [], + uncaught: uncaught.slice(), + initOk: initRes ? initRes.ok : false, + registeredPlugins: pluginSet, + }), + /** 闭环/失败态/定价证明(boot 期纯逻辑跑出)。 */ + get loopProof() { return gameForensics ? gameForensics.loopProof : null; }, + get failureProof() { return gameForensics ? gameForensics.failureProof : null; }, + get priceProof() { return gameForensics ? gameForensics.priceProof : null; }, + /** 游戏状态快照(JSON 安全)。 */ + state: () => (gameForensics ? gameForensics.state() : { phase: 'booting' }), + /** 最近逻辑事件(环形 240 条)。 */ + recentEvents: () => (gameForensics ? gameForensics.recentEvents() : []), + /** 实时度量。 */ + measures: () => (gameForensics ? gameForensics.measures() : {}), }; - /** @type {Record} 顾客出生时刻(走位动画)。 */ - const customerBornMs = {}; - /* ── 度量(checklist 自动子项的数据源)── */ - const measures = { - frames: 0, - taps: 0, - tapFeedbackMaxFrames: 0, - comboUiShown: 0, - hitStopCount: 0, - rushPaletteSeen: false, - bgmCrossfadeSeen: false, - linesShown: 0, - bufferedServeReplays: 0, - }; - /** @type {Array} 最近逻辑事件环形缓冲(取证)。 */ - const recentEvents = []; - function pushRecent(e) { - recentEvents.push(e); - if (recentEvents.length > 240) recentEvents.shift(); + /** 逐帧刷新取证(游戏侧 checklist + host forensics 已是 getter,无需额外刷)。 */ + function refreshForensics() { + if (game && typeof game._refreshChecklist === 'function') game._refreshChecklist(checklist); } - /* ── 台词气泡 ── */ - /** - * 店主说一句(场景取词;普通优先级 700ms 内不顶替)。 - * @param {string} sceneKey @param {boolean} [force] 高优先级强插 - */ - function say(sceneKey, force) { - if (!force && mockNowMs - ui.bubbleShownAt < 700 && ui.bubbleUntil > mockNowMs) return; - const text = pickLine(sceneKey); - if (!text) return; - ui.bubbleText = text; - ui.bubbleUntil = mockNowMs + 2600; - ui.bubbleShownAt = mockNowMs; - ui.tickerText = text; - measures.linesShown += 1; - fx.springKick('bubble', 2.2); + /* ════════════════════════════════════════════════════════════════════ + * 共用收尾:建 bundle → 注册插件 → 建游戏 → init → 打链 → 挂取证(real/stub 都调) + * renderCtx:游戏 render(g) 的绘制面(real=引擎 mainContext / stub=#game ctx)。 + * inputCanvasGetter:输入 rect 映射用的可见画布(real=引擎 mainCanvas / stub=#game)。 + * ════════════════════════════════════════════════════════════════════ */ + async function setupCommon(renderCtx, inputCanvasGetter) { + // 1) bundle:context2d=绘制面(受控面 getContext2d 一次性快照此 ctx,§12-M4 必须就绪后建)。 + // engineFactory:real 经 createEngineCaps 真背书引擎;stub 短路返 null(保 null-engine 回归)。 + bundle = createHostDevContext({ + context2d: renderCtx, + seed, + audioFactory, + clock: () => mockNowMs, + engineFactory: () => createEngineCaps({ + LJS: engineMode === 'real' ? LJS : null, // stub 不持引擎名字空间(createEngineCaps 首行按 engineMode 短路返 null) + engineMath: engineMode === 'real' ? buildEngineMath(LJS) : null, + engineMode, + // 取证 recHook:注 window.__engineCalls(门② call-ID)。ref-bundle 即取证用(非渠道产物,同 host-dev 现状)。 + recHook: (id) => { try { (window.__engineCalls || (window.__engineCalls = [])).push(id); } catch (_) { /* 取证副作用,忽略 */ } }, + }), + }); + + // 2) 注册 7 件业务插件(probe 宿主自管,保证六锚偏序——host-dev 同款编排)。 + const registry = new PluginRegistry(); + registry + .register(collision) + .register(physics) + .register(gamefeel) + .register(juice) + .register(palettePost) + .register(save) + .register(audioMusic); + registry.useContext(bundle.context); + initRes = registry.initAll(); + + // 3) runtime-probe:宿主自管(六锚 canonical 顺序,t_input_bound 恰在 first_paint 后)。 + pluginSet = registry.list().concat(['runtime-probe']); + probe = createRuntimeProbePlugin({ pluginSet, inputAnchorType: 'pointerdown', autoBoot: true }); + const probeCtx = bundle.deriveContextFor('runtime-probe', 'runtime-probe'); + mark('t_boot', { by: 'ref-host', stage: 'pre-init', engineMode }); + mark('t_plugins_ready', { initialized: initRes.initialized.length, ok: initRes.ok }); + + // 4) 经装载契约建游戏实例(host 经 opts 注入 runtime:插件实例 + bundle + 取真 AudioContext 回调)。 + game = createShopGame({ + runtime: { + plugins, + bundle, + getRealAudioCtx: () => realAudioCtx, + }, + }); + // 5) game.init:建游戏对象 + 异步装资产 + 订阅受控输入。boot=装载契约脊柱(ctx/mainContext/canvas/seed)。 + await game.init({ + ctx: bundle.context, + mainContext: renderCtx, + canvas: inputCanvasGetter(), + seed, + }); + gameForensics = game._forensicsView(); + mark('t_game_init', { + assetsLoaded: gameForensics.assetsLoaded().loaded, + assetsTotal: gameForensics.assetsLoaded().total, + loopProofOk: gameForensics.loopProof && gameForensics.loopProof.ok, + }); + + // 6) 首帧 + 输入锚 + 启动链收尾(与轮 1 同序:first_paint → probe.init 绑输入 → game_start)。 + stepOneFrame(); + mark('t_first_paint', { frame: bundle.frameCount() }); + try { + probe.init(probeCtx); // 绑输入 → t_input_bound(恰在 first_paint 之后,偏序成立) + } catch (e) { + uncaught.push({ type: 'probe-init', message: String((e && e.message) || e) }); + } + mark('t_game_start', { mode, engineMode }); + + // 7) 挂取证(real 通道顶层不可早挂——bundle/game 此刻才存在;§12-M1)。 + refreshForensics(); + window.__refForensics = forensics; + window.__refChecklist = checklist; + window.__refHost = refHost; } - /** 横幅。 */ - function banner(text, color, durMs) { - ui.banner = { text, color, dur: durMs || 1500, until: mockNowMs + (durMs || 1500) }; + /* ── 单帧推进(stub RAF / evidence stepFrames / 首帧共用):game.update + game.render(绘制面=renderCtx)。 ── */ + /** @type {CanvasRenderingContext2D|null} */ + let stubRenderCtx = null; + function stepOneFrame(dt) { + const d = typeof dt === 'number' ? dt : FIXED_DT; + if (!game) return; + // 【mustFix #2】唯一时钟在 game.update 之前自增(stub 驱动点):使 bundle.clock 与游戏逻辑读同一推进值, + // 且与原 game-shop「update 首行 += dt*1000」逐行语义等价(游戏在 update 内读到的钟值不变)。 + mockNowMs += d * 1000; + game.update(d); + // stub 通道绘制:game.render(g) 内 drawFrame 首调 drawBackdrop 全屏铺底(不另 clearBlack——与轮 1 frame() 逐行等价, + // 保逐像素门⑤双开哈希一致;real 通道引擎清屏 + render 由引擎 gameRender 回调驱动,不走本函数绘制)。 + if (engineMode === 'stub' && stubRenderCtx) { + game.render(stubRenderCtx); + } + refreshForensics(); + } + /** 确定性推进 n 帧(evidence 驱动 / 启动首帧)。 */ + function stepFrames(n) { + for (let i = 0; i < n; i++) stepOneFrame(FIXED_DT); } - /* ── 音频解锁(首个手势)── */ + /* ── 首个用户手势:解锁音频(创建/恢复 AudioContext)+ 通知游戏起 BGM。幂等。 ── */ function unlockAudioOnce() { if (audioUnlocked) return; audioUnlocked = true; const ac = audioFactory(); if (ac && ac.state === 'suspended' && typeof ac.resume === 'function') ac.resume().catch(() => {}); - audio.startBgm(); + if (game && typeof game._onAudioUnlock === 'function') game._onAudioUnlock(); } - /* ── 命中区(视觉同源几何;collision broadphase + 窄相点测)── */ - /** @type {Array} 当前帧命中区。 */ - let zones = []; - function rebuildZones() { - zones = computeZones(core, { hasSave: hasSave() }); - collision.spatial.clear(); - for (const z of zones) collision.spatial.insert(z.id, { x: z.x, y: z.y, hw: z.hw, hh: z.hh }); - } - /** 点测:broadphase 粗筛 → aabbVsAabb 窄相 → 取 z 最高者。 */ - function hitZone(x, y) { - const candidates = collision.spatial.queryPoint(x, y); - let best = null; - for (const c of candidates) { - const z = zones.find((zz) => zz.id === c.id); - if (!z) continue; - if (!aabbVsAabb({ x, y, hw: 0, hh: 0 }, { x: z.x, y: z.y, hw: z.hw, hh: z.hh }).hit) continue; - if (!best || z.z > best.z) best = z; - } - return best; - } - - function hasSave() { - return !!save.get('profile'); - } - - /* ── 接待缓冲重放(P4 输入缓冲:冷却期点击不丢,窗口内自动补打)── */ - /** @type {{productId:string, tMs:number}|null} */ - let pendingServe = null; - function maybeReplayBufferedServe() { - if (!pendingServe || core.state.phase !== 'open' || core.state.serveCooldown > 0) return; - const within = mockNowMs - pendingServe.tMs <= 260; - if (within && gamefeel.inputBuffer && gamefeel.inputBuffer.consume('pointerdown', mockNowMs)) { - const pid = pendingServe.productId; - pendingServe = null; - const r = core.serve(pid); - if (r.ok) measures.bufferedServeReplays += 1; - } else if (!within) { - pendingServe = null; - } - } - - /* ── 点击路由 ── */ - let sliderDrag = null; // {track} - /** 滑条 x → 定价。 */ - function priceFromX(x, track) { - const v = Math.max(0, Math.min(1, (x - track.x) / track.w)); - core.setPrice(ECON.priceMin + v * (ECON.priceMax - ECON.priceMin)); - } - - function onTap(e) { - measures.taps += 1; - const tapFrame = bundle.frameCount(); - fx.addTapRing(e.x, e.y); // 当帧反馈(≤1 渲染帧可见) - const z = hitZone(e.x, e.y); - if (!z) { - // 空点:极轻 tick(仍有听觉回执),不打扰。 - if (audioUnlocked) audio.sfx('tick'); - noteFeedback(tapFrame); - return; - } - fx.springKick('z:' + z.id, -2.6); // 按压脉冲(render 侧按钮读同名弹簧 'z:') - switch (z.kind) { - case 'serve': { - const pid = z.payload.productId; - fx.springKick('slot-' + pid, -3.2); - const r = core.serve(pid); - if (!r.ok) { - if (r.reason === 'cooldown') { - pendingServe = { productId: pid, tMs: mockNowMs }; // 输入缓冲补打 - } else if (r.reason === 'no_stock') { - audio.sfx('empty'); - const rect = shelfSlotRect(core.state.unlocked.indexOf(pid)); - fx.addFloater('缺货…', rect.x + rect.w / 2, rect.y, 'danger', 13); - } else if (r.reason === 'no_customer') { - audio.sfx('tick'); - const rect = shelfSlotRect(core.state.unlocked.indexOf(pid)); - fx.addFloater('没人要这个', rect.x + rect.w / 2, rect.y, 'inkSoft', 12); - } - } - break; + /* ── refHost 驱动句柄(evidence 驱动 / __refHost.do)── */ + const refHost = { + seed, + mode, + engineMode, + protocol: CORE_PROTOCOL_VERSION, + frameCount: () => (bundle ? bundle.frameCount() : 0), + stepFrames, + startRaf: () => startRaf(), + stopRaf: () => stopRaf(), + /** 注入一次点击(evidence 驱动;与真输入同走受控桥)。 */ + tap(x, y) { + unlockAudioOnce(); + if (bundle) { + bundle.inputBridge._emit('pointerdown', { x, y }); + bundle.inputBridge._emit('pointerup', { x, y }); } - case 'stockInc': { - const r = core.buyStock(z.payload.productId, 1); - if (r.ok) audio.sfx('tick'); - else { - audio.sfx('empty'); - fx.addFloater(r.reason === 'no_cash' ? '钱不够了' : '货架满了', z.x, z.y - 22, 'danger', 13); - } - break; - } - case 'stockDec': { - const r = core.refundStock(z.payload.productId, 1); - audio.sfx(r.ok ? 'tick' : 'empty'); - break; - } - case 'slider': - sliderDrag = { track: z.payload.track }; - priceFromX(e.x, z.payload.track); - audio.sfx('tick'); - break; - case 'upgrade': { - const r = core.buyUpgrade(z.payload.upgradeId); - if (!r.ok && r.reason === 'no_cash') { - audio.sfx('empty'); - fx.addFloater('钱不够了', z.x, z.y - 24, 'danger', 13); - } else if (!r.ok && r.reason === 'requires') { - audio.sfx('empty'); - fx.addFloater('要先买前置', z.x, z.y - 24, 'inkSoft', 12); - } else if (!r.ok && r.reason === 'owned') { - audio.sfx('tick'); - } - break; - } - case 'btn': - audio.sfx('tick'); - handleAction(z.payload.action); - break; - default: - break; + }, + /** 直接命令(e2e 辅助;与 UI 同走逻辑公开 API)。 */ + do(action) { + if (game && typeof game._handleAction === 'function') game._handleAction(action); + }, + state: () => forensics.state(), + forensics, + }; + + /* ════════════════════════════════════════════════════════════════════ + * 输入桥接:DOM 事件 → 归一化坐标 → 受控桥 _emit + * real:监听挂引擎 mainCanvas(坐标按引擎可见画布 rect,§12-M4);stub:挂 #game(逐行不变)。 + * 监听挂载在 bundle 就绪后(inputBridge 存在);故 real 在 setupReal 内接、stub 在 setupStub 内接。 + * ════════════════════════════════════════════════════════════════════ */ + function attachInput(targetCanvas) { + const inputBridge = bundle.inputBridge; + function toCanvasXY(clientX, clientY) { + const rect = targetCanvas.getBoundingClientRect(); + return { + x: ((clientX - rect.left) / rect.width) * VIEW_W, + y: ((clientY - rect.top) / rect.height) * VIEW_H, + }; } - noteFeedback(tapFrame); - } - - /** 反馈延迟度量:反馈在 tap 同帧注册,下一渲染帧可见 ⇒ 帧延迟 ≤1。 */ - function noteFeedback(tapFrame) { - const delay = Math.max(bundle.frameCount() - tapFrame, 0) + 1; // +1=下一渲染帧可见的保守口径 - measures.tapFeedbackMaxFrames = Math.max(measures.tapFeedbackMaxFrames, delay); - } - - /** 按钮动作。 */ - function handleAction(action) { - switch (action) { - case 'newGame': - core.startGame(null); - break; - case 'continue': - core.startGame(save.get('profile')); - break; - case 'openShop': - core.startDay(); - break; - case 'confirmSettle': - core.confirmSettle(); - break; - case 'confirmSummary': - core.confirmSummary(); - break; - case 'restart': - core.startGame(null); - break; - default: - break; - } - } - - /* ── 逻辑事件 → 表现(粒子/音效/台词/横幅/存档)── */ - /** 槽位中心坐标。 */ - function slotPos(slot) { - const s = LAYOUT.queue[slot] || LAYOUT.queue[0]; - return { x: s.x, y: s.y }; - } - - function applyEvents(events) { - for (const e of events) { - pushRecent(e); - switch (e.type) { - case 'customer_arrive': { - customerBornMs[e.id] = mockNowMs; - audio.sfx('arrive'); - break; - } - case 'serve_ok': { - const from = shelfSlotRect(core.state.unlocked.indexOf(e.productId)); - const to = slotPos(e.slot); - delete customerBornMs[e.id]; - // 满意幽灵(顾客拿到货的余韵)。 - ui.ghosts.push({ slot: e.slot, avatar: e.avatar, emote: e.rescued ? '!' : '♪', until: mockNowMs + 460 }); - // 商品飞行:货架 → 顾客(非线性缓动+弧线),到达再收钱爆金光。 - fx.addFlight({ - fx: from.x + 28, - fy: from.y + 28, - tx: to.x, - ty: to.y, - dur: 0.28, - imageKey: PRODUCT_IMAGE_KEY[e.productId], - label: (productOf(e.productId) || { name: '?' }).name[0], - onArrive: () => { - fx.burstCoin(to.x, to.y, e.big); - fx.addFloater(`+¥${fmtYuan(e.revenueJiao)}`, to.x, to.y - 30, 'gold', e.big ? 19 : 15); - audio.sfx('coin', { combo: e.combo }); - fx.springKick('hud-cash', 1.8); - if (e.rescued) { - fx.burstSparkle(to.x, to.y - 10, 14); - audio.sfx('rescue'); - } - }, - }); - if (e.combo >= 2) { - fx.springKick('combo', 2.4); - if (e.combo === 3) measures.comboUiShown += 1; - } - if (e.big) { - // 大额成交:hit-stop + 屏震 + 闪白(脆的打击感三件套)。 - juice.hitStop(0.09, 0.12); - juice.shakeScreen(0.22, 5, 26); - juice.flashScreen(0.12, 0.18); - measures.hitStopCount += 1; - audio.sfx('bigsale'); - ui.keeperWaveUntil = mockNowMs + 1300; - say('big_sale', true); - } else if (e.rescued) { - say('rescue', true); - } else if (e.combo >= 3 && e.combo % 3 === 0) { - say('combo_hot'); - } else if (e.combo === 1 && core.state.today.served % 4 === 1) { - say('serve_ok'); - } - break; - } - case 'angry_leave': { - const p = slotPos(e.slot); - delete customerBornMs[e.id]; - ui.ghosts.push({ slot: e.slot, avatar: e.avatar, emote: '怒', until: mockNowMs + 520 }); - fx.burstSmoke(p.x, p.y); - fx.addFloater('差评!', p.x, p.y - 36, 'danger', 15); - juice.shakeScreen(0.16, 3, 24); - audio.sfx('bad'); - say('angry', true); - break; - } - case 'stockout_leave': { - const p = slotPos(e.slot); - delete customerBornMs[e.id]; - ui.ghosts.push({ slot: e.slot, avatar: e.avatar, emote: '…', until: mockNowMs + 480 }); - fx.burstSmoke(p.x, p.y); - fx.addFloater('白跑一趟…', p.x, p.y - 36, 'danger', 13); - audio.sfx('empty'); - if (core.state.today.stockout % 3 === 1) say('stockout'); - break; - } - case 'customer_grace': - audio.sfx('tick'); - break; - case 'walkaway_full': - fx.addFloater('店里挤不下…', 372, 420, 'inkSoft', 12); - break; - case 'rush_start': - banner('高峰来了!', 'brick', 1600); - say('rush_start', true); - break; - case 'rush_end': - banner('缓过来了~', 'leaf', 1300); - break; - case 'closing': - banner('打烊咯~', 'wood', 1500); - break; - case 'day_start': { - say('day_start', true); - ui.keeperWaveUntil = mockNowMs + 1200; - // 进货上架弹道(P2 Projectile):开门瞬间把备货抛上架,落架压扁+闷响。 - let delay = 0; - for (let i = 0; i < core.state.unlocked.length; i++) { - const pid = core.state.unlocked[i]; - const n = Math.min(core.state.stock[pid] || 0, 3); - const rect = shelfSlotRect(i); - for (let k = 0; k < n; k++) { - const d = delay; - fx.addTween(Math.max(d, 0.001), () => {}, { - onDone: () => - fx.addToss({ - fx: -24, - fy: 600, - tx: rect.x + 28, - ty: rect.y + 28, - dur: 0.42, - imageKey: PRODUCT_IMAGE_KEY[pid], - onLand: () => { - fx.springKick('slot-' + pid, -3.4); - audio.sfx('shelve'); - }, - }), - }); - delay += 0.07; - } - } - break; - } - case 'day_end': { - ui.settleAnim = 0; - fx.addTween(1.1, (v) => { - ui.settleAnim = v; - }); - say(e.settle.netJiao >= 0 ? 'settle_win' : 'settle_lose', true); - break; - } - case 'fined': - audio.sfx('bad'); - break; - case 'upgrade_bought': { - fx.burstSparkle(VIEW_W / 2, 430, e.first ? 22 : 14); - fx.addFloater('升级!', VIEW_W / 2, 410, 'gold', 18); - audio.sfx('upgrade'); - ui.keeperWaveUntil = mockNowMs + 1300; - say('upgrade', true); - break; - } - case 'price_changed': - break; - case 'phase_changed': { - if (e.to === 'prep') { - ui.panelSlide = 0; - fx.addTween(0.45, (v) => { - ui.panelSlide = v; - }); - if (e.from === 'title' || e.from === 'gameover') say('open_shop', true); - } - ui.ghosts.length = 0; - break; - } - case 'gameover': - save.remove('profile'); // 失败清档(重开新局) - audio.sfx('bad'); - say('gameover', true); - break; - case 'save_point': - save.set('profile', core.snapshotForSave()); - break; - case 'segment_end': - break; - default: - break; - } - } - } - - /* ── 输入接线:DOM → 归一化 → 受控桥;游戏经受控面订阅 ── */ - const inputBridge = bundle.inputBridge; - function toCanvasXY(clientX, clientY) { - const rect = canvas.getBoundingClientRect(); - return { - x: ((clientX - rect.left) / rect.width) * VIEW_W, - y: ((clientY - rect.top) / rect.height) * VIEW_H, - }; - } - canvas.addEventListener('pointerdown', (ev) => { - unlockAudioOnce(); - const p = toCanvasXY(ev.clientX, ev.clientY); - inputBridge._emit('pointerdown', p); - }); - canvas.addEventListener('pointermove', (ev) => { - const p = toCanvasXY(ev.clientX, ev.clientY); - inputBridge._emit('pointermove', p); - }); - canvas.addEventListener('pointerup', (ev) => { - const p = toCanvasXY(ev.clientX, ev.clientY); - inputBridge._emit('pointerup', p); - }); - // 游戏侧:经受控输入面订阅(与插件同一面)。 - const input = bundle.context.getInput(); - input.on('pointerdown', (e) => onTap(e)); - input.on('pointermove', (e) => { - if (sliderDrag) priceFromX(e.x, sliderDrag.track); - }); - input.on('pointerup', () => { - sliderDrag = null; - }); - - /* ── 闲时台词节拍器 ── */ - let idleChatTimer = 7; - - /* ── 主帧 ── */ - function frame(dtReal) { - mockNowMs += dtReal * 1000; - const ts = juice.getTimeScale(); - const dtWorld = dtReal * ts; // hit-stop 冻结世界(手感锚点) - core.update(dtWorld); - bundle.tick(dtReal); // 驱动插件 onFrame(粒子 autoStep 等,按 PLUGIN.md 约定吃真实 dt) - applyEvents(core.drainEvents()); - maybeReplayBufferedServe(); - fx.step(dtReal, dtWorld); - - // 高峰 → 色板/暗角/BGM 三联动。 - const rush = core.state.phase === 'open' ? core.state.rushFactor : 0; - pal.setRush(rush); - if (rush > 0.9) measures.rushPaletteSeen = true; - palettePost.setPost({ vignette: { enabled: true, strength: 0.22 + rush * 0.24 } }); - audio.step(dtReal, rush, core.state.phase === 'open'); - const ap = audio.probe(); - if (ap.bgm.ready && ap.bgm.idleLevel > 0.02 && ap.bgm.rushLevel > 0.02) measures.bgmCrossfadeSeen = true; - - // 闲时随口台词(营业中、不忙的时候偶尔来一句)。 - if (core.state.phase === 'open') { - idleChatTimer -= dtReal; - if (idleChatTimer <= 0) { - idleChatTimer = 8 + (rush > 0.5 ? 4 : 0); - if (ui.bubbleUntil < mockNowMs) say(rush > 0.5 ? 'rush_start' : 'idle_chat'); - } - } - // 幽灵过期清理。 - for (let i = ui.ghosts.length - 1; i >= 0; i--) if (ui.ghosts[i].until <= mockNowMs) ui.ghosts.splice(i, 1); - - rebuildZones(); - drawFrame(g, { - core, - pal, - assets, - fx, - ui, - nowMs: mockNowMs, - customerBornMs, - hasSave: hasSave(), + targetCanvas.addEventListener('pointerdown', (ev) => { + unlockAudioOnce(); + inputBridge._emit('pointerdown', toCanvasXY(ev.clientX, ev.clientY)); + }); + targetCanvas.addEventListener('pointermove', (ev) => { + inputBridge._emit('pointermove', toCanvasXY(ev.clientX, ev.clientY)); + }); + targetCanvas.addEventListener('pointerup', (ev) => { + inputBridge._emit('pointerup', toCanvasXY(ev.clientX, ev.clientY)); }); - palettePost.renderPost(g); // 暗角收口(绘制末尾叠加) - measures.frames += 1; - refreshForensics(); } - /** 确定性推进 n 帧(evidence 驱动 / 启动首帧)。 */ - function stepFrames(n) { - for (let i = 0; i < n; i++) frame(FIXED_DT); - } - - /* ── RAF(play 模式)── */ + /* ── RAF(stub play 模式)── */ let rafId = 0; let rafRunning = false; let lastTs = 0; @@ -675,10 +386,11 @@ export async function bootRef(opts) { let dt = (ts - lastTs) / 1000; lastTs = ts; if (dt > 0.05) dt = 0.05; // 钳大跳帧 - frame(dt); + stepOneFrame(dt); rafId = window.requestAnimationFrame(rafLoop); } function startRaf() { + if (engineMode !== 'stub') return; // real 通道引擎内部 RAF 驱动五回调,host 不起 RAF if (rafRunning) return; rafRunning = true; lastTs = 0; @@ -690,134 +402,118 @@ export async function bootRef(opts) { rafId = 0; } - /* ── 取证导出 ── */ - const forensics = { - protocol: CORE_PROTOCOL_VERSION, - seed, - mode, - /** 可交互几何(当前帧;与视觉同源)。 */ - geometry: () => zones.map((z) => ({ id: z.id, kind: z.kind, x: z.x, y: z.y, hw: z.hw, hh: z.hh, z: z.z })), - /** 探针六锚链导出。 */ - probe: () => ({ - jsonl: probe.toJSONL(), - verify: probe.verify(), - anchors: probe.getRecords().map((r) => r.anchor), - uncaught: uncaught.slice(), - initOk: initRes.ok, - registeredPlugins: pluginSet, - }), - /** 闭环/失败态/定价证明(boot 期纯逻辑跑出)。 */ - loopProof, - failureProof, - priceProof, - /** 游戏状态快照(JSON 安全)。 */ - state: () => JSON.parse(JSON.stringify({ - phase: core.state.phase, - day: core.state.day, - cash: core.state.cash, - reputation: core.reputation(), - priceMul: core.state.priceMul, - stock: core.state.stock, - unlocked: core.state.unlocked, - upgrades: core.state.upgrades, - customers: core.state.customers.length, - combo: core.state.comboCount, - today: core.state.today, - lifetime: core.state.lifetime, - gameoverReason: core.state.gameoverReason, - })), - /** 最近逻辑事件(环形 240 条)。 */ - recentEvents: () => recentEvents.slice(), - /** 实时度量。 */ - measures: () => Object.assign({ particle: fx.particleProbe(), audio: audio.probe(), assetsMissing: assets.missing }, measures), - }; - - /** checklist(LOOP-LOG 五要素表自动子项 → 布尔+数值,逐帧刷新)。 */ - const checklist = {}; - function refreshForensics() { - const ap = audio.probe(); - checklist.shougan = { - tapFeedbackMaxFrames: measures.tapFeedbackMaxFrames, // 目标 ≤3 - tapFeedbackWithin3: measures.taps === 0 || measures.tapFeedbackMaxFrames <= 3, - everyTapScalePulse: true, // 构造保证:onTap 必加涟漪+弹簧(见 onTap) - comboUiShown: measures.comboUiShown > 0 || core.state.today.maxCombo >= 2, - flightEasingUsed: true, // serve_ok 飞行走 easing.quadOut+弧线(构造保证) - hitStopTriggered: measures.hitStopCount > 0, - bufferedServeReplays: measures.bufferedServeReplays, - }; - checklist.meishu = { - allDrawsViaPalette: true, // 构造保证:render.js 一切取色经 pal.c() - vignetteEnabled: palettePost.getPost().vignette.enabled, - rushPaletteSeen: measures.rushPaletteSeen, - assetsLoaded: `${assets.loaded}/${assets.total}`, - noPrimaryPlaceholderRects: true, // 构造保证:回退块也走调色(amber/sky 族) - }; - checklist.yinyue = { - audioUnlocked, - bgmReady: ap.bgm.ready, - bgmCrossfadeSeen: measures.bgmCrossfadeSeen, - sfxKindCount: ap.sfxKinds.length, // 目标 ≥5 - sfxKinds: ap.sfxKinds, - }; - checklist.shendu = { - loopReachable: loopProof.ok, - loopOps: loopProof.opCount, - failureReachable: failureProof.ok, - failureReason: failureProof.reason, - priceTwoSidedLoss: priceProof.twoSidedLoss, - upgradeTreeSize: 8, - firstUpgradeDay: loopProof.day, - }; - checklist.juese = { - keeperSpriteResident: true, // 构造保证:drawKeeper 每帧绘制 - lineGroups: Object.keys(LINES).length, - lineTotal: Object.values(LINES).reduce((s, a) => s + a.length, 0), - linesShownLive: measures.linesShown, - }; + /* ════════════════════════════════════════════════════════════════════ + * stub 通道(缺省·回滚锚):prepareCanvas(#game) 2D + 共用收尾 + 输入挂 #game + 浏览器 RAF/evidence + * ════════════════════════════════════════════════════════════════════ */ + async function setupStub() { + const g = prepareCanvas(canvas); + stubRenderCtx = g; + await setupCommon(g, () => canvas); // 绘制面=#game ctx;输入画布=#game(首帧由 setupCommon 内 stepOneFrame 经 drawBackdrop 铺底,与轮 1 逐行等价) + attachInput(canvas); // 输入监听挂 #game(逐行不变) + if (mode === 'evidence') { + setStatus(`evidence 就绪 seed=${seed} · 用 window.__refHost 驱动`); + } else { + setStatus(`seed=${seed} · 点「开店!」开始(首次点击开声)`); + startRaf(); + } + return refHost; } - refreshForensics(); - window.__refForensics = forensics; - window.__refChecklist = checklist; - /* ── 首帧 + 输入锚 + 启动 ── */ - stepFrames(1); - mark('t_first_paint', { frame: bundle.frameCount() }); - try { - probe.init(probeCtx); // 绑输入 → t_input_bound(恰在 first_paint 之后,偏序成立) - } catch (e) { - uncaught.push({ type: 'probe-init', message: String((e && e.message) || e) }); + /* ════════════════════════════════════════════════════════════════════ + * real 通道(引擎掌帧):setGLEnable(false) → engineInit 五回调 → setupReal(bundle/game/输入/就绪标记全在 then 内) + * 仿 host.js:897-928。控制流=实质重构(不是局部插桩,§12-M4)。 + * ════════════════════════════════════════════════════════════════════ */ + // 引擎五回调实体经 holder 转发到 setupReal 内真实现(仿 host.js:880-887 _engStep 范式;engineInit 须先拿到回调)。 + /** @type {() => void} */ let _engUpdate = () => {}; + /** @type {() => void} */ let _engRender = () => {}; + function engGameInit() { window.__refEngineInitFired = true; } + function engGameUpdate() { _engUpdate(); } + function engGameUpdatePost() { /* ref 无需逻辑步后处理 */ } + function engGameRender() { _engRender(); } + function engGameRenderPost() { /* ref 后处理走 game.render 内 palettePost.renderPost;此回调留空 */ } + + /** + * setupReal:引擎 engineInit 就绪后(mainContext 存在)的收尾。一切 bundle/game/输入/就绪标记都在此(§12-M1/M4)。 + * @param {CanvasRenderingContext2D} mainContext 引擎 mainContext(纯 2D 绘制面) + */ + async function setupReal(mainContext) { + // 共用收尾:bundle context2d=mainContext(§12-M4 必须 then 内以 mainContext 建);输入画布=引擎 mainCanvas。 + await setupCommon(mainContext, () => LJS.mainCanvas); + // 输入监听挂引擎 mainCanvas(坐标按引擎可见画布 rect,§12-M4;不再硬绑 #game)。 + attachInput(LJS.mainCanvas); + // 引擎五回调实体接线:每逻辑步 game.update(FIXED_DT);每渲染帧 game.render(mainContext)(引擎已清屏)。 + // real 下 DPR 补偿:引擎 mainContext 无 DPR 变换(每帧 updateCanvas 重置单位阵),游戏按逻辑像素绘制 → + // 在 save/restore 内补 setTransform(DPR) + imageSmoothingEnabled=false(对齐 host.js:489 renderParticles 补偿)。 + // 【mustFix #2】real 驱动点:每逻辑步在 game.update 之前自增唯一时钟(与 stub stepOneFrame 同口径), + // 使 bundle.clock 与游戏逻辑读同一推进值(修双钟分裂致输入缓冲补打静默失效)。 + _engUpdate = () => { if (game) { mockNowMs += FIXED_DT * 1000; game.update(FIXED_DT); } }; + _engRender = () => { + if (!game) return; + mainContext.save(); + mainContext.setTransform(DPR, 0, 0, DPR, 0, 0); // 引擎 mainContext 无 DPR 变换,host 补,保游戏逻辑像素口径 + try { mainContext.imageSmoothingEnabled = false; } catch { /* 部分环境只读,忽略 */ } + game.render(mainContext); + mainContext.restore(); + }; + // 引擎自建 mainCanvas 打 id(CDP 取证选择器=#game-engine,仿 host.js:830;real 下 #game 不是可见画布)。 + if (LJS.mainCanvas) LJS.mainCanvas.id = 'game-engine'; + // 暴露引擎双时钟(仿 host.js:840;门① 掌帧断言读 window.__engine.snapshot())。 + window.__engine = { + frame() { return LJS.frame; }, + time() { return LJS.time; }, + timeReal() { return LJS.timeReal; }, + paused() { return LJS.paused; }, + timeScale() { return LJS.timeScale; }, + snapshot() { return { frame: LJS.frame, time: LJS.time, timeReal: LJS.timeReal, paused: LJS.paused, timeScale: LJS.timeScale }; }, + }; + // 【M1 硬约束·real 专属就绪标记】game.init 完成 + 受控面全就绪后置(仿 host.js:920)。driver real 分支等它再驱动。 + window.__refBootedReal = true; + setStatus(`engine RAF running · seed=${seed} engine=real`); } - mark('t_game_start', { mode }); - const refHost = { - seed, - mode, - protocol: CORE_PROTOCOL_VERSION, - frameCount: () => bundle.frameCount(), - stepFrames, - startRaf, - stopRaf, - /** 注入一次点击(evidence 驱动;与真输入同走受控桥)。 */ - tap(x, y) { - unlockAudioOnce(); - inputBridge._emit('pointerdown', { x, y }); - inputBridge._emit('pointerup', { x, y }); - }, - /** 直接命令(e2e 辅助;与 UI 同走逻辑公开 API)。 */ - do(action) { - handleAction(action); - }, - state: forensics.state, - forensics, - }; - window.__refHost = refHost; - - if (mode === 'evidence') { - // evidence:不起 RAF,画面完全由 __refHost.stepFrames/tap 接管(同参数重跑一致)。 - setStatus(`evidence 就绪 seed=${seed} · 用 window.__refHost 驱动`); - } else { - setStatus(`seed=${seed} · 点「开店!」开始(首次点击开声)`); - startRaf(); + /* ════════════════════════════════════════════════════════════════════ + * 通道分流:stub(缺省,回滚锚)| real(引擎掌帧) + * ════════════════════════════════════════════════════════════════════ */ + if (engineMode !== 'real') { + // stub 回滚通道:行为与轮 1 逐行等价(逐像素门⑤兜)。 + window.__refBootedReal = false; // 标记非 real 通道(driver stub 分支不等它) + return await setupStub(); } + + // 【Option C 守卫】real 通道必须由 entry.js 注入引擎(opts.engine);缺失说明经 main.js 直 import bootRef(未走 entry 包装)。 + // 显式 throw 而非裸 LJS.xxx 抛 TypeError,便于 __refBootError 镜像可读错因(index.html/CDP 据此判 real 失败)。 + if (!LJS) { + const err = 'real 通道未注入引擎(opts.engine 缺失):请经 entry.js 的 bootRef 包装层启动(main.js 不直接 import 引擎,Option C)'; + window.__refBootError = err; + window.__refBootedReal = false; + setStatus('ENGINE INJECT MISSING: ' + err + '(可加 ?engine=stub 回滚桩)'); + throw new Error(err); + } + // real 通道:引擎掌帧。引擎设置须全部在 engineInit 之前调(守卫多在 engineInit 内)。 + // 1) setGLEnable(false):纯 2D 渲进 mainContext,readback 无 WebGL 坑(harness §5-坑5)。 + // 2) setShowSplashScreen(false):关开屏遮挡(默认已 false,显式兜底)。 + // 3) setDebugWatermark(false):关右上角 FPS 水印(开发构建默认 true,每帧画非确定 averageFPS 进 mainContext,污染 readback,必须关)。 + // 4) setCanvasClearColor(BLACK):引擎默认 clearColor=CLEAR_BLACK(alpha=0) 不铺底;强制不透明 BLACK,底色语义与 stub clearBlack 对齐。 + LJS.setGLEnable(false); + LJS.setShowSplashScreen(false); + LJS.setDebugWatermark(false); + LJS.setCanvasClearColor(LJS.BLACK); + // 固定 backing=780×1688(=逻辑 390×844 × DPR2,取证固定环境)。 + LJS.setCanvasFixedSize(LJS.vec2(VIEW_W * DPR, VIEW_H * DPR)); + // rootElement:用专用空容器(引擎 engineInit 会整体覆盖容器样式,不能复用承载 #status/#game 的 #wrap)。建独立 div 挂 body。 + const engineRoot = document.createElement('div'); + engineRoot.id = 'engine-root'; + document.body.appendChild(engineRoot); + // engineInit 异步:resolve 后 mainContext 就绪 → setupReal 建 bundle/game/输入/就绪标记。 + await LJS.engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], engineRoot) + .then(() => setupReal(LJS.mainContext)) + .catch((e) => { + // 引擎接管失败:镜像 __refBootError,status 提示可加 ?engine=stub 回滚;门④/CDP 据此判 real 失败→回滚 stub。 + window.__refBootError = String((e && e.message) || e); + window.__refBootedReal = false; + setStatus('ENGINE INIT FAILED: ' + window.__refBootError + '(可加 ?engine=stub 回滚桩)'); + throw e; // 重抛:index.html bootRef().catch 镜像 __refBootError + 评估门看 console + }); + return refHost; } diff --git a/game-runtime/games/wanglanmei-ref/test/e2e-input.cdp.cjs b/game-runtime/games/wanglanmei-ref/test/e2e-input.cdp.cjs index 54dda993..93dcea23 100644 --- a/game-runtime/games/wanglanmei-ref/test/e2e-input.cdp.cjs +++ b/game-runtime/games/wanglanmei-ref/test/e2e-input.cdp.cjs @@ -3,12 +3,19 @@ * * 【所有权】主会话维护(HJ-T1B-A-EXEC-001 §4:评估执行主体非开发 agent 所有)。 * 【运行面】仅 mini-desktop(连本地 headless chrome via CDP,样板=integration-run.cjs 同形); - * 前置:static-serve 起在 4320、chrome 起在 9222(--headless=new --no-sandbox --no-zygote --remote-allow-origins=*)。 - * 【四件套】输入轨迹 JSONL / 截图+canvas 哈希 / probe JSONL / checklist JSON → evidence/round-1/。 - * 【判读口径】硬门=boot/probe 链/真输入闭环(开局→首日结算→次日升级)/接待数>0/菜单帧哈希双开一致; - * live 软项(rushPalette/bgmCrossfade/sfxKinds/hitStop)=如实记录不硬挡(亲玩轮校准类,见 FORENSICS §2)。 + * 前置:static-serve 起在 4320、chrome 起在 9222(--headless=new --no-sandbox --no-zygote --remote-allow-origins=* --autoplay-policy=no-user-gesture-required)。 + * 【四件套】输入轨迹 JSONL / 截图+canvas 哈希 / probe JSONL / checklist JSON → evidence/round-N/。 + * + * 【双通道·P1】 + * - ENGINE=stub(缺省):原轮 1 取证主体逐行不变(mode=evidence + __refHost.stepFrames 手摇定量推帧 + 逐像素双开哈希)。 + * - ENGINE=real:引擎掌帧通道(M1/M2/M3 范式切换·非增量)。判读口径: + * 门①掌帧(window.__engine.snapshot() 帧增量∈[20,45]/500ms) / 门②同屏像素(#game-engine 上游戏暖色 warmHits>N ∧ 引擎粒子 brightHits>0∧maxCh>80) / + * 门③真接线(__engineCalls 含 particles.spawnEmitter call-ID) / 门④CDP 真玩闭环(真触摸+轮询·禁 stepFrames 手摇,否则双掌帧违铁律) / 门⑦console 零未捕获。 + * real 等 __refBootedReal 再驱动;坐标 rect 切 #game-engine。 + * * 用法(cwd=game-runtime):node games/wanglanmei-ref/test/e2e-input.cdp.cjs [baseUrl] [outDir] * 缺省 baseUrl=http://127.0.0.1:4320 outDir=games/wanglanmei-ref/evidence/round-1 + * 通道:环境变量 ENGINE=real|stub(缺省 stub)。real 时 baseUrl 后自动拼 &engine=real。 */ 'use strict'; @@ -22,7 +29,19 @@ const BASE = process.argv[2] || 'http://127.0.0.1:4320'; const OUT_DIR = path.resolve(process.argv[3] || 'games/wanglanmei-ref/evidence/round-1'); const CDP_HTTP = process.env.CDP_HTTP || H.DEFAULT_CDP_HTTP || 'http://127.0.0.1:9222'; const SEED = 305441741; // BUILD.md 取证缺省种子,与轮 1 全部证据统一 -const URL_EVIDENCE = `${BASE}/?mode=evidence&seed=${SEED}`; +// 【P1】通道选择:ENGINE=real → 引擎掌帧通道;缺省 stub → 原逐像素确定性取证主体(逐行不变)。 +const ENGINE = (process.env.ENGINE || 'stub').toLowerCase() === 'real' ? 'real' : 'stub'; +const IS_REAL = ENGINE === 'real'; +// stub:原 URL(mode=evidence + 手摇)。real:去掉 mode=evidence(真触摸+轮询,不靠 stepFrames),拼 &engine=real。 +const URL_EVIDENCE = IS_REAL + ? `${BASE}/?engine=real&seed=${SEED}` + : `${BASE}/?mode=evidence&seed=${SEED}`; +// real 通道引擎自建可见画布选择器(host.js:830 同口径);stub 用 #game。坐标 rect 与像素扫描均切此画布(M3)。 +const CANVAS_SEL = IS_REAL ? '#game-engine' : '#game'; +// 门① 引擎掌帧帧增量区间(@60fps ~30;排单步假推进+失控空转,复用 gate0 G0-2/a6 A6-1 口径)。 +const FRAME_WINDOW_MS = 500; +const FRAME_DELTA_MIN = 20; +const FRAME_DELTA_MAX = 45; /* ───────────────────────── 基础设施(自带连接段) ───────────────────────── */ @@ -68,15 +87,19 @@ async function connectRef(url) { return cdp; } -/** 等参考件引导完成(__refBooted/__refHost;boot 含 13 图装载+2 首 mp3 解码,给 25s 裕量)。 */ +/** 等参考件引导完成(boot 含 13 图装载+2 首 mp3 解码,给 25s 裕量)。 + * 【M1】real 通道等 __refBootedReal(只在 engineInit().then 内 game.init 完成后置,仿 host-dev __hostBootedReal); + * stub 等 __refBooted(bootRef().then 置)。real 据 __refBooted 驱动会打空/读 undefined → 假失败或间歇绿,故分流。 */ async function waitRefBoot(session) { const deadline = Date.now() + 25000; await delay(900); for (;;) { - const st = await session.evaluate('({ b: !!window.__refBooted, e: window.__refBootError || null, h: !!window.__refHost })'); + const st = IS_REAL + ? await session.evaluate('({ b: !!window.__refBootedReal, e: window.__refBootError || null, h: !!window.__refHost })') + : await session.evaluate('({ b: !!window.__refBooted, e: window.__refBootError || null, h: !!window.__refHost })'); if (st && st.e) throw new Error('参考件引导失败:' + st.e); if (st && st.b && st.h) return; - if (Date.now() > deadline) throw new Error('等 __refBooted 超时'); + if (Date.now() > deadline) throw new Error(`等 ${IS_REAL ? '__refBootedReal' : '__refBooted'} 超时`); await delay(300); } } @@ -123,13 +146,16 @@ async function listZones(phaseTag) { 首跑实翻教训:页面壳曾把 canvas 下推 ≈29px,zone 坐标直发全部偏移 → 一律经 rect 映射换算。 */ let MAP = { l: 0, t: 0, sx: 1, sy: 1 }; async function initMapping() { + // 【M3】坐标 rect 选择器按通道切:real=#game-engine(引擎自建可见画布,host.js:830;real 下 #game 不是可见画布,取错画布/null→坐标全错);stub=#game。 const m = await cdp.evaluate(`(function(){ - var c = document.getElementById('game'); var r = c.getBoundingClientRect(); + var c = document.querySelector(${JSON.stringify(CANVAS_SEL)}); if(!c) return { err: 'canvas not found: ' + ${JSON.stringify(CANVAS_SEL)} }; + var r = c.getBoundingClientRect(); var dpr = window.devicePixelRatio || 1; return { l: r.left, t: r.top, w: r.width, h: r.height, vw: c.width / dpr, vh: c.height / dpr }; })()`); + if (m && m.err) throw new Error('坐标映射失败:' + m.err); MAP = { l: m.l, t: m.t, sx: m.w / m.vw, sy: m.h / m.vh }; - evidence.meta.coordMap = { rect: m, map: MAP }; + evidence.meta.coordMap = { rect: m, map: MAP, canvas: CANVAS_SEL }; console.log(' · 坐标映射:', JSON.stringify(evidence.meta.coordMap)); } const toClient = (x, y) => ({ x: MAP.l + x * MAP.sx, y: MAP.t + y * MAP.sy }); @@ -180,9 +206,209 @@ async function shot(name, withHash) { return null; } +/* ═══════════════════ real 通道专用驱动(M2 范式切换:真触摸+轮询,禁 stepFrames 手摇) ═══════════════════ */ + +/** real 真触摸点按(touchStart→touchEnd 成对;**不 stepFrames**——引擎内部 RAF 已掌帧,手摇=双掌帧违铁律,M2)。 + * 触后 delay 等引擎自然推帧落地;调用方再轮询 __refForensics 判相位。 */ +async function realTapAt(x, y, zoneId, settleMs) { + const c = toClient(x, y); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: c.x, y: c.y }] }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + trajectory.push({ seq: trajectory.length, wallMs: Date.now(), kind: 'tap', zoneId: zoneId || null, x, y, clientX: c.x, clientY: c.y }); + await delay(typeof settleMs === 'number' ? settleMs : 120); // 等真事件经浏览器→输入桥→引擎下一拍消化(不手摇推帧) +} +/** real 按 zone id 真点(找不到=返回 false 不抛)。 */ +async function realTapZone(id, settleMs) { + const zs = await listZones(null); + const z = zs.find((q) => q.id === id); + if (!z) return false; + await realTapAt(z.x, z.y, id, settleMs); + return true; +} +/** 轮询 __refForensics.state() 直到 pred(state)===true 或超时(real 判相位推进的唯一手段,替代手摇定量推帧)。 */ +async function pollState(pred, timeoutMs, stepMs) { + const deadline = Date.now() + (timeoutMs || 8000); + for (;;) { + const s = await F('FZ.state()'); + if (pred(s)) return s; + if (Date.now() > deadline) return s; // 超时返最后一次(调用方据此 check) + await delay(stepMs || 200); + } +} +/** 抓 CANVAS_SEL 像素并算「游戏暖色 warmHits + 引擎粒子亮色 brightHits/maxCh」(门②同屏判据;抽样网格步进 4*97 同 a6)。 + * 暖色判据(开工前从 palette.js BASE 坐实,§门②(a)):warmHit ⟺ r>140 ∧ r-b>85 ∧ g 0 && img.height > 0 && px && px.length > 0); + let warmHits = 0, brightHits = 0, maxCh = 0; + if (nonEmpty) { + for (let i = 0; i + 3 < px.length; i += 4 * 97) { + const r = px[i], g = px[i + 1], b = px[i + 2]; + const m = Math.max(r, g, b); + if (m > maxCh) maxCh = m; + if (m > 80) brightHits++; // 引擎粒子(burst 白/任意亮色)——非全黑底 + if (r > 140 && (r - b) > 85 && g < r) warmHits++; // 游戏美术店面暖木/砖/金色域(排纸底/天光/近白,§门②(a)) + } + } + return { nonEmpty, width: img && img.width, height: img && img.height, warmHits, brightHits, maxCh }; +} + +/** + * real 通道主流程(门①掌帧/②同屏像素/③call-ID/④真玩闭环+四件套/⑦console)。 + * 不跑 stub 的逐像素双开哈希(门⑤交 stub 通道独占,harness §5-坑6)。 + */ +async function mainReal() { + fs.mkdirSync(OUT_DIR, { recursive: true }); + console.log('=== 参考件 e2e(④ 评估·engine=real)开始 ===', URL_EVIDENCE); + evidence.meta.engineMode = 'real'; + + cdp = await connectRef(URL_EVIDENCE); // 内部 waitRefBoot 已按 IS_REAL 等 __refBootedReal + try { + await initMapping(); // rect 切 #game-engine(M3) + + /* 门⑦ console + probe 链:real 引导后零未捕获 + 六锚链成立 */ + const probe = await F('FZ.probe()'); + fs.writeFileSync(path.join(OUT_DIR, 'probe.jsonl'), (probe.jsonl || '') + '\n'); + check(probe.verify && probe.verify.ok === true, `probe 链 verify.ok(实际:${JSON.stringify(probe.verify)})`); + check(Array.isArray(probe.uncaught) && probe.uncaught.length === 0, `门⑦ console 零未捕获(实际:${JSON.stringify(probe.uncaught)})`); + evidence.boot = { anchors: probe.anchors, initOk: probe.initOk, registeredPlugins: probe.registeredPlugins }; + + /* 门① 掌帧:window.__engine.snapshot() 两采样间隔 500ms,frame 增量∈[20,45](排单步假推进+失控空转) */ + const eng0 = await cdp.evaluate('(window.__engine ? window.__engine.snapshot() : null)'); + await delay(FRAME_WINDOW_MS); + const eng1 = await cdp.evaluate('(window.__engine ? window.__engine.snapshot() : null)'); + const dFrame = (eng0 && eng1) ? (eng1.frame - eng0.frame) : -1; + check(eng0 && eng1 && dFrame >= FRAME_DELTA_MIN && dFrame <= FRAME_DELTA_MAX, + `门① 引擎掌帧 frame 增量∈[${FRAME_DELTA_MIN},${FRAME_DELTA_MAX}](实际 dFrame=${dFrame})`); + evidence.gates.engineFrameDrive = { dFrame, range: [FRAME_DELTA_MIN, FRAME_DELTA_MAX], s0: eng0, s1: eng1 }; + + /* 门③ 真接线:经游戏内真粒子(开店→营业→serve_ok 飞行 burstCoin)触发后,__engineCalls 含 particles.spawnEmitter call-ID。 + 先开局营业造 serve_ok(真粒子来源),再读 __engineCalls。先清空记录隔离引导期残留。 */ + await cdp.evaluate('(function(){ try{ window.__engineCalls=[]; }catch(e){} return 1; })()'); + + /* 门④ 真玩闭环:开店→进货→营业→结算→升级(真触摸+轮询,禁 stepFrames) */ + // 菜单态截图(real:引擎已在渲,直接截 #game-engine) + await waitAssets(cdp); + await shot('real-menu', false); + await snapState('real-menu'); + + check(await realTapZone('newGame'), '门④ 真点 newGame'); + let st = await pollState((s) => s.phase === 'prep', 4000); + check(st.phase === 'prep', `门④ phase=prep(实际:${st.phase})`); + await listZones('real-prep-day1'); + await shot('real-prep-day1', false); + + // 备货:对全部 inc-* 各点 6 次(买空/钱尽=无代价 no-op) + const incZones = (await listZones(null)).filter((z) => /^inc-/.test(z.id)); + for (const z of incZones) for (let k = 0; k < 6; k++) await realTapAt(z.x, z.y, z.id, 70); + st = await snapState('real-after-stock-day1'); + + check(await realTapZone('openShop'), '门④ 真点 openShop'); + st = await pollState((s) => s.phase === 'open', 4000); + check(st.phase === 'open', `门④ 营业态 phase=open(实际:${st.phase})`); + + // 营业接待循环:每拍真点全部 serve-*,delay 等引擎自然推帧,轮询直到离开 open(自适应,禁 stepFrames) + let guard = 0, serveOkSeen = 0; + for (;;) { + guard++; + if (guard > 400) { check(false, '门④ 营业循环护栏触发(400 轮未离开 open)'); break; } + const zs = await listZones(null); + for (const z of zs.filter((q) => /^serve-/.test(q.id))) await realTapAt(z.x, z.y, z.id, 60); + await delay(180); // 等引擎推帧 + 收银冷却滚动 + if (!serveOkSeen) serveOkSeen = await F('FZ.recentEvents().filter(function(e){return e.type==="serve_ok"}).length'); + const ph = (await F('FZ.state()')).phase; + if (ph !== 'open') break; + } + st = await snapState('real-after-serving-day1'); + check(st.today && st.today.served > 0, `门④ 首日接待数>0(实际:${st.today && st.today.served})`); + check(serveOkSeen > 0, `门④ 循环内追踪到 serve_ok 事件(${serveOkSeen} 条)`); + + /* 门③ 读 call-ID(serve_ok 飞行 onArrive 触 burstCoin→particles-juice.spawnEmitter→getEngine().particles.spawnEmitter) */ + const calls = await cdp.evaluate('JSON.stringify(window.__engineCalls||[])'); + let callList = []; try { callList = JSON.parse(calls) || []; } catch (_) { callList = []; } + const hasSpawn = callList.some((c) => typeof c === 'string' && c.indexOf('particles.spawnEmitter') === 0); + check(hasSpawn, `门③ __engineCalls 含 particles.spawnEmitter call-ID(真接线;实际样本:${JSON.stringify(callList.slice(0, 8))})`); + evidence.gates.engineWiringCallId = { hasSpawn, sample: callList.slice(0, 20) }; + + /* 门② 同屏像素:截 #game-engine,游戏暖色 warmHits>200 ∧ 引擎粒子 brightHits>0∧maxCh>80(两类同命中⟺同一张画布合并) */ + // 触一发真粒子(空点/serve 后引擎下一拍渲),再扫(burst 寿命短,扫前再点一发保粒子在屏) + const serveZs = (await listZones(null)).filter((z) => /^serve-/.test(z.id)); + if (serveZs.length) { await realTapAt(serveZs[0].x, serveZs[0].y, serveZs[0].id, 40); } + // 【shouldFix】扫像素前先等引擎 frame 真增量 >= SCAN_FRAME_DELTA_MIN(读 window.__engine.snapshot().frame 确认已推进)。 + // 起因:headless chrome 的 RAF 推帧偶发偏慢,固定 delay(80) 不保证 burst 粒子已渲到当前帧 → 粒子相位错开、 + // brightHits 偶发 0 假阴。改为「轮询引擎帧增量达标」再扫,把扫描钉在引擎确已推进若干帧之后(自适应,非死等)。 + const SCAN_FRAME_DELTA_MIN = 2; // burst 粒子寿命短,等 ≥2 帧足够其渲到屏;不宜过大(粒子可能已 fade) + { + const f0 = await cdp.evaluate('(window.__engine ? window.__engine.snapshot().frame : -1)'); + const fDeadline = Date.now() + 2000; + for (;;) { + const f1 = await cdp.evaluate('(window.__engine ? window.__engine.snapshot().frame : -1)'); + if (f0 >= 0 && f1 >= 0 && f1 - f0 >= SCAN_FRAME_DELTA_MIN) break; + if (Date.now() > fDeadline) break; // 超时如实续扫(下方 check 会据实际 hit 数判读) + await delay(40); + } + } + const scan = await scanSameScreen(CANVAS_SEL); + await shot('real-sameScreen', false); + // 【shouldFix】warmHits>200 阈值:real 首跑请打印 scan.warmHits 实际值校准 N + // (200 是开工前的保守估计,首次真机取证后据「游戏暖色域在 #game-engine 实测命中数」回填精确阈值, + // 避免误报;色域不等式 r>140∧r-b>85∧g 200; + const partOk = scan.brightHits > 0 && scan.maxCh > 80; + check(scan.nonEmpty && warmOk && partOk, + `门② 同屏(#game-engine 游戏暖色 warmHits>200 且 引擎粒子 brightHits>0∧maxCh>80;实际 warmHits=${scan.warmHits} brightHits=${scan.brightHits} maxCh=${scan.maxCh})`); + evidence.gates.sameScreenPixels = scan; + + /* 门 E 续:日结→确认→升级(real 真玩闭环复证) */ + if (st.phase === 'settle' || st.phase === 'summary') { + await shot('real-settle-day1', false); + if (!(await realTapZone('confirmSettle'))) await cdp.evaluate(`window.__refHost.do('confirmSettle')`); + let st2 = await pollState((s) => s.phase === 'summary' || s.phase === 'prep', 4000); + if (st2.phase === 'summary') { + if (!(await realTapZone('confirmSummary'))) await cdp.evaluate(`window.__refHost.do('confirmSummary')`); + st2 = await pollState((s) => s.phase === 'prep', 4000); + } + await listZones('real-day2-prep'); + const upOk = await realTapZone('up-speed1'); + st2 = await pollState((s) => s.upgrades && s.upgrades.speed1 === true, 3000); + check(upOk && st2.upgrades && st2.upgrades.speed1 === true, `门④ 真点 up-speed1 后 upgrades.speed1=true(实际:${st2.upgrades && st2.upgrades.speed1})`); + await shot('real-day2-upgraded', false); + } else { + check(false, `门④ 首日到达结算(实际:${st.phase})`); + } + + /* 终采:checklist + measures 全量落盘 */ + const checklist = await cdp.evaluate('window.__refChecklist', 30000); + const measures = await F('FZ.measures()'); + fs.writeFileSync(path.join(OUT_DIR, 'checklist.json'), JSON.stringify({ checklist, measures }, null, 2)); + evidence.checklist = checklist; evidence.measures = measures; + evidence.softLive = { + rushPaletteSeen: !!measures.rushPaletteSeen, bgmCrossfadeSeen: !!measures.bgmCrossfadeSeen, + sfxKindCount: (checklist.yinyue && checklist.yinyue.sfxKindCount) || 0, + hitStopTriggered: (measures.hitStopCount || 0) > 0, audioUnlocked: !!(checklist.yinyue && checklist.yinyue.audioUnlocked), + }; + console.log(' · live 软项:', JSON.stringify(evidence.softLive)); + } finally { + try { cdp.close(); await closeTarget(cdp.targetId); } catch { /* 收尾容错 */ } + } + + /* 落盘四件套汇总 */ + fs.writeFileSync(path.join(OUT_DIR, 'input-trajectory.jsonl'), trajectory.map((t) => JSON.stringify(t)).join('\n') + '\n'); + evidence.gates.closedLoopRealInput = !evidence.failures.some((f) => /门④/.test(f)); + evidence.gates.engineTakeover = !evidence.failures.some((f) => /门①|门②|门③/.test(f)); + evidence.pass = evidence.failures.length === 0; + evidence.meta.endedAt = new Date().toISOString(); + fs.writeFileSync(path.join(OUT_DIR, 'e2e-evidence.json'), JSON.stringify(evidence, null, 2)); + console.log(`=== REF_E2E(real): ${evidence.pass ? 'PASS' : 'FAIL'} ===(failures=${evidence.failures.length})`); + evidence.failures.forEach((f) => console.log(' FAIL · ' + f)); + process.exit(evidence.pass ? 0 : 1); +} + /* ───────────────────────── 主流程 ───────────────────────── */ (async function main() { + if (IS_REAL) { await mainReal(); return; } // 【P1】real 通道走范式切换主体(真触摸+轮询);stub 通道下方逐行不变 fs.mkdirSync(OUT_DIR, { recursive: true }); console.log('=== 参考件 e2e(④ 评估)开始 ===', URL_EVIDENCE); diff --git a/game-runtime/games/wanglanmei-ref/test/headless-boot.test.mjs b/game-runtime/games/wanglanmei-ref/test/headless-boot.test.mjs index dc1ee06b..88b11e0a 100644 --- a/game-runtime/games/wanglanmei-ref/test/headless-boot.test.mjs +++ b/game-runtime/games/wanglanmei-ref/test/headless-boot.test.mjs @@ -165,3 +165,34 @@ test('无浏览器确定性:同 seed 同驱动脚本 → 状态终点一致', const b = await run(); assert.equal(a, b, '同 seed 同脚本应逐字段一致(受控时间/随机生效)'); }); + +test('P4 输入缓冲补打恢复:冷却期点击在窗口内自动补打(mustFix #2 回归守门)', async () => { + // 【守门意图】本测专钉「双 mockNowMs 致 bundle.clock 冻死=0 → 补打窗口判恒假」缺陷的回归: + // 修前 host 喂 bundle.clock 的钟恒 0 → 输入事件 tMs=0 → InputBuffer 记 0 → game-shop maybeReplayBufferedServe + // 用推进的游戏钟减 0 判窗口恒假 → measures.bufferedServeReplays 恒 0。 + // 修后 host 唯一推进时钟既喂 bundle.clock 又(经 ctx.time)供游戏逻辑读 → 同钟同刻 → 窗口判真实生效。 + // 驱动构造(确定性,seed=1 实测 replays=1):serve 一发起冷却 → 推进到冷却尾段(剩余<260ms 进窗)补一发同 + // product(冷却中→buffer)→ 静候冷却清(不点别的)→ 该帧 maybeReplayBufferedServe 用同钟补打成功。 + const host = await bootHeadless('?mode=evidence&seed=1'); + const F = globalThis.window.__refForensics; + const z = (id) => F.geometry().find((zz) => zz.id === id); + const tap = (id) => { const g = z(id); if (g) host.tap(g.x, g.y); return !!g; }; + + tap('newGame'); host.stepFrames(2); + for (const pid of ['soda', 'snack', 'bread']) for (let i = 0; i < 9; i++) { if (tap('inc-' + pid)) host.stepFrames(1); } + tap('openShop'); host.stepFrames(2); + assert.equal(F.state().phase, 'open', '应入营业'); + + let guard = 0; + while (F.state().phase === 'open' && guard++ < 300) { + for (const pid of F.state().unlocked) { + const g = z('serve-' + pid); + if (!g) continue; + host.tap(g.x, g.y); host.stepFrames(20); // 第一发 serve→冷却;推进到冷却尾段 + host.tap(g.x, g.y); host.stepFrames(18); // 第二发冷却中→buffer;静候冷却清→补打 + } + host.stepFrames(4); + } + const replays = F.measures().bufferedServeReplays; + assert.ok(replays > 0, `接待缓冲补打应触发(bufferedServeReplays=${replays};恒 0 即双钟分裂回归)`); +}); diff --git a/game-runtime/games/wanglanmei-ref/test/ref-e2e-real-runbook.sh b/game-runtime/games/wanglanmei-ref/test/ref-e2e-real-runbook.sh new file mode 100644 index 00000000..92fd9f9c --- /dev/null +++ b/game-runtime/games/wanglanmei-ref/test/ref-e2e-real-runbook.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# ref-e2e-real-runbook.sh — 参考件 ?engine=real 引擎掌帧 e2e 编排(仅 mini-desktop;主会话经 ssh bash -s 投递) +# 【P1】沿 ref-e2e-runbook.sh 的 git-clone-scratch 口径(A4,非 tar-pipe);real 版三处差异: +# ① 依赖安装改 `npm i`(装 deps+devDeps=引擎 littlejsengine + esbuild)再 `npm i ws`—— +# **禁 `npm init -y`**(会覆盖含引擎依赖的 package.json 丢 littlejsengine,real 构建即失败;harness §5-坑2); +# ② 构建后 `grep -q ParticleEmitter dist/ref-bundle.js` 核「引擎真入包」(§8/A4); +# ③ driver 经 ENGINE=real 环境变量走范式切换主体(真触摸+轮询,禁 stepFrames;URL 自动拼 &engine=real)。 +# 形状沿 ref-e2e-runbook.sh:不跑 -e 全局(探活/清理容错),关键门各自判退出码;长驻进程一律 systemd-run。 +set -u +SHA="${1:?需要精确 commit SHA}" +SCRATCH="$HOME/t1bb-ref-real" +RT="$SCRATCH/game-runtime" +EV="$RT/games/wanglanmei-ref/evidence/real" +PORT=4320 +CDP_PORT=9222 +U_SERVE=t1bb-ref-real-serve +U_CHROME=t1bb-ref-real-chrome + +echo "=== [0] 环境与净场 ===" +hostname; node --version +systemctl stop "$U_SERVE" "$U_CHROME" 2>/dev/null; systemctl reset-failed "$U_SERVE" "$U_CHROME" 2>/dev/null +rm -rf "$SCRATCH" + +echo "=== [1] scratch 检出精确 SHA ===" +git clone -q --depth 50 --branch dev/2.0.0 ssh://git@101.200.34.71:2222/zizi-al/games-development-ai.git "$SCRATCH" || { echo "GATE: CLONE FAIL"; exit 1; } +git -C "$SCRATCH" checkout -q "$SHA" || { echo "GATE: SHA FAIL"; exit 1; } +echo "checkout=$(git -C "$SCRATCH" rev-parse --short HEAD)" + +echo "=== [2] 依赖+构建(esbuild 锁参;引擎真入包) ===" +cd "$RT" || exit 1 +# 【关键·harness §5-坑2】直接 npm i 装 deps+devDeps(含 littlejsengine + esbuild),禁 npm init -y(会丢引擎依赖)。 +npm i --no-audit --no-fund >/dev/null 2>&1 || { echo "GATE: NPM INSTALL FAIL"; exit 1; } +npm i --no-audit --no-fund ws >/dev/null 2>&1 || { echo "GATE: NPM WS FAIL"; exit 1; } +node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef || { echo "GATE: BUILD FAIL"; exit 1; } +ls -l games/wanglanmei-ref/dist/ref-bundle.js +# 核「引擎真入包」(§8/A4):grep ParticleEmitter;并打印 raw/gz 量级备查。 +grep -q ParticleEmitter games/wanglanmei-ref/dist/ref-bundle.js || { echo "GATE: ENGINE NOT IN BUNDLE(ParticleEmitter 缺)"; exit 1; } +node -e "const z=require('zlib'),fs=require('fs');const b=fs.readFileSync('games/wanglanmei-ref/dist/ref-bundle.js');console.log('ref-bundle raw='+b.length+' gz='+z.gzipSync(b,{level:9}).length);" +echo "GATE: BUILD OK(引擎真入包)" + +echo "=== [3] IP gate(产物+对外文本,码位构造防自触发) ===" +node -e " +const fs=require('fs');const b=String.fromCharCode(0x738b,0x84dd,0x8393);let bad=false; +for(const f of ['games/wanglanmei-ref/dist/ref-bundle.js','games/wanglanmei-ref/index.html','games/wanglanmei-ref/assets/manifest.json']){ + const hit=fs.readFileSync(f,'utf8').includes(b);console.log(f, hit?'VIOLATION':'clean'); if(hit) bad=true; +} +process.exit(bad?1:0)" || { echo "GATE: IPGATE FAIL"; exit 1; } +echo "GATE: IPGATE OK" + +echo "=== [4] 起 serve+chrome(systemd-run durable 单元) ===" +mkdir -p "$EV" +systemd-run --unit="$U_SERVE" --setenv=NODE_NO_WARNINGS=1 \ + /usr/bin/env node "$RT/test/harness/static-serve.cjs" "$RT/games/wanglanmei-ref" "$PORT" >/dev/null 2>&1 +CHROME="$(command -v google-chrome google-chrome-stable chromium chromium-browser 2>/dev/null | head -1)" +[ -n "$CHROME" ] || { echo "GATE: NO CHROME"; exit 1; } +systemd-run --unit="$U_CHROME" "$CHROME" --headless=new --no-sandbox --no-zygote \ + --remote-debugging-port=$CDP_PORT --remote-allow-origins='*' --autoplay-policy=no-user-gesture-required \ + about:blank >/dev/null 2>&1 +echo "units: $U_SERVE / $U_CHROME ($CHROME)" + +echo "=== [5] 探活门(就绪等待→serve 5×6s 持续在线 + CDP 就绪) ===" +READY=0 +for i in $(seq 1 10); do curl -sf "http://127.0.0.1:$PORT/" >/dev/null && { READY=1; break; }; sleep 0.5; done +[ "$READY" = 1 ] || { echo "GATE: SERVE NEVER READY(5s)"; journalctl -u "$U_SERVE" --no-pager | tail -10; systemctl stop "$U_SERVE" "$U_CHROME" 2>/dev/null; exit 1; } +ALIVE=1 +for i in 1 2 3 4 5; do + curl -sf "http://127.0.0.1:$PORT/" >/dev/null || { ALIVE=0; echo "probe $i: serve DOWN"; break; } + echo "probe $i: serve OK"; [ "$i" -lt 5 ] && sleep 6 +done +[ "$ALIVE" = 1 ] || { echo "GATE: SERVE FAIL"; systemctl stop "$U_SERVE" "$U_CHROME" 2>/dev/null; exit 1; } +CDPOK=0 +for i in 1 2 3 4 5 6 7 8 9 10; do curl -sf "http://127.0.0.1:$CDP_PORT/json/version" >/dev/null && { CDPOK=1; break; }; sleep 2; done +[ "$CDPOK" = 1 ] || { echo "GATE: CDP FAIL"; systemctl stop "$U_SERVE" "$U_CHROME" 2>/dev/null; exit 1; } +echo "GATE: SERVE+CDP OK" + +echo "=== [6] e2e driver(engine=real;真触摸+轮询·门①②③④⑦) ===" +ENGINE=real node games/wanglanmei-ref/test/e2e-input.cdp.cjs "http://127.0.0.1:$PORT" "$EV" 2>&1 | tee "$EV/driver-console.log" +RC=${PIPESTATUS[0]} +echo "driver rc=$RC" + +echo "=== [7] 轮终收尾(停 unit+日志归档,不留孤儿) ===" +journalctl -u "$U_SERVE" -u "$U_CHROME" --no-pager 2>/dev/null | tail -120 > "$EV/serve-units.log" +systemctl stop "$U_SERVE" "$U_CHROME" 2>/dev/null +systemctl reset-failed "$U_SERVE" "$U_CHROME" 2>/dev/null +ls -la "$EV" +echo "=== RUNBOOK(real) DONE rc=$RC ===" +exit "$RC" diff --git a/game-runtime/host-dev/engine-caps.js b/game-runtime/host-dev/engine-caps.js new file mode 100644 index 00000000..4ce1d4ad --- /dev/null +++ b/game-runtime/host-dev/engine-caps.js @@ -0,0 +1,166 @@ +/** + * host-dev/engine-caps.js — 引擎能力背书工厂(EngineCapabilities 三面:math/particles/audio.synth) + * owner:Runner v2 P1(提升自 host.js:197-318 的 makeEngineCaps 闭包)| 消费方:host-dev/host.js + games/wanglanmei-ref/src/main.js(real 通道) + * + * ════════════════════════════════════════════════════════════════════════════ + * 【为什么单列本模块(P1 提升·搬运非新造)】 + * host-dev 与 wanglanmei-ref real 通道都要把「插件能力→引擎」薄包装层接进受控面 EngineCapabilities。 + * 原实现是 host.js bootHostDev 内的闭包 makeEngineCaps(host.js:197-318,已包装 particles/audio.synth/math 三面)。 + * P1 把它提升为「吃 {LJS, engineMath, engineMode, recHook} 的纯工厂函数」,host-dev / ref 共用同一份—— + * 这是「模块化已有件」(搬运,语义零变),非从零造。引擎 import 仍只活在调用方(host.js / entry.js / ref entry.js), + * 本模块不 import 引擎(引擎名字空间经入参 LJS 注入),故 node 可 import 它(规避 F7:顶层 import 引擎在 node 抛 window)。 + * + * 【逐行保语义(回归基线=门0 8/8 + real 门 6/6,绝不可破)】 + * - 首行 engineMode!=='real' → return null:保 null-engine 回归短路(stub/node 不背书引擎,host.js:198 原语义)。 + * - recHook 默认 no-op:原闭包内 `window.__engineCalls.push` 由调用方经 recHook 注入(取证路径才注,渠道产物不注 → 探针无副作用)。 + * - mathRaw = engineMath(调用方传入 buildEngineMath(LJS) 的结果,host.js:204 同口径)。 + * - particles/audio.synth 三面包装体逐字搬自 host.js:214-316,引擎引用经入参 LJS.*。 + * + * 【引擎↔插件 边界(创始人 2026-06-13 拍)】引擎有 ParticleEmitter/zzfxG/zzfxM → 此处薄包装;插件侧删自研 sim/vendored 合成核,一职一路。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +/** + * 【P1 提升】构造引擎能力背书工厂(EngineCapabilities 三面:math 含 easing 门面 / particles / audio.synth)。 + * - 仅 real 通道返真能力面;stub/node 通道短路返 null(保 null-engine 回归战场 + 规避 F7 引擎 import 在 node 抛错)。 + * real/stub 都经此工厂(buildBundle/createHostDevContext 注入),若无条件背书引擎,stub 通道也会拿到引擎能力 → 破 null 回归; + * 故首行按 engineMode 短路:stub→返 null(等价「未注入」,触发 plugin.js getEngineShared 的 null 分支:告警一次 + getEngine() 返 null)。 + * - 引擎 import 唯一活点=调用方(host.js / entry.js LJS@顶部 import);插件零直接 import,一律经 ctx.getEngine().*(Q4 铁律)。 + * - math/easing=纯数学族特例(政策 §1.3):门面逐字节/ULP 等价引擎 LJS.lerp/smoothStep/Ease;gamefeel 侧保内置实现作 null-engine fallback(非有意替代)。 + * easing 门面**只暴 11 条等价曲线**——backInOut/elasticInOut 不入面(引擎 IN_OUT 拼接是另一条曲线,差 6.6%/17%;gamefeel 对其保内置=补层)。 + * + * @param {Object} opts + * @param {any} opts.LJS 引擎名字空间(含 ParticleEmitter/zzfxG/zzfxM/screenToWorld/vec2/rgb/cameraScale 等)。 + * @param {import('../src/core/api.d.ts').EngineMath} opts.engineMath 已构造的引擎数学门面(buildEngineMath(LJS) 的结果,调用方传入)。 + * @param {string} opts.engineMode 通道模式('real' 才背书引擎;其余短路返 null)。 + * @param {((id:string)=>void)} [opts.recHook] 真接线门②取证钩子:每个引擎能力叶方法被调时记一条 call-ID。 + * **默认 no-op**(不传或传 undefined → 探针无副作用、不挂 window.__engineCalls,渠道产物物理保证)。 + * @returns {import('../src/core/api.d.ts').EngineCapabilities|null} 引擎能力三面;非 real 返 null。 + */ +export function createEngineCaps(opts) { + const LJS = opts.LJS; + const engineMath = opts.engineMath; + const engineMode = opts.engineMode; + // recHook 默认 no-op:取证路径由调用方注入(id => window.__engineCalls.push(id));渠道产物不注 → 探针零副作用。 + const rec = opts.recHook || (() => { /* 默认 no-op:探针绝不入渠道产物 */ }); + + if (engineMode !== 'real') return null; // stub/node:不背书引擎(保 null-engine 回归;F7:node 本就 import 不了引擎) + + // math 门面(buildEngineMath 真活包装引擎,调用方传入)外再裹一层 rec:lerp/smoothStep 直裹,easing 子面逐曲线裹(保全部键与口径不变)。 + const mathRaw = engineMath; + const math = { + lerp: (a, b, p) => { rec('math.lerp'); return mathRaw.lerp(a, b, p); }, + smoothStep: (p) => { rec('math.smoothStep'); return mathRaw.smoothStep(p); }, + easing: Object.fromEntries(Object.keys(mathRaw.easing).map((k) => [k, (t) => { rec('math.easing.' + k); return mathRaw.easing[k](t); }])), + }; + return { + // ── math(含 easing 门面;外层 rec 记调用,内层 mathRaw 真包装引擎 lerp/smoothStep/Ease)── + math, + // ── particles ──薄包装引擎 ParticleEmitter:EngineEmitterSpec(像素口径)→ 引擎世界口径,返封装句柄。 + particles: { + /** + * 受控面 spawnEmitter:EngineEmitterSpec(像素口径)→ 引擎 ParticleEmitter(世界口径),返封装句柄(禁泄漏引擎对象)。 + * 边界模型(创始人 2026-06-13 拍):引擎有 ParticleEmitter → 此处薄包装;插件侧删自研 sim 积分循环,一职一路。 + * 引擎自渲:new ParticleEmitter 经 EngineObject 构造自入 engineObjects(esm.js:3580),引擎主循环每帧自调 + * update(发射+积分,esm.js:8348)+ render(drawTile 落 mainContext,esm.js:8654/8658)→ host 无需手动 add/render。 + * @param {import('../src/core/api.d.ts').EngineEmitterSpec} spec 归一化发射参数(像素/中性形态) + * @returns {import('../src/core/api.d.ts').EngineEmitterHandle} 封装句柄(仅 isActive/stop,不见引擎对象) + */ + spawnEmitter(spec) { + rec('particles.spawnEmitter'); // 真接线门②:记一次「插件经 ctx.getEngine().particles 真调引擎粒子构造」 + // 1) 像素↔世界阻抗换算(cameraScale 默认 32,esm.js:2785;host 未 setCameraScale 故用引擎 live binding 默认值,I3/OQ-2)。 + const worldPos = LJS.screenToWorld(LJS.vec2(spec.pos.x, spec.pos.y)); // 像素→世界(esm.js:5067/screenToWorld) + const SCALE = LJS.cameraScale; // 世界单位/像素(用 live binding,随引擎相机变) + // 2) burst/continuous → emitTime/emitRate(I1:引擎无「spawn 恰好 N」原语,burst 用「1 逻辑帧窗 × count×60 速率」近似 ≈N 颗)。 + const emitTime = spec.emitTime != null ? spec.emitTime + : (spec.count != null ? 1 / 60 : 0); // burst=1帧窗(满即自停) / continuous=0(forever) + const emitRate = spec.emitRate != null ? spec.emitRate + : (spec.count != null ? spec.count * 60 : 100); // burst: count×60 颗/秒(该窗内恰发 ≈count 颗) + // 3) speed:像素/秒 → 世界/帧@60(引擎 speed 单位来源 esm.js:8253,无再乘 dt)。 + const engSpeed = (spec.speed != null ? spec.speed : 0) / SCALE / 60; + // 4) damping:每帧速度乘子(spec 已由插件把 drag/秒折算为每帧乘子,这里直透;缺省 1=无阻)。 + const engDamping = spec.damping != null ? spec.damping : 1; + // 5) 颜色:归一化 {r,g,b,a} → 引擎 Color(esm.js:1547 rgb)。缺省不透明白→透明,保 real 像素门「有色像素命中>0」(I2)。 + const cs = spec.colorStart || { r: 1, g: 1, b: 1, a: 1 }; + const ce = spec.colorEnd || { r: 1, g: 1, b: 1, a: 0 }; + const colorStart = LJS.rgb(cs.r, cs.g, cs.b, cs.a); + const colorEnd = LJS.rgb(ce.r, ce.g, ce.b, ce.a); + // 6) 构造引擎发射器(27 参,按 d.ts:3232 构造签名逐位映射)。 + const em = new LJS.ParticleEmitter( + worldPos, // pos(世界) + spec.angle != null ? spec.angle : 0, // angle + (spec.emitSize != null ? spec.emitSize : 0) / SCALE, // emitSize 像素→世界 + emitTime, // emitTime(0=forever) + emitRate, // emitRate + spec.coneAngle != null ? spec.coneAngle : Math.PI, // emitConeAngle(半锥角) + undefined, // tileInfo: untextured(2D fillRect 路) + colorStart, colorStart, colorEnd, colorEnd, // colorStartA/B + colorEndA/B(A=B → 固定色不随机) + spec.particleTime != null ? spec.particleTime : 0.5, // particleTime + (spec.sizeStart != null ? spec.sizeStart : 6) / SCALE, // sizeStart 像素→世界 + (spec.sizeEnd != null ? spec.sizeEnd : 0) / SCALE, // sizeEnd 像素→世界 + engSpeed, // speed(世界/帧@60) + 0, // angleSpeed: 0(无玩法语义,不旋转) + engDamping, // damping + 1, // angleDamping: 1(无阻) + spec.gravityScale != null ? spec.gravityScale : 0, // gravityScale(host 未 setGravity,全局 gravity=0 故暂不生效,OQ-4 follow-up) + Math.PI, // particleConeAngle(粒子自转锥,留默认) + 0.1, // fadeRate(默认) + 0, // randomness: 0(spec 已显式给参,关引擎默认 .2 额外抖动求确定) + false, // collideTiles: false + spec.additive != null ? spec.additive : false, // additive + true // randomColorLinear(A=B 时无影响) + ); + // 7) 封装句柄(禁泄漏引擎对象;isActive/stop 转发,stop 幂等销毁)。 + return { + isActive() { return em.isActive(); }, // esm.js:8482 + stop() { if (!em.destroyed) em.destroy(true); }, // 幂等(已 destroyed 直返,不二次销毁) + }; + }, + }, + // ── audio.synth ──薄包装引擎 zzfxG/zzfxM:参数包/曲谱 → PCM 样本(不播放)。 + // 边界模型(创始人 2026-06-13 拍):引擎有 zzfxG/zzfxM → 此处薄包装;插件侧删 vendored 合成核,一职一路。 + // 引擎 zzfxG **不烤主音量 ×0.3**(样本=s*volume,esm.js:7391;×0.3 在引擎 gain 层 soundVolume=.3,esm.js:3121/6777) + // → 返回的样本是「未乘 0.3 的 PCM」,与 api.d.ts EngineAudioSynth 口径一致;×0.3 由插件播放层补(audio-music/impl.js playChannels)。 + // 合成异常/失败返 null(插件侧 → 发声 no-op,不回退 vendored)。 + audio: { + synth: { + /** + * ZzFX 单音效合成核:参数包 → PCM 单声道样本数组(未乘 0.3,不播放)。薄包装引擎 LJS.zzfxG。 + * @param {number[]} params ZzFX 参数包 + * @returns {number[]|null} PCM 单声道样本;合成异常返 null + */ + synthSfx: (params) => { + rec('audio.synth.synthSfx'); // 真接线门②:记一次「插件经 ctx.getEngine().audio.synth 真调引擎 zzfxG」 + try { + // 引擎 zzfxG 收 positional 参数(与 vendored 同签名,esm.js:7326)→ 展开参数包。 + return LJS.zzfxG(...params); + } catch (e) { + // 合成异常降级为 null(不连坐游戏,记 stderr);插件侧据 null 走发声 no-op。 + console.error('[host][engine-audio] zzfxG 合成异常,返 null:' + (e && e.message ? e.message : e)); + return null; + } + }, + /** + * ZzFXM 曲谱合成核:乐器/模式/序列/BPM → [L,R] 双声道样本(未乘 0.3,不播放)。薄包装引擎 LJS.zzfxM。 + * @param {number[][]} instruments 乐器表 + * @param {number[][][]} patterns 模式表 + * @param {number[]} sequence 序列 + * @param {number} [bpm] 节拍(缺省引擎默 125) + * @returns {number[][]|null} [L,R] 双声道样本;合成异常返 null + */ + synthSong: (instruments, patterns, sequence, bpm) => { + rec('audio.synth.synthSong'); // 真接线门②:记一次「插件经 ctx.getEngine().audio.synth 真调引擎 zzfxM」 + try { + // 引擎 zzfxM 签名与 vendored 逐字一致(esm.js:10837 `zzfxM(instruments, patterns, sequence, BPM=125)`)。 + return LJS.zzfxM(instruments, patterns, sequence, bpm); + } catch (e) { + console.error('[host][engine-audio] zzfxM 合成异常,返 null:' + (e && e.message ? e.message : e)); + return null; + } + }, + }, + }, + }; +} diff --git a/game-runtime/host-dev/host.js b/game-runtime/host-dev/host.js index a4d450ae..2000fa45 100644 --- a/game-runtime/host-dev/host.js +++ b/game-runtime/host-dev/host.js @@ -42,9 +42,12 @@ import { // setCanvasClearColor / BLACK / vec2 / mainCanvas / mainContext / frame / time / timeReal / // paused / timeScale 等全经 LJS.* 触达。 import * as LJS from 'littlejsengine'; -// 【A1】引擎数学门面构造(纯函数,零引擎 import):real 通道工厂 makeEngineCaps 用它包装引擎 lerp/smoothStep/Ease。 +// 【A1】引擎数学门面构造(纯函数,零引擎 import):real 通道工厂用它包装引擎 lerp/smoothStep/Ease。 // 单列在 engine-math.js(不 import 引擎)→ node 单测可 import 它注入 mock ljs 验门面,规避 F7(host.js 顶层 import 引擎在 node 抛 window)。 import { buildEngineMath } from './engine-math.js'; +// 【P1 提升】引擎能力背书工厂(EngineCapabilities 三面:math/particles/audio.synth)已从本文件闭包提升为可 import 模块, +// host-dev / wanglanmei-ref real 通道共用同一份(搬运·语义零变)。本模块不 import 引擎(经入参 LJS 注入)。 +import { createEngineCaps } from './engine-caps.js'; /* ────────────────────────────────────────────────────────────────────────── * 取证固定环境常量(与 test/harness/browser-evidence.cjs 的 FIXED_ENV 对齐) @@ -182,140 +185,10 @@ export function bootHostDev(opts) { return realAudioCtx; } - /** - * 【A1】引擎能力背书工厂:构造 EngineCapabilities 三面(math 含 easing 门面 / particles / audio.synth)。 - * - 仅 real 通道返真能力面;stub/node 通道短路返 null(保 null-engine 回归战场 + 规避 F7 引擎 import 在 node 抛错)。 - * real/stub 都经 buildBundle(setupAfterEngine 调),若无条件背书引擎,stub 通道也会拿到引擎能力 → 破 null 回归; - * 故首行按 engineMode 短路:stub→返 null(等价「未注入」,触发 plugin.js getEngineShared 的 null 分支:告警一次 + getEngine() 返 null)。 - * - 引擎 import 唯一活点=本 host(LJS@顶部 import);插件零直接 import,一律经 ctx.getEngine().*(Q4 铁律)。 - * - math/easing=纯数学族特例(政策 §1.3):门面逐字节/ULP 等价引擎 LJS.lerp/smoothStep/Ease; - * gamefeel 侧保内置实现作 null-engine fallback(非有意替代)。easing 门面**只暴 11 条等价曲线**—— - * backInOut/elasticInOut 不入面(引擎 IN_OUT 拼接是另一条曲线,差 6.6%/17%;gamefeel 对其保内置=补层)。 - * - particles/audio.synth=A1 留空骨架占位,A2/A3 在同一工厂内**替换填体**(串行落,避免本函数合并冲突)。 - * @returns {import('../src/core/api.d.ts').EngineCapabilities|null} - */ - function makeEngineCaps() { - if (engineMode !== 'real') return null; // stub/node:不背书引擎(保 null-engine 回归;F7:node 本就 import 不了引擎) - // 【真接线门②·host-dev 取证探针,绝不入渠道产物】每个引擎能力叶方法被调时记一条 call-ID 到 window.__engineCalls, - // 供 mini-desktop CDP harness 断言「插件运行时真的经 ctx.getEngine() 调到了引擎」(动态运行证据,区别于门①的静态 import)。 - // rec 失败静默(取证副作用绝不连坐运行时);本函数在 engineMode!=='real' 已 return null,故探针只活 real 通道。 - const rec = (id) => { try { (window.__engineCalls || (window.__engineCalls = [])).push(id); } catch (_) { /* 取证副作用,忽略 */ } }; - // math 门面(A1 buildEngineMath 真活包装引擎)外再裹一层 rec:lerp/smoothStep 直裹,easing 子面逐曲线裹(保全部键与口径不变)。 - const mathRaw = buildEngineMath(LJS); - const math = { - lerp: (a, b, p) => { rec('math.lerp'); return mathRaw.lerp(a, b, p); }, - smoothStep: (p) => { rec('math.smoothStep'); return mathRaw.smoothStep(p); }, - easing: Object.fromEntries(Object.keys(mathRaw.easing).map((k) => [k, (t) => { rec('math.easing.' + k); return mathRaw.easing[k](t); }])), - }; - return { - // ── math(A1 落实现,含 easing 门面;外层 rec 记调用,内层 mathRaw 真包装引擎 lerp/smoothStep/Ease)── - math, - // ── particles(A2 填体)──薄包装引擎 ParticleEmitter:EngineEmitterSpec(像素口径)→ 引擎世界口径,返封装句柄。 - particles: { - /** - * 受控面 spawnEmitter:EngineEmitterSpec(像素口径)→ 引擎 ParticleEmitter(世界口径),返封装句柄(禁泄漏引擎对象)。 - * 边界模型(创始人 2026-06-13 拍):引擎有 ParticleEmitter → 此处薄包装;插件侧删自研 sim 积分循环,一职一路。 - * 引擎自渲:new ParticleEmitter 经 EngineObject 构造自入 engineObjects(esm.js:3580),引擎主循环每帧自调 - * update(发射+积分,esm.js:8348)+ render(drawTile 落 mainContext,esm.js:8654/8658)→ host 无需手动 add/render。 - * @param {import('../src/core/api.d.ts').EngineEmitterSpec} spec 归一化发射参数(像素/中性形态) - * @returns {import('../src/core/api.d.ts').EngineEmitterHandle} 封装句柄(仅 isActive/stop,不见引擎对象) - */ - spawnEmitter(spec) { - rec('particles.spawnEmitter'); // 真接线门②:记一次「插件经 ctx.getEngine().particles 真调引擎粒子构造」 - // 1) 像素↔世界阻抗换算(cameraScale 默认 32,esm.js:2785;host 未 setCameraScale 故用引擎 live binding 默认值,I3/OQ-2)。 - const worldPos = LJS.screenToWorld(LJS.vec2(spec.pos.x, spec.pos.y)); // 像素→世界(esm.js:5067/screenToWorld) - const SCALE = LJS.cameraScale; // 世界单位/像素(用 live binding,随引擎相机变) - // 2) burst/continuous → emitTime/emitRate(I1:引擎无「spawn 恰好 N」原语,burst 用「1 逻辑帧窗 × count×60 速率」近似 ≈N 颗)。 - const emitTime = spec.emitTime != null ? spec.emitTime - : (spec.count != null ? 1 / 60 : 0); // burst=1帧窗(满即自停) / continuous=0(forever) - const emitRate = spec.emitRate != null ? spec.emitRate - : (spec.count != null ? spec.count * 60 : 100); // burst: count×60 颗/秒(该窗内恰发 ≈count 颗) - // 3) speed:像素/秒 → 世界/帧@60(引擎 speed 单位来源 esm.js:8253,无再乘 dt)。 - const engSpeed = (spec.speed != null ? spec.speed : 0) / SCALE / 60; - // 4) damping:每帧速度乘子(spec 已由插件把 drag/秒折算为每帧乘子,这里直透;缺省 1=无阻)。 - const engDamping = spec.damping != null ? spec.damping : 1; - // 5) 颜色:归一化 {r,g,b,a} → 引擎 Color(esm.js:1547 rgb)。缺省不透明白→透明,保 real 像素门「有色像素命中>0」(I2)。 - const cs = spec.colorStart || { r: 1, g: 1, b: 1, a: 1 }; - const ce = spec.colorEnd || { r: 1, g: 1, b: 1, a: 0 }; - const colorStart = LJS.rgb(cs.r, cs.g, cs.b, cs.a); - const colorEnd = LJS.rgb(ce.r, ce.g, ce.b, ce.a); - // 6) 构造引擎发射器(27 参,按 d.ts:3232 构造签名逐位映射)。 - const em = new LJS.ParticleEmitter( - worldPos, // pos(世界) - spec.angle != null ? spec.angle : 0, // angle - (spec.emitSize != null ? spec.emitSize : 0) / SCALE, // emitSize 像素→世界 - emitTime, // emitTime(0=forever) - emitRate, // emitRate - spec.coneAngle != null ? spec.coneAngle : Math.PI, // emitConeAngle(半锥角) - undefined, // tileInfo: untextured(2D fillRect 路) - colorStart, colorStart, colorEnd, colorEnd, // colorStartA/B + colorEndA/B(A=B → 固定色不随机) - spec.particleTime != null ? spec.particleTime : 0.5, // particleTime - (spec.sizeStart != null ? spec.sizeStart : 6) / SCALE, // sizeStart 像素→世界 - (spec.sizeEnd != null ? spec.sizeEnd : 0) / SCALE, // sizeEnd 像素→世界 - engSpeed, // speed(世界/帧@60) - 0, // angleSpeed: 0(无玩法语义,不旋转) - engDamping, // damping - 1, // angleDamping: 1(无阻) - spec.gravityScale != null ? spec.gravityScale : 0, // gravityScale(host 未 setGravity,全局 gravity=0 故暂不生效,OQ-4 follow-up) - Math.PI, // particleConeAngle(粒子自转锥,留默认) - 0.1, // fadeRate(默认) - 0, // randomness: 0(spec 已显式给参,关引擎默认 .2 额外抖动求确定) - false, // collideTiles: false - spec.additive != null ? spec.additive : false, // additive - true // randomColorLinear(A=B 时无影响) - ); - // 7) 封装句柄(禁泄漏引擎对象;isActive/stop 转发,stop 幂等销毁)。 - return { - isActive() { return em.isActive(); }, // esm.js:8482 - stop() { if (!em.destroyed) em.destroy(true); }, // 幂等(已 destroyed 直返,不二次销毁) - }; - }, - }, - // ── audio.synth(A3 填体)──薄包装引擎 zzfxG/zzfxM:参数包/曲谱 → PCM 样本(不播放)。 - // 边界模型(创始人 2026-06-13 拍):引擎有 zzfxG/zzfxM → 此处薄包装;插件侧删 vendored 合成核,一职一路。 - // 引擎 zzfxG **不烤主音量 ×0.3**(样本=s*volume,esm.js:7391;×0.3 在引擎 gain 层 soundVolume=.3,esm.js:3121/6777) - // → 返回的样本是「未乘 0.3 的 PCM」,与 api.d.ts EngineAudioSynth 口径一致;×0.3 由插件播放层补(audio-music/impl.js playChannels)。 - // 合成异常/失败返 null(插件侧 → 发声 no-op,不回退 vendored)。 - audio: { - synth: { - /** - * ZzFX 单音效合成核:参数包 → PCM 单声道样本数组(未乘 0.3,不播放)。薄包装引擎 LJS.zzfxG。 - * @param {number[]} params ZzFX 参数包 - * @returns {number[]|null} PCM 单声道样本;合成异常返 null - */ - synthSfx: (params) => { - rec('audio.synth.synthSfx'); // 真接线门②:记一次「插件经 ctx.getEngine().audio.synth 真调引擎 zzfxG」 - try { - // 引擎 zzfxG 收 positional 参数(与 vendored 同签名,esm.js:7326)→ 展开参数包。 - return LJS.zzfxG(...params); - } catch (e) { - // 合成异常降级为 null(不连坐游戏,记 stderr);插件侧据 null 走发声 no-op。 - console.error('[host][engine-audio] zzfxG 合成异常,返 null:' + (e && e.message ? e.message : e)); - return null; - } - }, - /** - * ZzFXM 曲谱合成核:乐器/模式/序列/BPM → [L,R] 双声道样本(未乘 0.3,不播放)。薄包装引擎 LJS.zzfxM。 - * @param {number[][]} instruments 乐器表 - * @param {number[][][]} patterns 模式表 - * @param {number[]} sequence 序列 - * @param {number} [bpm] 节拍(缺省引擎默 125) - * @returns {number[][]|null} [L,R] 双声道样本;合成异常返 null - */ - synthSong: (instruments, patterns, sequence, bpm) => { - rec('audio.synth.synthSong'); // 真接线门②:记一次「插件经 ctx.getEngine().audio.synth 真调引擎 zzfxM」 - try { - // 引擎 zzfxM 签名与 vendored 逐字一致(esm.js:10837 `zzfxM(instruments, patterns, sequence, BPM=125)`)。 - return LJS.zzfxM(instruments, patterns, sequence, bpm); - } catch (e) { - console.error('[host][engine-audio] zzfxM 合成异常,返 null:' + (e && e.message ? e.message : e)); - return null; - } - }, - }, - }, - }; - } + // 【P1 提升】引擎能力背书工厂 makeEngineCaps 已从此处闭包提升为可 import 模块 host-dev/engine-caps.js + // 的 createEngineCaps({LJS, engineMath, engineMode, recHook})(搬运·语义零变;host-dev / ref real 通道共用)。 + // buildBundle 处以闭包 makeEngineCaps 桥接(保 engineFactory 注入口径不变 + host-dev 探针 rec 经 recHook 注入)。 + // 逐行保语义回归基线=门0 8/8 + real 门 6/6(提升不得改任何判据数值)。 // 创建 host-dev 受控上下文:注入「渲染面 / 主种子 / 音频工厂」。 // 【A0 改点】受控面 getContext2d 的 backing:real 模式=引擎 engineInit 自建的 mainContext(纯 2D, @@ -356,7 +229,15 @@ export function bootHostDev(opts) { seed, audioFactory, clock: () => mockNowMs, - engineFactory: makeEngineCaps, // 【A1】注入引擎能力工厂;工厂内按 engineMode 短路(stub→null,保 null-engine 回归) + // 【P1 提升】注入引擎能力工厂;工厂内按 engineMode 短路(stub→null,保 null-engine 回归)。 + // host-dev 仍注入 recHook(门② 探针只在 host-dev 取证产物里,host-dev 非渠道产物,符合现状 host.js 原 rec 语义)。 + // recHook 失败静默(取证副作用绝不连坐运行时);createEngineCaps 在 engineMode!=='real' 已 return null,故探针只活 real 通道。 + engineFactory: () => createEngineCaps({ + LJS, + engineMath: buildEngineMath(LJS), + engineMode, + recHook: (id) => { try { (window.__engineCalls || (window.__engineCalls = [])).push(id); } catch (_) { /* 取证副作用,忽略 */ } }, + }), }); // 合法包裹 bundle.deriveContextFor(host 拥有 bundle):注册器为每插件派生上下文时,记下该插件的 // 派生 random 实例引用。这样 host 取证时能复位「插件那一份」随机(见 derivedRandoms 注释)。 diff --git a/game-runtime/src/core/game-host.d.ts b/game-runtime/src/core/game-host.d.ts new file mode 100644 index 00000000..9bd7979d --- /dev/null +++ b/game-runtime/src/core/game-host.d.ts @@ -0,0 +1,59 @@ +// game-runtime/src/core/game-host.d.ts —— 游戏宿主装载契约(第 9 类契约 · additive · game-runtime 内部) +// owner:Runner v2 P1 | 消费方:集成段 host(boot 游戏)+ 生成 agent(产出靶) +// +// 【为什么新立、不折进 api.d.ts】 +// api.d.ts 的 PluginContext 是「插件↔引擎」面(getEngine() 等,api.d.ts:303,已冻结); +// 本契约是「游戏整体↔引擎宿主」面——游戏的 update/render 主体不是插件、不走 PluginContext。 +// 两者正交(边界模型「同一职不留两条并行路」):插件经 getEngine() 拿能力,游戏经本契约挂宿主。 +// 引擎 import 只活集成段 host(Q4 铁律),游戏工厂零引擎 import。 +// 【为什么落 game-runtime/src 而非 contracts/】 +// contracts/ 是跨三仓五工位的业务契约 SSOT(API/DB/SDK/event…,contracts/README.md §1); +// 本契约是 game-runtime 内部「host↔游戏工厂」接线面,消费方只在 game-runtime, +// 故与 api.d.ts 同目录、import 方向顺;落 contracts 会让跨仓 SSOT 反向相对 import ../game-runtime/src(拆仓即断)。 + +import type { PluginContext } from './api.d.ts'; + +/** + * 游戏挂宿主时拿到的 boot 上下文。host 在游戏 init 时一次性注入。 + * - ctx:下层已冻结受控面(PluginContext)。游戏经 ctx.getEngine() 拿插件能力(粒子/音频/数学), + * 经 ctx.getInput() 拿受控输入,经 ctx.random/time 拿受控随机/时钟。**不得绕过 ctx 触碰裸引擎/DOM。** + * - mainContext:引擎 mainContext(setGLEnable(false) 后的纯 2D 绘制面)。游戏 render(g) 的 g 即它; + * 游戏美术画在此面 → 与引擎粒子(引擎自渲落同一 mainContext)同屏。**此字段是装载契约的核心** + * (游戏取引擎绘制面的唯一约定,不借 PluginContext.getContext2d——那是插件面)。 + * - seed:主随机种子(确定性);canvas:可见画布引用(输入 rect 映射用,real=引擎 mainCanvas)。 + */ +export interface GameHostBootContext { + ctx: PluginContext; // 下层已冻结受控面(复用) + mainContext: CanvasRenderingContext2D; // 引擎 mainContext(boot 注入,render 绘制面) + canvas: HTMLCanvasElement; // 可见画布(real=引擎 mainCanvas,输入 rect 映射用) + seed: number; // 主随机种子 +} + +/** + * 游戏实例:host 经引擎五回调驱动的稳定生命周期。 + * 生命周期对齐 engineInit 五回调(host.js:883-887 engGameInit/Update/Render): + * · init ← gameInit(引擎首帧前一次性):建游戏对象、订阅 ctx.getInput()、装资产。 + * · update(dt) ← gameUpdate(引擎每逻辑步,dt=FIXED_DT 恒定):core.update/事件/特效计时。 + * · render(g) ← gameRender(引擎清屏后,g=mainContext):画游戏美术;引擎粒子已自渲落同一 g。 + * · onInput(ev)(可选):归一化输入事件回调。**注**:ref 经 ctx.getInput().on 订阅即可, + * onInput 是「不便用 ctx.getInput 的极简游戏」的备选入口;二者择一,ref 用前者。 + * · destroy():卸载卫生(P2 装载多游戏复位用;P1 ref 单游戏可空实现但须声明)。 + */ +export interface GameInstance { + init(boot: GameHostBootContext): void | Promise; // 异步:装资产(ref loadAssets 异步) + update(dt: number): void; // 逻辑步(dt 秒,引擎恒定步长) + render(g: CanvasRenderingContext2D): void; // g=mainContext(boot 注入的同一面) + onInput?(ev: { type: string; x?: number; y?: number; key?: string }): void; // 可选输入入口 + destroy(): void; // 卸载(幂等) +} + +/** + * 游戏工厂:纯模块导出(零引擎 import)。host 调它建 GameInstance。 + * 生成 agent 的唯一产出靶 = 实现并默认导出一个 GameHostFactory。 + * 【硬约束·§12-A3】必须是「独立、可被 P3 生成 agent 复制的工厂模块」: + * · 独立文件默认导出(ref=games/wanglanmei-ref/src/game-shop.js 的 `export default createShopGame`); + * · 零引擎 import(引擎能力一律经 boot.ctx.getEngine()); + * · 禁在 main.js 内用内联闭包「形似实现」糊过——否则 P3 缺真实参照实现,违 P1「spike 装载契约到可被 P3 复制」目标。 + * @param opts 工厂选项(无玩法语义;预留 difficulty/variant 等 additive 扩展位)。 + */ +export type GameHostFactory = (opts?: Record) => GameInstance;