From 6f854851f5cff419fa4abf3ad1b7a35be3e7b586 Mon Sep 17 00:00:00 2001 From: zizi Date: Fri, 12 Jun 2026 11:28:52 +0000 Subject: [PATCH] =?UTF-8?q?feat(game-runtime):=20core-protocol-v0=20?= =?UTF-8?q?=E5=86=BB=E7=BB=93(Gate0.1=20=E5=8F=97=E6=8E=A7=E9=9D=A2?= =?UTF-8?q?=E8=A1=A5=E4=B8=81,38/38=20=E7=BB=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 受控面 v0 六项冻结:getContext2d/onFrame/getInput/getAudioContext/time/random - getInput=归一化事件订阅面(5枚举,host注入桥+_emit测试口,dispose自动清理防泄漏);getAudioContext=lazy单例无工厂返null告警降级 - per-plugin 派生随机流(FNV-1a子种子,序列互不影响同seed复现,向后兼容回退);useContext 守卫(友好错误带插件名) - 向后兼容增量不bump版本;manifest schema 未动;三 lane 按此冻结面开工 Co-Authored-By: Claude Fable 5 --- game-runtime/README.md | 11 +- game-runtime/REPORT.md | 43 ++ game-runtime/docs/PLUGIN-TEMPLATE.md | 2 +- game-runtime/src/core/api.d.ts | 135 ++++- game-runtime/src/core/plugin.js | 489 ++++++++++++++++-- game-runtime/src/core/plugin.test.mjs | 238 ++++++++- game-runtime/src/plugins/_example/PLUGIN.md | 29 +- game-runtime/src/plugins/_example/api.d.ts | 10 +- game-runtime/src/plugins/_example/impl.js | 32 +- .../plugins/_example/test/example.test.mjs | 75 ++- game-runtime/test/RESULT.txt | 124 +++-- 11 files changed, 1059 insertions(+), 129 deletions(-) diff --git a/game-runtime/README.md b/game-runtime/README.md index dec776d9..f95dd2b8 100644 --- a/game-runtime/README.md +++ b/game-runtime/README.md @@ -12,12 +12,13 @@ | 项 | 值 | |---|---| -| 当前协议版本 | **core-protocol-v0** | +| 当前协议版本 | **v0(2026-06-12 冻结,含 Gate0.1 受控面补丁)** | | 标识来源 | `src/core/plugin.js` → `export const CORE_PROTOCOL_VERSION = 'core-protocol-v0'`;`schema/plugin-manifest.schema.json` → `$id .../v0` | -| 冻结状态 | Gate0 评审通过即冻结;冻结后受控面/Plugin 接口/manifest 字段的任何变更 = 新版本号 + 走评审 | +| 冻结状态 | Gate0 评审通过即冻结。Gate0.1 补丁(受控面由 4 扩 6:+getInput/+getAudioContext;random 改 per-plugin 派生流;useContext 守卫)为**向后兼容增量**,随 v0 一并冻结,不 bump 版本号 | | 升级规则 | 受控面只增不破坏既有 API;破坏性变更须 bump 版本(v0→v1)并在本表登记 | > **扩展受控面 / 改 manifest 字段 = 改协议**,必须走 Gate0 后的扩展申请,不得 lane 私自加。 +> **受控面 v0 最终成员(冻结口径,6 项)**:`getContext2d` / `onFrame` / `getInput` / `getAudioContext` / `time` / `random`。 --- @@ -28,16 +29,16 @@ game-runtime/ ├── README.md # 本文件:目录 + 证据约定 + 协议版本声明位 ├── src/ │ ├── core/ # 协议核心(core-protocol-v0,已冻结对象) -│ │ ├── plugin.js # Plugin 接口 / PluginRegistry 生命周期 / PluginContext 受控面 + host-dev 桩 +│ │ ├── plugin.js # Plugin 接口 / PluginRegistry 生命周期 / PluginContext 受控面(6 项) + host-dev 桩(含输入桥) │ │ ├── api.d.ts # 手写类型门面(lane 读契约的一等入口,与 plugin.js JSDoc 同步) -│ │ └── plugin.test.mjs # 核心零依赖单测(17 测,node --test) +│ │ └── plugin.test.mjs # 核心零依赖单测(31 测,node --test) │ └── plugins/ │ └── _example/ # 空插件样例(五件全,可直接克隆为新插件) │ ├── impl.js # ESM 实现(演示受控面用法,无玩法语义) │ ├── api.d.ts # 该插件类型门面 │ ├── manifest.json # 插件机器契约(过 schema 校验) │ ├── PLUGIN.md # 给 agent 读的能力文档(PLUGIN-TEMPLATE 活样板) -│ └── test/example.test.mjs # 该插件零依赖单测(5 测) +│ └── test/example.test.mjs # 该插件零依赖单测(7 测) ├── schema/ │ └── plugin-manifest.schema.json # manifest.json 契约(additionalProperties:false) ├── docs/ diff --git a/game-runtime/REPORT.md b/game-runtime/REPORT.md index 394ce5b6..0ea1a2b6 100644 --- a/game-runtime/REPORT.md +++ b/game-runtime/REPORT.md @@ -105,3 +105,46 @@ spec §2/§5 钉死:vfx 渲染哈希、audio 发声证据**必须**集成段 | harness 纯计算口径 | 临时自检(哈希/非空/几何/占位/路径) | 通过 | **未在本机跑(按纪律留集成段)**:esbuild 打包(build.mjs/size.mjs 打包段)、CDP 浏览器真跑(harness 4 占位)—— 均仅 mini-desktop 跑,本机无 esbuild、禁 headless Chrome。 + +--- + +## 7. Gate0.1 受控面补丁(主会话 Gate0 终审三裁定落地,2026-06-12) + +> 主会话 Gate0 终审在 §5 三重点基础上拍板三项裁定,本节记「改了什么 + 三裁定如何落的 + 自验」。 +> 定性:**向后兼容增量**(既有 22 测零改判全绿),随 `core-protocol-v0` 一并冻结,**不 bump 版本号**。 + +### 7.1 改动文件清单 + +| 文件 | 改动 | +|---|---| +| `src/core/plugin.js` | 受控面扩 `getInput`/`getAudioContext`;新增 `HostDevInputBridge`(输入桥,带 `_emit`/`disposeScope`);`createHostDevContext` 增 `audioFactory` 选项 + `inputBridge`/`deriveContextFor` 出口 + 非枚举派生钩子;`PluginRegistry` 增 per-plugin 上下文派生(`_contextFor`)、`disposeAll` 协作回收输入订阅、`useContext`/`initAll` 友好守卫;新增 `deriveSeed`(FNV-1a 子种子派生);导出 `HostDevInputBridge`/`deriveSeed`/`INPUT_EVENT_TYPE_LIST` | +| `src/core/api.d.ts` | 新增 `InputEventType`/`InputEvent`/`InputSubscription`/`InputSource` 类型;`PluginContext` 增两方法 + per-plugin random 说明;`HostDevContextOptions` 增 `audioFactory`;`HostDevContextBundle` 增 `inputBridge`/`deriveContextFor`;新增 `HostDevInputBridge`/`deriveSeed`/`INPUT_EVENT_TYPE_LIST` 声明 | +| `src/core/plugin.test.mjs` | 新增 §7(受控面两项:输入订阅/退订/归一化/时间戳/dispose 自动清理/非法类型/handler 隔离/音频降级+lazy 单例)、§8(per-plugin 派生随机:独立性/复现/deriveSeed 确定性)、§9(useContext 三守卫)——计 14 新测 | +| `src/plugins/_example/{impl.js,api.d.ts,PLUGIN.md,test/example.test.mjs}` | 样板演示新两面:init 订阅 `pointerdown`(句柄留 dispose 注销)+ lazy 取音频(容忍 null);probe 增 `inputEvents`/`hasAudio`;新增 2 例测(输入驱动/音频降级与注入);既有 5 例测注入 mock `audioFactory` 保 RESULT.txt 干净 | +| `README.md` | 协议版本声明位填「v0(2026-06-12 冻结,含 Gate0.1 受控面补丁)」+ 受控面 6 项冻结口径;测数 17→31 / 5→7 | + +### 7.2 三裁定如何落的 + +**裁定一·受控面 v0 扩两项(具名消费者 P4/P10/P6)** +- `getInput()` → 返回 `InputSource`(`on/off`,5 枚举 `pointerdown/pointermove/pointerup/keydown/keyup`);事件对象 = 归一化 `{type,x,y,key,tMs}`,**时间戳取自受控时间源**(取证可复现,禁原生 `event.timeStamp`)。host 注入事件桥 `HostDevInputBridge`,测试口 `_emit(type,payload)`。**dispose 自动清理**:注册器为每插件派生上下文时给该插件 `scope`,`disposeAll` 中**无条件**对每插件调 `inputBridge.disposeScope(name)` 回收其订阅(对「无 dispose 方法但订阅过输入」的插件同样生效,且插件 dispose 抛错也仍回收,防泄漏)。 +- `getAudioContext()` → lazy:host 注入工厂,首调即创建、后续返回同一实例(全 host 单例);**无工厂返回 null 并 `console.warn` 仅一次(绝不抛错)**——P6 容错降级。工厂抛错亦降级为 null(不连坐插件)。 + +**裁定二·per-plugin 派生随机流** +- `context` 改为每插件实例化:共享 canvas/帧/时间源/事件桥/音频,**random 独立**——`deriveSeed(mainSeed, pluginName)` 用 **FNV-1a 32-bit**(与全发行版证据哈希族同口径)派生子种子,注册器 `initAll` 时为每插件 `deriveContextFor(name)`。根治 §5 重点二的「共享 random 互推」隐患。 +- 实现接缝:host-dev `context` 上挂**非枚举** Symbol 钩子指回 bundle,注册器据此把 plain `context` 升级为每插件派生上下文;非枚举 → 不污染受控面、不影响既有 `deepEqual`/形状断言。**兼容回退**:手搓无钩子的 plain context 仍可用(退化为共享上下文,不参与 per-plugin 随机隔离)——既有「直接 `plugin.init(context)`」用法零改。 + +**裁定三·useContext 守卫** +- 忘调 `useContext` 即 `initAll` → 抛含修复提示的友好错误(「未注入受控上下文:请在 initAll() 之前调用 useContext(context)」+ host-dev 用法示例),而非插件内裸 `TypeError`。 +- `useContext` 传 null/非对象 → 友好抛错;`initAll` 之后再 `useContext` → 抛错并点出当前阶段。 + +### 7.3 自验汇总(Gate0.1) + +| 验证项 | 命令 | 结果 | +|---|---|---| +| core 31 测(17 旧 + 14 新) | `node --test src/core/plugin.test.mjs` | 31/31 | +| _example 7 测(5 旧 + 2 新) | `node --test src/plugins/_example/test/example.test.mjs` | 7/7 | +| 全量遍历 | `bash scripts/test.sh` | **38/38**(原始输出见 `test/RESULT.txt`) | +| 既有 22 测向后兼容 | 同上(未改一条旧断言) | 全绿 | +| manifest 正/反向校验 | `node scripts/validate-manifest.mjs`(manifest 未改) | 通过 / 逮 6 违规 | + +**受控面 v0 最终成员(冻结口径,6 项)**:`getContext2d` / `onFrame` / `getInput` / `getAudioContext` / `time` / `random`。 diff --git a/game-runtime/docs/PLUGIN-TEMPLATE.md b/game-runtime/docs/PLUGIN-TEMPLATE.md index 6fac81b1..22ccecc2 100644 --- a/game-runtime/docs/PLUGIN-TEMPLATE.md +++ b/game-runtime/docs/PLUGIN-TEMPLATE.md @@ -23,7 +23,7 @@ ### 2. 集成点(Integration) 插件如何接入宿主与受控面。必须覆盖: - **依赖**:`dependencies` 声明了哪些其它插件(无则写「无」); -- **受控面用法**:用到 `PluginContext` 的哪几项(`getContext2d`/`onFrame`/`time`/`random`),各自用途; +- **受控面用法**:用到 `PluginContext` 的哪几项(`getContext2d`/`onFrame`/`getInput`/`getAudioContext`/`time`/`random`;Gate0.1 由 4 扩 6),各自用途; - **注册方式**:`registry.register(createXxxPlugin(opts))` 的标准接法; - **配置项**:工厂 `opts` 的字段与含义(无则写「无配置」)。 diff --git a/game-runtime/src/core/api.d.ts b/game-runtime/src/core/api.d.ts index 05f6830f..063a8fd2 100644 --- a/game-runtime/src/core/api.d.ts +++ b/game-runtime/src/core/api.d.ts @@ -8,8 +8,15 @@ * 等价于 channel-probe 的「字段级定义」。本文件与 plugin.js 的 JSDoc 必须同形同步。 * * 【受控面铁律】 - * PluginContext 是插件能从引擎拿到的**全部**能力,仅 4 项(canvas/帧回调/时间/随机)。 - * 绝不在此暴露 littlejsengine 裸类型——保「引擎可换」。扩展受控面需走 Gate0 后的扩展申请。 + * PluginContext 是插件能从引擎拿到的**全部**能力,仅 6 项(Gate0.1 由 4 扩 6): + * canvas / 帧回调 / 输入订阅 / 音频上下文 / 时间 / 随机。 + * 绝不在此暴露 littlejsengine 裸类型 / 原生 DOM 事件 / 裸 AudioContext——保「引擎可换」。 + * 扩展受控面需走 Gate0 后的扩展申请。 + * + * 【Gate0.1 受控面补丁(2026-06-12 冻结)】 + * - 新增 getInput():受控输入事件订阅面(具名消费者 P4 输入缓冲 / P10 t_input_bound); + * - 新增 getAudioContext():lazy 受控音频上下文(具名消费者 P6 音乐),无工厂返回 null 且不抛错; + * - random 改 per-plugin 派生流:注册器为每插件派生独立子随机源(主 seed + 插件名 FNV-1a 子种子)。 */ /* ────────────────────────────────────────────────────────────────────────── @@ -40,12 +47,55 @@ export interface FrameHandle { } /* ────────────────────────────────────────────────────────────────────────── - * PluginContext —— 受控引擎能力面(最小 4 项,YAGNI) + * 受控输入(Gate0.1)—— 具名消费者 P4 输入缓冲 / P10 t_input_bound + * ────────────────────────────────────────────────────────────────────────── */ + +/** 受控输入事件类型枚举(仅 5 类:指针三态 + 键盘两态;不直透原生 DOM 事件名空间)。 */ +export type InputEventType = + | 'pointerdown' + | 'pointermove' + | 'pointerup' + | 'keydown' + | 'keyup'; + +/** 归一化输入事件对象:插件只见归一化字段,不见原生 DOM Event(保受控)。 */ +export interface InputEvent { + /** 事件类型(5 枚举之一)。 */ + type: InputEventType; + /** 归一化指针横坐标(指针类事件有意义;键盘事件为 0)。 */ + x: number; + /** 归一化指针纵坐标(同上)。 */ + y: number; + /** 归一化键码(键盘类事件有意义,如 'ArrowLeft'/'Space';指针事件为空串)。 */ + key: string; + /** 事件时间戳(毫秒),来自**受控时间源**(取证可复现,禁用原生 event.timeStamp)。 */ + tMs: number; +} + +/** 输入订阅句柄:cancel() 注销该订阅(与 FrameHandle 同构)。 */ +export interface InputSubscription { + cancel(): void; +} + +/** + * 受控输入事件订阅面:插件经 on/off 订阅归一化输入事件。 + * 插件经此面注册的订阅,在插件 dispose 时由注册器协作自动清理(防泄漏)。 + */ +export interface InputSource { + /** 订阅某类输入事件;返回 InputSubscription,cancel() 注销。 */ + on(type: InputEventType, handler: (e: InputEvent) => void): InputSubscription; + /** 退订:移除该 type 上等值的 handler(移除第一处匹配;未订阅过则静默无操作)。 */ + off(type: InputEventType, handler: (e: InputEvent) => void): void; +} + +/* ────────────────────────────────────────────────────────────────────────── + * PluginContext —— 受控引擎能力面(最小 6 项,YAGNI;Gate0.1 由 4 扩 6) * ────────────────────────────────────────────────────────────────────────── */ /** * 受控上下文:插件能从引擎拿到的全部能力。 - * 设计铁律:绝不直透 littlejsengine 裸对象 / 全局 canvas / 原生 RAF;一切经此面,引擎可换。 + * 设计铁律:绝不直透 littlejsengine 裸对象 / 全局 canvas / 原生 RAF / 原生 DOM 事件 / 裸 AudioContext; + * 一切经此面,引擎可换。 */ export interface PluginContext { /** @@ -59,9 +109,24 @@ export interface PluginContext { * @returns 句柄,cancel() 注销。 */ onFrame(cb: (dtSeconds: number, frame: number) => void): FrameHandle; + /** + * 获取受控输入事件订阅面(Gate0.1;具名消费者 P4 输入缓冲 / P10 t_input_bound)。 + * 经 on/off 订阅归一化输入事件;事件由 host 事件桥分发,时间戳来自受控时间源。 + * 插件**禁止**直接 addEventListener。经此面注册的订阅在插件 dispose 时由注册器协作自动清理(防泄漏)。 + */ + getInput(): InputSource; + /** + * lazy 获取受控音频上下文(Gate0.1;具名消费者 P6 音乐)。host 注入工厂;首次调用即创建,后续返回同一实例。 + * **无工厂时返回 null 并 console 告警一次(不抛错)**——P6 须容错降级。集成段=真实 Web Audio AudioContext。 + */ + getAudioContext(): AudioContext | null; /** 受控时间源。 */ time: TimeSource; - /** 受控随机源(可注种子)。 */ + /** + * 受控随机源(可注种子)。 + * **Gate0.1 起为 per-plugin 派生流**:注册器 init 时给每插件派生独立子随机源 + * (主 seed + 插件名 FNV-1a 子种子),插件 A 取随机不再推进插件 B 的序列。 + */ random: RandomSource; } @@ -127,11 +192,18 @@ export declare class PluginRegistry { get(name: string): Plugin | undefined; /** 已注册插件 name(注册顺序)。 */ list(): string[]; - /** 绑定受控上下文(host 注入),必须在 initAll 之前。 */ + /** + * 绑定受控上下文(host 注入),必须在 initAll 之前。 + * Gate0.1:传入 host-dev context(带派生钩子)时,注册器 init 会为每插件派生独立上下文 + * (random 独立 + 输入订阅 scope 化)。传 null/非对象 → 友好抛错(含修复提示)。 + */ useContext(context: PluginContext): this; - /** 依次 init 全部插件(正序,错误隔离,依赖声明校验)。重复调用幂等。 */ + /** + * 依次 init 全部插件(正序,错误隔离,依赖声明校验)。重复调用幂等。 + * Gate0.1:未先调 useContext 即 initAll → 友好抛错(含插件用法提示),而非裸 TypeError。 + */ initAll(): InitResult; - /** 释放全部已 init 插件(逆序,幂等,错误隔离)。 */ + /** 释放全部已 init 插件(逆序,幂等,错误隔离)。Gate0.1:每插件 dispose 后自动回收其输入订阅(防泄漏)。 */ disposeAll(): DisposeResult; } @@ -145,18 +217,33 @@ export interface HostDevContextOptions { context2d?: CanvasRenderingContext2D | null; /** 注入单调时钟(测试用步进时钟可复现)。 */ clock?: () => number; - /** 随机源种子(确定性)。 */ + /** 主随机种子(确定性)。Gate0.1 起每插件由「主 seed + 插件名」FNV-1a 派生独立子种子。 */ seed?: number; + /** + * 受控音频上下文工厂(Gate0.1)。缺省 null → getAudioContext 返回 null 并告警一次(P6 容错降级)。 + * 集成段注入真实工厂(如 () => new AudioContext())。 + */ + audioFactory?: (() => AudioContext) | null; } -/** createHostDevContext 返回:受控上下文 + host 私有 tick 驱动器 + 帧数读取。 */ +/** createHostDevContext 返回:受控上下文 + host 私有 tick 驱动器 + 帧数读取 + 输入桥 + 每插件派生器。 */ export interface HostDevContextBundle { - /** 交给插件的受控上下文。 */ + /** 交给插件的受控上下文(默认 scope,直接用)。 */ context: PluginContext; /** host 私有:推进一帧,驱动所有 onFrame 回调(模拟引擎主循环)。插件不可见。 */ tick(dtSeconds: number): void; /** 当前帧数。 */ frameCount(): number; + /** host 私有:输入事件桥(带 _emit 测试口 / disposeScope 回收)。插件不可见。 */ + inputBridge: HostDevInputBridge; + /** + * host 私有:为某插件派生一份受控上下文(Gate0.1 per-plugin 派生流)。 + * 共享 canvas/帧/时间/输入桥/音频;random 独立(主 seed + 插件名 FNV-1a 子种子)。 + * 注册器在 initAll 时自动调用(lane 一般无需手调)。 + * @param pluginName 插件名(参与子种子派生)。 + * @param scope 输入订阅作用域(缺省用 pluginName 作 scope)。 + */ + deriveContextFor(pluginName: string, scope?: unknown): PluginContext; } /** @@ -164,6 +251,23 @@ export interface HostDevContextBundle { */ export declare function createHostDevContext(opts?: HostDevContextOptions): HostDevContextBundle; +/** + * host-dev 输入事件桥(Gate0.1)—— InputSource 的本机/集成前实现。 + * 给插件暴露受控订阅面(经 bundle.context.getInput),给 host 暴露 _emit 测试口与按 scope 回收。 + * 集成段换「真实 DOM/引擎事件 → 归一化 InputEvent」的包装,插件侧零改。 + */ +export declare class HostDevInputBridge { + constructor(time: TimeSource); + /** 取一个绑定到指定 scope 的受控订阅面(注册器为每插件传该插件 scope)。 */ + sourceFor(scope?: unknown): InputSource; + /** host 测试口:注入一条输入,归一化为 InputEvent 后分发给该 type 的全部订阅者。 */ + _emit(type: InputEventType, payload?: { x?: number; y?: number; key?: string }): void; + /** 注册器协作:撤销某 scope(某插件)注册的全部订阅(插件 dispose 时调,防泄漏)。返回撤销数。 */ + disposeScope(scope: unknown): number; + /** 当前总订阅数(诊断/测试:验证 dispose 后无泄漏)。 */ + _subscriptionCount(): number; +} + /** 确定性随机源(mulberry32),可注种子,跨端一致。 */ export declare class SeededRandom implements RandomSource { constructor(seed?: number); @@ -182,5 +286,14 @@ export declare class HostDevTime implements TimeSource { /** 单调时钟选择器(performance.now > hrtime > Date.now)。 */ export declare function defaultMonotonicClock(): () => number; +/** + * per-plugin 子种子派生(Gate0.1):主种子 + 插件名经 FNV-1a 32-bit 混合得确定子种子。 + * 性质:同(主种子,插件名)→ 同子种子;不同插件名 → 大概率不同子种子(互不推演)。导出便于单测断言。 + */ +export declare function deriveSeed(mainSeed: number, pluginName: string): number; + +/** 受控输入事件类型枚举(冻结口径,便于 lane/host 引用与校验)。 */ +export declare const INPUT_EVENT_TYPE_LIST: ReadonlyArray; + /** 协议版本标识(冻结时 = 'core-protocol-v0')。 */ export declare const CORE_PROTOCOL_VERSION: string; diff --git a/game-runtime/src/core/plugin.js b/game-runtime/src/core/plugin.js index b232816c..a1ac00b1 100644 --- a/game-runtime/src/core/plugin.js +++ b/game-runtime/src/core/plugin.js @@ -18,12 +18,22 @@ * 因此本协议只提供「能力编排 + 受控引擎句柄」,绝不内置任何玩法假设。 * * 【三条核心协议取舍(详见 REPORT.md)】 - * 1. 受控面宁小勿大(YAGNI):PluginContext 只暴露 canvas/帧回调/时间源/随机源 4 项最小面, - * 禁直透 littlejsengine 裸对象——保「引擎可换」。其余 lane 按需走扩展申请,不在 Gate0 预扩。 + * 1. 受控面宁小勿大(YAGNI):PluginContext 只暴露最小受控面,禁直透 littlejsengine 裸对象—— + * 保「引擎可换」。Gate0.1 据具名消费者(P4 输入缓冲 / P10 t_input_bound / P6 音乐)扩两项: + * getInput(受控事件订阅面)/ getAudioContext(lazy 音频上下文,容错降级);其余仍按需走扩展申请。 * 2. 错误隔离不连坐:单插件 init 抛错只隔离该插件(记 errors[]),其余已注册插件照常 init, * 整个注册流程不因一个插件崩溃而中断——取证/运行不被单点故障带崩。 * 3. 生命周期严格有序 + dispose 幂等:init 按注册顺序正序、dispose 按注册逆序(后进先出, * 符合资源依赖释放直觉);dispose 可重复调用无副作用(幂等),防重复释放。 + * + * 【Gate0.1 受控面补丁(主会话 Gate0 终审三裁定,2026-06-12)】 + * - 受控面 v0 扩两项:getInput()(受控事件订阅,由 host 注入事件桥;插件 dispose 时其订阅 + * 自动清理,注册器协作防泄漏)、getAudioContext()(lazy 获取,host 注入工厂;无工厂返回 + * null 并告警一次,绝不抛错——P6 须容错降级)。 + * - per-plugin 派生随机流:context 改为每插件实例化——共享 canvas/时间源/事件桥/音频,但 + * random 独立(主 seed + 插件名 FNV-1a 派生子 seed),根治「共享序列互推」的确定性隐患。 + * - useContext 守卫:插件忘调 useContext / init 前调用,给出含插件名与修复提示的友好错误, + * 而非裸 TypeError。 */ 'use strict'; @@ -57,9 +67,9 @@ /** * @typedef {Object} PluginContext - * 受控上下文 —— 插件能从引擎拿到的**全部**能力,仅此 4 项(最小受控面,YAGNI)。 - * 设计铁律:**绝不**把 littlejsengine 裸对象、全局 canvas、原生 RAF 直接交给插件, - * 一切经此受控面,从而「引擎可换」(换引擎只需换 host 实现,插件零改)。 + * 受控上下文 —— 插件能从引擎拿到的**全部**能力,仅此 6 项(最小受控面,YAGNI;Gate0.1 由 4 扩 6)。 + * 设计铁律:**绝不**把 littlejsengine 裸对象、全局 canvas、原生 RAF、原生 DOM 事件、裸 AudioContext + * 直接交给插件,一切经此受控面,从而「引擎可换」(换引擎只需换 host 实现,插件零改)。 * * @property {() => CanvasRenderingContext2D | null} getContext2d * 获取受控 2D 画布上下文。host 决定底层 canvas 来源(集成段=LittleJS overlay canvas; @@ -68,12 +78,56 @@ * 注册逐帧回调。host 用引擎主循环驱动(集成段=LittleJS gameUpdate;host-dev 桩=手动 tick)。 * 回调入参:dtSeconds=本帧时间步(秒,已做封顶钳制由 host 负责)、frame=单调帧序号(从 1 起)。 * 返回 FrameHandle,cancel() 注销。**插件禁止直接 requestAnimationFrame**。 + * @property {() => InputSource} getInput + * 获取受控输入事件订阅面(Gate0.1 新增,具名消费者 P4 输入缓冲 / P10 t_input_bound)。 + * 返回 InputSource,经 on/off 订阅归一化输入事件;事件由 host 注入的事件桥分发, + * 时间戳来自受控时间源。**插件禁止直接 addEventListener**。插件经此面注册的订阅,在该插件 + * dispose 时由注册器协作自动清理(防泄漏)。 + * @property {() => (AudioContext | null)} getAudioContext + * lazy 获取受控音频上下文(Gate0.1 新增,具名消费者 P6 音乐)。host 注入工厂;首次调用即创建, + * 后续返回同一实例。**无工厂时返回 null 并 console 告警一次(不抛错)**——P6 须容错降级。 + * 集成段=真实 Web Audio AudioContext;host-dev/node 侧通常无工厂 → null。 * @property {TimeSource} time * 受控时间源。插件取「现在」「自启动经过」都走它,不读 Date.now/performance.now, * 从而支持集成段的时间 mock(取证可复现)。 * @property {RandomSource} random * 受控随机源(可注种子)。插件需要随机走它,不读 Math.random,从而确定性可复现 * (取证哈希、fuzz 测试都依赖此)。 + * **Gate0.1 起为 per-plugin 派生流**:注册器 init 时给每个插件派生独立子随机源 + * (主 seed + 插件名 FNV-1a 派生子 seed),插件 A 取随机不再推进插件 B 的序列。 + */ + +/** + * @typedef {'pointerdown'|'pointermove'|'pointerup'|'keydown'|'keyup'} InputEventType + * 受控输入事件类型枚举(Gate0.1)。仅此 5 类——指针三态 + 键盘两态,覆盖 P4 输入缓冲/coyote + * 与 P10 输入边界探测所需,不直透原生 DOM 事件名空间(保「引擎/宿主可换」)。 + */ + +/** + * @typedef {Object} InputEvent + * 归一化输入事件对象(Gate0.1)。host 把原生事件归一化为本形状后经事件桥分发, + * 插件只见归一化字段,不见原生 DOM Event(保受控)。 + * @property {InputEventType} type 事件类型(5 枚举之一)。 + * @property {number} x 归一化指针横坐标(指针类事件有意义;键盘事件为 0)。host 约定坐标系(通常画布逻辑像素)。 + * @property {number} y 归一化指针纵坐标(同上)。 + * @property {string} key 归一化键码(键盘类事件有意义,如 'ArrowLeft'/'Space';指针事件为空串)。 + * @property {number} tMs 事件时间戳(毫秒),来自**受控时间源**(取证可复现,禁用原生 event.timeStamp)。 + */ + +/** + * @typedef {Object} InputSubscription + * 输入订阅句柄;调用 cancel() 即注销该订阅(与 FrameHandle 同构,便于插件精确撤销)。 + * @property {() => void} cancel 注销本订阅。 + */ + +/** + * @typedef {Object} InputSource + * 受控输入事件订阅面(Gate0.1)。插件经 on/off 订阅归一化输入事件;host 经事件桥 _emit 分发。 + * 插件经此面注册的订阅,在插件 dispose 时由注册器协作自动清理(防泄漏)。 + * @property {(type: InputEventType, handler: (e: InputEvent) => void) => InputSubscription} on + * 订阅某类输入事件;返回 InputSubscription,cancel() 注销。同一 handler 重复订阅同一 type 会注册多次(与 DOM 语义异,调用方自律)。 + * @property {(type: InputEventType, handler: (e: InputEvent) => void) => void} off + * 退订:移除该 type 上等值的 handler(移除第一处匹配)。handler 未订阅过则静默无操作。 */ /** @@ -164,6 +218,152 @@ class HostDevTime { } } +/** + * host-dev 输入事件桥(Gate0.1)——InputSource 的本机/集成前实现。 + * + * 职责: + * - 给插件暴露受控订阅面 on/off(只见归一化 InputEvent,不见原生 DOM Event); + * - 给 host 暴露测试口 _emit(type, payload):把一条输入归一化为 InputEvent 后分发给订阅者; + * - 单个 handler 抛错被隔离(记 stderr,不连坐同类型其它 handler),与注册器错误隔离哲学一致; + * - 支持「按插件作用域」回收订阅:注册器为每插件派生上下文时给一个独立 scope,插件 dispose + * 时注册器调 disposeScope(scope) 撤销该插件全部订阅(防泄漏,注册器协作)。 + * + * 集成段把本桥替换为「真实 DOM/引擎事件 → 归一化 InputEvent」的包装,插件侧零改(受控面同形)。 + * + * 设计:订阅表 = type → 该 type 下的订阅项数组;每订阅项记 { handler, scope }, + * scope 用于按插件回收(scope=null 表示「直接用 context、未经注册器派生」的订阅,由 bundle 兜底清理)。 + */ +class HostDevInputBridge { + /** + * @param {TimeSource} time 受控时间源(事件时间戳取自它,保取证可复现,禁用原生 event.timeStamp)。 + */ + constructor(time) { + this._time = time; + // 订阅表:type → Array<{ handler, scope }> + /** @type {Mapvoid, scope:any}>>} */ + this._subs = new Map(); + } + + /** + * 取一个绑定到指定 scope 的受控订阅面(InputSource)。 + * 注册器为每插件派生上下文时传该插件的 scope(用于 dispose 时定向回收); + * 直接用 context(未经注册器)的调用方 scope=null,订阅由 bundle 兜底清理。 + * @param {any} [scope] 作用域标识(通常 = 插件对象或其 name)。 + * @returns {InputSource} + */ + sourceFor(scope) { + const self = this; + const boundScope = scope == null ? null : scope; + return { + on(type, handler) { + self._assertType(type); + if (typeof handler !== 'function') { + throw new Error('[HostDevInputBridge] on 的 handler 必须是函数'); + } + const arr = self._subs.get(type) || []; + const entry = { handler, scope: boundScope }; + arr.push(entry); + self._subs.set(type, arr); + // 返回订阅句柄:cancel 精确移除本订阅项(按引用,不误删同 handler 的其它订阅)。 + return { + cancel() { + const cur = self._subs.get(type); + if (!cur) return; + const idx = cur.indexOf(entry); + if (idx >= 0) cur.splice(idx, 1); + }, + }; + }, + off(type, handler) { + self._assertType(type); + const cur = self._subs.get(type); + if (!cur) return; + // 退订:移除第一处「同 scope 且 handler 等值」的订阅(与 DOM 语义近似:移除等值监听)。 + const idx = cur.findIndex((e) => e.handler === handler && e.scope === boundScope); + if (idx >= 0) cur.splice(idx, 1); + }, + }; + } + + /** + * host 测试口:注入一条输入,归一化为 InputEvent 后分发给该 type 的全部订阅者。 + * 单个 handler 抛错被隔离(记 stderr,不连坐其它 handler)。 + * @param {InputEventType} type 事件类型(5 枚举之一)。 + * @param {{x?:number, y?:number, key?:string}} [payload] 载荷(指针给 x/y,键盘给 key)。 + */ + _emit(type, payload) { + this._assertType(type); + const p = payload || {}; + /** @type {InputEvent} */ + const evt = { + type, + x: typeof p.x === 'number' ? p.x : 0, + y: typeof p.y === 'number' ? p.y : 0, + key: typeof p.key === 'string' ? p.key : '', + // 时间戳取自受控时间源(取证可复现),不用原生 event.timeStamp。 + tMs: this._time.nowMs(), + }; + const cur = this._subs.get(type); + if (!cur || cur.length === 0) return; + // 复制快照,避免 handler 内增删订阅导致迭代异常。 + const snapshot = cur.slice(); + for (const entry of snapshot) { + try { + entry.handler(evt); + } catch (e) { + // 输入 handler 抛错隔离:记 stderr,不中断同类型其它 handler(错误隔离哲学一致)。 + logError('[host-dev input] 输入 handler 抛错(已隔离)type=' + type, e); + } + } + } + + /** + * 注册器协作:撤销某 scope(某插件)注册的全部订阅(插件 dispose 时调,防泄漏)。 + * @param {any} scope 作用域标识。 + * @returns {number} 撤销的订阅数(便于断言/诊断)。 + */ + disposeScope(scope) { + if (scope == null) return 0; + let removed = 0; + for (const [type, arr] of this._subs) { + const kept = arr.filter((e) => e.scope !== scope); + removed += arr.length - kept.length; + if (kept.length === 0) this._subs.delete(type); + else this._subs.set(type, kept); + } + return removed; + } + + /** 当前总订阅数(诊断/测试用:验证 dispose 后无泄漏)。 */ + _subscriptionCount() { + let n = 0; + for (const arr of this._subs.values()) n += arr.length; + return n; + } + + /** 校验事件类型合法(5 枚举之一),非法抛错(防 lane 误用裸 DOM 事件名)。 */ + _assertType(type) { + if (!INPUT_EVENT_TYPES.has(type)) { + throw new Error( + '[HostDevInputBridge] 非法输入事件类型:' + String(type) + + '(仅支持 ' + Array.from(INPUT_EVENT_TYPES).join('/') + ')' + ); + } + } +} + +/** + * 受控输入事件类型枚举(与 InputEventType typedef 同步)。冻结口径:指针三态 + 键盘两态。 + * @type {Set} + */ +const INPUT_EVENT_TYPES = new Set([ + 'pointerdown', + 'pointermove', + 'pointerup', + 'keydown', + 'keyup', +]); + /** * 创建一个 host-dev 版 PluginContext(接口 + 桩)。 * 集成段用真实 LittleJS 句柄实现同形上下文替换它;插件侧零改。 @@ -171,42 +371,101 @@ class HostDevTime { * @param {Object} [opts] * @param {CanvasRenderingContext2D|null} [opts.context2d] 注入的 2D 上下文(node 侧通常 null/mock)。 * @param {() => number} [opts.clock] 注入单调时钟(测试用步进时钟可复现)。 - * @param {number} [opts.seed] 随机源种子(确定性)。 - * @returns {{ context: PluginContext, tick: (dtSeconds:number)=>void, frameCount: () => number }} - * 返回受控上下文 + 一个 host 私有的 tick 驱动器(推进所有 onFrame 回调,模拟引擎主循环) - * + 当前帧数读取。tick/frameCount 不属于插件可见面(插件只见 context),是 host 编排用。 + * @param {number} [opts.seed] 主随机种子(确定性)。Gate0.1 起:每插件由「主 seed + 插件名」FNV-1a 派生独立子种子。 + * @param {(() => AudioContext) | null} [opts.audioFactory] 受控音频上下文工厂(Gate0.1)。缺省 null → + * getAudioContext 返回 null 并告警一次(node 侧无 Web Audio,符合 P6 容错降级)。集成段注入真实工厂。 + * @returns {HostDevContextBundle} + * 返回受控上下文(默认 scope,直接用)+ host 私有的 tick 驱动器 + 帧数读取 + 输入桥(带 _emit 测试口) + * + 每插件上下文派生器。tick/frameCount/inputBridge/deriveContextFor 不属于插件可见面(插件只见 context),是 host 编排用。 */ function createHostDevContext(opts) { const o = opts || {}; const time = new HostDevTime(o.clock); - const random = new SeededRandom(o.seed); + // 主随机源:供「直接用 context」(未经注册器派生)的调用方使用,保持 Gate0 既有语义。 + const mainRandom = new SeededRandom(o.seed); + // 主种子原值(用于 per-plugin 派生子种子)。缺省与 SeededRandom 同口径(黄金比例常数)。 + const mainSeed = o.seed == null ? 0x9e3779b9 : o.seed >>> 0; + // 输入事件桥:全 host 共享一座桥(事件源唯一),各插件经 scope 隔离订阅。 + const inputBridge = new HostDevInputBridge(time); + // 音频上下文工厂(可空):lazy 创建,全 host 共享同一实例(一个宿主一个 AudioContext)。 + const audioFactory = typeof o.audioFactory === 'function' ? o.audioFactory : null; + /** @type {AudioContext|null} lazy 创建后的音频上下文缓存(null=尚未创建或无工厂)。 */ + let audioCtx = null; + // 无工厂告警只发一次的闸(防每帧刷屏)。 + let audioWarned = false; + // 帧回调表:用 Map 以便精确按句柄注销(key=自增 id) /** @type {Mapvoid>} */ const frameCbs = new Map(); let cbIdSeq = 0; let frame = 0; - /** @type {PluginContext} */ - const context = { - getContext2d() { - // host-dev 桩:返回注入的上下文;node 侧无 canvas 时为 null(插件须容忍 null,见 PLUGIN-TEMPLATE) - return o.context2d == null ? null : o.context2d; - }, - onFrame(cb) { - // 注册帧回调,返回带 cancel 的句柄 - const id = cbIdSeq++; - frameCbs.set(id, cb); - return { - cancel() { - frameCbs.delete(id); - }, - }; - }, - time, - random, - }; + /** + * lazy 获取受控音频上下文(全 host 共享)。无工厂 → 返回 null 并告警一次(不抛错)。 + * @returns {AudioContext|null} + */ + function getAudioContextShared() { + if (audioCtx) return audioCtx; // 已创建:返回同一实例(lazy 单例)。 + if (!audioFactory) { + // 无工厂:P6 容错降级——返回 null,仅首次告警一次(绝不抛错)。 + if (!audioWarned) { + logWarn('[host-dev audio] 未注入 audioFactory,getAudioContext 返回 null(P6 须容错降级;集成段注入真实工厂)'); + audioWarned = true; + } + return null; + } + try { + audioCtx = audioFactory(); + } catch (e) { + // 工厂抛错也降级为 null(不连坐插件),记 stderr。 + logError('[host-dev audio] audioFactory 抛错,getAudioContext 降级返回 null', e); + audioCtx = null; + } + return audioCtx; + } - return { + /** + * 构造一个受控上下文(共享 canvas/帧/时间/输入桥/音频,random 按入参独立)。 + * @param {any} scope 输入订阅作用域(null=直接用;插件对象=注册器派生,用于 dispose 定向回收)。 + * @param {RandomSource} random 该上下文的受控随机源(直接用=主随机;派生=per-plugin 子随机)。 + * @returns {PluginContext} + */ + function buildContext(scope, random) { + /** @type {PluginContext} */ + const ctx = { + getContext2d() { + // host-dev 桩:返回注入的上下文;node 侧无 canvas 时为 null(插件须容忍 null,见 PLUGIN-TEMPLATE) + return o.context2d == null ? null : o.context2d; + }, + onFrame(cb) { + // 注册帧回调,返回带 cancel 的句柄 + const id = cbIdSeq++; + frameCbs.set(id, cb); + return { + cancel() { + frameCbs.delete(id); + }, + }; + }, + getInput() { + // 受控输入订阅面:绑定本上下文 scope,插件 dispose 时由注册器协作定向回收(防泄漏)。 + return inputBridge.sourceFor(scope); + }, + getAudioContext() { + // lazy 受控音频上下文(共享单例);无工厂返回 null 并告警一次(不抛错)。 + return getAudioContextShared(); + }, + time, + random, + }; + return ctx; + } + + // 默认上下文:scope=null(直接用,未经注册器派生)+ 主随机源。保持 Gate0 既有直接用语义。 + const context = buildContext(null, mainRandom); + + /** @type {HostDevContextBundle} */ + const bundle = { context, /** * host 私有:推进一帧,驱动所有 onFrame 回调(模拟引擎主循环的一次 update)。 @@ -230,9 +489,44 @@ function createHostDevContext(opts) { frameCount() { return frame; }, + // host 私有:输入事件桥(带 _emit 测试口 / disposeScope 回收)。插件不可见。 + inputBridge, + /** + * 为某插件派生一份受控上下文(Gate0.1 per-plugin 派生流)。 + * 共享 canvas/帧/时间/输入桥/音频;**random 独立**:主 seed + 插件名 FNV-1a 派生子种子, + * 根治「共享序列互推」。注册器在 initAll 时对每插件调用本派生器。 + * 输入订阅 scope 用入参 scope(通常 = 插件对象),dispose 时注册器据此定向回收。 + * @param {string} pluginName 插件名(参与子种子派生,使不同插件子流互不相同且确定可复现)。 + * @param {any} [scope] 输入订阅作用域(缺省用 pluginName 字符串作 scope)。 + * @returns {PluginContext} + */ + deriveContextFor(pluginName, scope) { + const childSeed = deriveSeed(mainSeed, pluginName); + const childRandom = new SeededRandom(childSeed); + return buildContext(scope == null ? pluginName : scope, childRandom); + }, }; + + // 在默认 context 上挂一条**非枚举**的派生钩子,使注册器拿到 plain context 也能反查 bundle, + // 从而为每插件派生独立上下文(per-plugin 随机 + scope 化输入回收)。非枚举 → 不污染受控面、 + // 不影响 deepEqual/JSON/for-in(既有测试对 context 形状的断言不受影响)。 + Object.defineProperty(context, CONTEXT_BUNDLE_HOOK, { + value: bundle, + enumerable: false, + writable: false, + configurable: false, + }); + + return bundle; } +/** + * 非枚举钩子键(Gate0.1):挂在 host-dev context 上,指回其 bundle。 + * 注册器据此把 plain context 升级为「每插件派生上下文」;用 Symbol 防与任何受控面字段冲突。 + * @type {symbol} + */ +const CONTEXT_BUNDLE_HOOK = Symbol('zaomeng.coreProtocol.contextBundle'); + /* ============================================================================ * 三、PluginRegistry —— 插件注册器与生命周期编排 * 职责:注册(重名拒绝)/ 依赖声明校验 / init 正序(错误隔离)/ dispose 逆序(幂等)。 @@ -262,6 +556,13 @@ class PluginRegistry { this._phase = 'idle'; // dispose 幂等标记 this._disposed = false; + // host 注入的受控上下文(经 useContext);undefined=未注入(initAll 时给友好守卫)。 + /** @type {PluginContext|undefined} */ + this._context = undefined; + // Gate0.1:每插件派生上下文表(name → 该插件专属 context,random 独立 + 输入 scope 化)。 + // 用于 init 时分发、dispose 时定向回收该插件的输入订阅(防泄漏)。 + /** @type {Map} */ + this._pluginContexts = new Map(); } /** @@ -340,6 +641,14 @@ class PluginRegistry { if (this._phase === 'disposed') { throw new Error('[PluginRegistry] 已 dispose 的注册器不可再 initAll'); } + // useContext 守卫(Gate0.1 裁定三):忘调 useContext 即 init → 给含修复提示的友好错误, + // 而非让插件在 init 内裸 TypeError(ctx 为 undefined 时取 ctx.xxx 崩)。 + if (this._context == null) { + throw new Error( + '[PluginRegistry] 未注入受控上下文:请在 initAll() 之前调用 useContext(context)。' + + '(host-dev 用法:const { context } = createHostDevContext(); reg.useContext(context); reg.initAll();)' + ); + } for (const [name, plugin] of this._plugins) { // 1) 依赖声明校验:声明的依赖必须已注册 @@ -352,12 +661,13 @@ class PluginRegistry { logError('[PluginRegistry] 跳过 init(依赖缺失,已隔离):' + name, err); continue; // 隔离:不 init 本插件,不连坐其它 } - // 2) 构造受控上下文交给插件 init - // 本波 Gate0:每插件拿到的是「同一份 host-dev 受控面」由外部 host 注入。 - // 注册器本身不造 context——context 由 host 在 initAll(ctx) 传入更合理, - // 但为保持 Gate0 骨架自洽 + 可单测,这里允许注册器持有一个 context 工厂。 + // 2) 为本插件取受控上下文交给 init(Gate0.1 per-plugin 派生): + // 若 host 注入的是 host-dev context(带派生钩子)→ 为每插件派生独立上下文 + // (random 独立 + 输入订阅 scope 化,dispose 时定向回收);否则回退用共享 context + // (手搓 plain context 无派生钩子时的兼容路径)。 + const ctx = this._contextFor(name); try { - plugin.init(this._context); + plugin.init(ctx); this._initOrder.push(name); } catch (e) { // 错误隔离:单插件 init 抛错只隔离该插件 @@ -371,6 +681,29 @@ class PluginRegistry { return this._initResult(); } + /** + * 取(或派生)某插件的受控上下文(Gate0.1)。 + * host-dev context 带非枚举派生钩子 → 派生「random 独立 + 输入 scope 化」的每插件上下文并缓存; + * 无派生钩子(手搓 plain context)→ 回退用共享上下文(兼容老用法,但无 per-plugin 随机隔离)。 + * @param {string} name 插件名 + * @returns {PluginContext} + */ + _contextFor(name) { + const existing = this._pluginContexts.get(name); + if (existing) return existing; + const bundle = this._context && this._context[CONTEXT_BUNDLE_HOOK]; + let ctx; + if (bundle && typeof bundle.deriveContextFor === 'function') { + // 用插件名派生子随机 + scope=插件名(dispose 时据此回收该插件输入订阅)。 + ctx = bundle.deriveContextFor(name, name); + } else { + // 兼容回退:无派生能力时共享同一上下文(plain context 不参与 per-plugin 随机隔离)。 + ctx = this._context; + } + this._pluginContexts.set(name, ctx); + return ctx; + } + /** * 释放全部已 init 的插件(按注册**逆序**,后进先出)。 * **幂等**:重复调用安全(第二次起直接返回,不重复触发 dispose)。 @@ -385,20 +718,34 @@ class PluginRegistry { } const disposed = []; const errors = []; + // 取输入桥(host-dev context 经派生钩子可达),用于 dispose 时定向回收各插件输入订阅(防泄漏)。 + const bundle = this._context && this._context[CONTEXT_BUNDLE_HOOK]; + const inputBridge = bundle && bundle.inputBridge; // 逆序释放(后 init 的先 dispose) for (let i = this._initOrder.length - 1; i >= 0; i--) { const name = this._initOrder[i]; const plugin = this._plugins.get(name); - if (!plugin || typeof plugin.dispose !== 'function') { - continue; // 无 dispose 的插件跳过(合法:dispose 可选) + // 1) 调插件自身 dispose(可选);抛错隔离不连坐。 + if (plugin && typeof plugin.dispose === 'function') { + try { + plugin.dispose(); + disposed.push(name); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + errors.push({ name, error: err }); + logError('[PluginRegistry] 插件 dispose 抛错(已隔离):' + name, err); + } } - try { - plugin.dispose(); - disposed.push(name); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - errors.push({ name, error: err }); - logError('[PluginRegistry] 插件 dispose 抛错(已隔离):' + name, err); + // 2) 注册器协作:无条件回收该插件经 getInput 注册的全部输入订阅(防泄漏)。 + // 对「无 dispose 方法但订阅过输入」的插件同样生效;scope=插件名(与 _contextFor 派生时一致)。 + // 即便插件 dispose 抛错,也仍回收其输入订阅(释放路径不被单点故障带漏)。 + if (inputBridge && typeof inputBridge.disposeScope === 'function') { + try { + inputBridge.disposeScope(name); + } catch (e) { + // 回收本身抛错也隔离(不连坐),仅记 stderr。 + logError('[PluginRegistry] 回收插件输入订阅抛错(已隔离):' + name, e); + } } } this._disposed = true; @@ -415,7 +762,18 @@ class PluginRegistry { */ useContext(context) { if (this._phase !== 'idle') { - throw new Error('[PluginRegistry] useContext 必须在 initAll 之前'); + throw new Error( + '[PluginRegistry] useContext 必须在 initAll() 之前调用(当前阶段:' + this._phase + ')。' + + '请在任何 initAll/disposeAll 之前注入受控上下文。' + ); + } + // 友好守卫(Gate0.1 裁定三):传入非法 context 时即点出问题与修复路径, + // 而非拖到插件 init 内裸 TypeError(取 ctx.getContext2d 时崩)。 + if (context == null || typeof context !== 'object') { + throw new Error( + '[PluginRegistry] useContext 需要一个受控上下文对象。' + + '(host-dev 用法:const { context } = createHostDevContext(); reg.useContext(context);)' + ); } this._context = context; return this; @@ -443,6 +801,40 @@ class PluginRegistry { * 四、零依赖运行时缺省能力 + 日志(错误路径留痕) * ========================================================================== */ +/** + * per-plugin 子种子派生(Gate0.1)——主种子 + 插件名经 FNV-1a 32-bit 混合得确定子种子。 + * + * 选 FNV-1a 与全发行版证据哈希族同口径(README「哈希族=FNV-1a 32-bit」),保跨端一致、零依赖。 + * 算法:以「主种子的 4 字节(小端)+ 插件名 UTF-16 码元逐字节」喂入 FNV-1a,产出 32 位无符号子种子。 + * 性质:同(主种子,插件名)→ 同子种子(确定可复现);不同插件名 → 大概率不同子种子(互不推演)。 + * + * @param {number} mainSeed 主种子(32 位无符号)。 + * @param {string} pluginName 插件名。 + * @returns {number} 32 位无符号子种子。 + */ +function deriveSeed(mainSeed, pluginName) { + // FNV-1a 32-bit 标准 offset basis 与 prime。 + let h = 0x811c9dc5 >>> 0; + const FNV_PRIME = 0x01000193; + // 1) 先混入主种子的 4 个字节(小端),使「同名不同主种子」也产不同子流。 + const s = mainSeed >>> 0; + for (let shift = 0; shift < 32; shift += 8) { + const byte = (s >>> shift) & 0xff; + h ^= byte; + h = Math.imul(h, FNV_PRIME) >>> 0; + } + // 2) 再混入插件名(按字符码低 8 位 + 高 8 位,覆盖非 ASCII 名)。 + const name = String(pluginName); + for (let i = 0; i < name.length; i++) { + const code = name.charCodeAt(i); + h ^= code & 0xff; + h = Math.imul(h, FNV_PRIME) >>> 0; + h ^= (code >>> 8) & 0xff; + h = Math.imul(h, FNV_PRIME) >>> 0; + } + return h >>> 0; +} + /** * 选择单调时钟:performance.now > process.hrtime.bigint > Date.now(同 channel-probe 口径)。 * @returns {() => number} @@ -490,9 +882,16 @@ export { createHostDevContext, SeededRandom, HostDevTime, + // host-dev 输入事件桥(Gate0.1):集成段换真实 DOM/引擎事件包装;导出便于单测/host 编排。 + HostDevInputBridge, // 内部工具导出,便于单测对确定性口径做断言(与 channel-probe 同策略) defaultMonotonicClock, + // per-plugin 子种子派生(Gate0.1):导出便于单测断言「同名同种子→同子种子、异名→异子种子」。 + deriveSeed, }; +/** 受控输入事件类型枚举(冻结口径,便于 lane/host 引用与校验)。单一事实源 = INPUT_EVENT_TYPES(Set)。 */ +export const INPUT_EVENT_TYPE_LIST = Object.freeze(Array.from(INPUT_EVENT_TYPES)); + /** 协议版本标识(冻结时 = core-protocol-v0)。 */ export const CORE_PROTOCOL_VERSION = 'core-protocol-v0'; diff --git a/game-runtime/src/core/plugin.test.mjs b/game-runtime/src/core/plugin.test.mjs index 542d51b3..6eefda49 100644 --- a/game-runtime/src/core/plugin.test.mjs +++ b/game-runtime/src/core/plugin.test.mjs @@ -2,13 +2,17 @@ * plugin.test.mjs — core/plugin.js 零依赖 node 单测(node --test 直跑) * owner:T1b-α lane-core | 运行:node --test src/core/plugin.test.mjs * - * 覆盖(执行版 §2「core 注册器自身带零依赖 node 测试」四项 + 受控面 + host-dev tick): + * 覆盖(执行版 §2「core 注册器自身带零依赖 node 测试」四项 + 受控面 + host-dev tick + Gate0.1 三裁定): * 1. 注册 / 重名拒绝 / 形状非法拒绝; * 2. 生命周期顺序:init 正序、dispose 逆序(后进先出); * 3. dispose 幂等(重复 dispose 无副作用); * 4. 错误隔离:单插件 init 抛错不连坐其它插件;依赖缺失隔离不连坐; * 5. 受控面:createHostDevContext 的 time/random/onFrame/getContext2d 行为 + 确定性随机可复现; * 6. host-dev tick:驱动 onFrame、frame 单调、cancel 注销、回调抛错隔离。 + * 7. Gate0.1 裁定一·受控面扩两项:getInput(订阅/退订/归一化/时间戳/dispose 自动清理/非法类型/handler 隔离)、 + * getAudioContext(无工厂 null+告警一次/有工厂 lazy 单例)。 + * 8. Gate0.1 裁定二·per-plugin 派生随机流:两插件随机序列互不影响、同 seed 复现、deriveSeed 确定性。 + * 9. Gate0.1 裁定三·useContext 守卫:忘调/传非法/阶段错误均给友好错误(非裸 TypeError)。 * * 测试基建:用步进时钟使 time 可预测;用计数数组记录生命周期顺序。零第三方依赖,仅 node:test/node:assert。 */ @@ -21,6 +25,9 @@ import { createHostDevContext, SeededRandom, HostDevTime, + HostDevInputBridge, + deriveSeed, + INPUT_EVENT_TYPE_LIST, } from './plugin.js'; /* ── 测试基建 ──────────────────────────────────────────────────────────────── */ @@ -316,3 +323,232 @@ test('帧回调抛错被隔离:不连坐同帧其它回调,也不中断后 assert.doesNotThrow(() => { tick(0.016); tick(0.016); }); assert.deepEqual(good, [1, 2]); }); + +/* ── 7. Gate0.1 裁定一:受控面 getInput / getAudioContext ───────────────────── */ + +test('getInput 订阅:on 收到归一化事件,坐标/键码/时间戳正确(时间戳取自受控时间源)', () => { + // 步进时钟从 1000 起每次 +5:事件时间戳 = _emit 时调一次 nowMs。 + const { context, inputBridge } = createHostDevContext({ clock: stepClock(1000, 5) }); + const seen = []; + const input = context.getInput(); + input.on('pointerdown', (e) => seen.push(e)); + input.on('keydown', (e) => seen.push(e)); + + inputBridge._emit('pointerdown', { x: 10, y: 20 }); + inputBridge._emit('keydown', { key: 'Space' }); + + assert.equal(seen.length, 2, '两次 _emit 应分发两条事件'); + // 指针事件:x/y 归一化透传,key 空串,type 正确,时间戳来自受控时间源(>=1000)。 + assert.equal(seen[0].type, 'pointerdown'); + assert.equal(seen[0].x, 10); + assert.equal(seen[0].y, 20); + assert.equal(seen[0].key, ''); + assert.ok(seen[0].tMs >= 1000, '时间戳应来自受控时间源'); + // 键盘事件:key 归一化透传,x/y 为 0。 + assert.equal(seen[1].type, 'keydown'); + assert.equal(seen[1].key, 'Space'); + assert.equal(seen[1].x, 0); + assert.equal(seen[1].y, 0); +}); + +test('getInput.off / 句柄 cancel 退订:退订后不再收事件', () => { + const { context, inputBridge } = createHostDevContext(); + const input = context.getInput(); + let viaOff = 0; + let viaCancel = 0; + const h1 = (e) => { void e; viaOff += 1; }; + input.on('pointermove', h1); + const sub = input.on('pointerup', () => { viaCancel += 1; }); + + inputBridge._emit('pointermove', { x: 1, y: 1 }); // viaOff=1 + inputBridge._emit('pointerup', {}); // viaCancel=1 + + input.off('pointermove', h1); // 退订 off + sub.cancel(); // 退订 cancel + + inputBridge._emit('pointermove', { x: 2, y: 2 }); // 不应再增 + inputBridge._emit('pointerup', {}); // 不应再增 + + assert.equal(viaOff, 1, 'off 退订后不再收事件'); + assert.equal(viaCancel, 1, '句柄 cancel 退订后不再收事件'); +}); + +test('插件经 getInput 注册的订阅,dispose 时由注册器自动清理(防泄漏)', () => { + const { context, inputBridge } = createHostDevContext(); + const reg = new PluginRegistry(); + reg.useContext(context); + const got = []; + // 插件 A:init 时订阅两类输入(不在 dispose 里手动退订,验证注册器协作回收)。 + reg.register({ + name: 'input-a', + version: '1.0.0', + init(ctx) { + const input = ctx.getInput(); + input.on('pointerdown', (e) => got.push(e)); + input.on('keyup', (e) => got.push(e)); + }, + // 故意无 dispose:验证「无 dispose 方法但订阅过输入」也被注册器无条件回收。 + }); + reg.initAll(); + assert.equal(inputBridge._subscriptionCount(), 2, 'init 后应有 2 条订阅'); + + inputBridge._emit('pointerdown', { x: 1, y: 1 }); + assert.equal(got.length, 1, 'dispose 前事件应分发'); + + reg.disposeAll(); + assert.equal(inputBridge._subscriptionCount(), 0, 'dispose 后订阅应被自动清理(防泄漏)'); + + inputBridge._emit('pointerdown', { x: 2, y: 2 }); + assert.equal(got.length, 1, 'dispose 后再 _emit 不应再分发(订阅已回收)'); +}); + +test('多插件各自的输入 scope 独立回收:dispose 一个不误伤其它(但本波整体 disposeAll 全回收)', () => { + // 直接用桥的 disposeScope 验证 scope 隔离(注册器按插件名作 scope)。 + const time = new HostDevTime(stepClock(0, 1)); + const bridge = new HostDevInputBridge(time); + let a = 0; let b = 0; + bridge.sourceFor('plug-a').on('pointerdown', () => { a += 1; }); + bridge.sourceFor('plug-b').on('pointerdown', () => { b += 1; }); + assert.equal(bridge._subscriptionCount(), 2); + + bridge._emit('pointerdown', {}); // a=1, b=1 + const removed = bridge.disposeScope('plug-a'); // 只回收 a 的订阅 + assert.equal(removed, 1, 'disposeScope 应只回收该 scope 的订阅'); + assert.equal(bridge._subscriptionCount(), 1, '应剩 b 的 1 条订阅'); + + bridge._emit('pointerdown', {}); // b=2,a 不再增 + assert.equal(a, 1, 'plug-a 订阅已回收,不再收事件'); + assert.equal(b, 2, 'plug-b 订阅未被误伤,仍收事件'); +}); + +test('getInput 非法事件类型拒绝(防误用裸 DOM 事件名)', () => { + const { context, inputBridge } = createHostDevContext(); + const input = context.getInput(); + assert.throws(() => input.on('wheel', () => {}), /非法输入事件类型/); + assert.throws(() => inputBridge._emit('click', {}), /非法输入事件类型/); + // on 的 handler 必须是函数 + assert.throws(() => input.on('pointerdown', 123), /handler 必须是函数/); + // 枚举导出与冻结口径一致(5 类)。 + assert.deepEqual( + [...INPUT_EVENT_TYPE_LIST].sort(), + ['keydown', 'keyup', 'pointerdown', 'pointermove', 'pointerup'] + ); +}); + +test('getInput 单个 handler 抛错被隔离:不连坐同类型其它 handler', () => { + const { context, inputBridge } = createHostDevContext(); + const input = context.getInput(); + const good = []; + input.on('pointerdown', () => { throw new Error('input-boom'); }); + input.on('pointerdown', (e) => good.push(e.type)); + // 抛错 handler 不应让 _emit 抛出,也不应阻止 good handler。 + assert.doesNotThrow(() => { inputBridge._emit('pointerdown', {}); inputBridge._emit('pointerdown', {}); }); + assert.deepEqual(good, ['pointerdown', 'pointerdown']); +}); + +test('getAudioContext 无工厂:返回 null 且仅告警一次(P6 容错降级,不抛错)', () => { + const { context } = createHostDevContext(); // 不传 audioFactory + // 拦截 console.warn 统计告警次数。 + const orig = console.warn; + let warnCount = 0; + console.warn = () => { warnCount += 1; }; + try { + const a1 = context.getAudioContext(); + const a2 = context.getAudioContext(); + assert.equal(a1, null, '无工厂应返回 null'); + assert.equal(a2, null, '再次调用仍返回 null'); + assert.equal(warnCount, 1, '告警应仅一次(不每次刷屏)'); + } finally { + console.warn = orig; + } +}); + +test('getAudioContext 有工厂:lazy 创建 + 单例(多次调用返回同一实例,工厂只造一次)', () => { + let made = 0; + const fakeAC = /** @type {any} */ ({ __ac: true }); + const { context } = createHostDevContext({ audioFactory: () => { made += 1; return fakeAC; } }); + // lazy:未调用前不创建。 + assert.equal(made, 0, 'lazy:getAudioContext 未调用前不创建'); + const ac1 = context.getAudioContext(); + const ac2 = context.getAudioContext(); + assert.equal(ac1, fakeAC, '应返回工厂产物'); + assert.equal(ac2, fakeAC, '单例:再次返回同一实例'); + assert.equal(made, 1, '工厂应只被调用一次(lazy 单例)'); +}); + +/* ── 8. Gate0.1 裁定二:per-plugin 派生随机流 ──────────────────────────────── */ + +test('per-plugin 派生随机:两插件各取随机序列互不影响 + 同 seed 复现', () => { + // 经注册器 init 时,host-dev context 给每插件派生独立子随机源(主 seed + 插件名 FNV-1a)。 + function run(seed) { + const reg = new PluginRegistry(); + const { context } = createHostDevContext({ seed }); + reg.useContext(context); + const seqA = []; + const seqB = []; + reg.register({ name: 'rand-a', version: '1.0.0', init(ctx) { for (let i = 0; i < 4; i++) seqA.push(ctx.random.next()); } }); + reg.register({ name: 'rand-b', version: '1.0.0', init(ctx) { for (let i = 0; i < 4; i++) seqB.push(ctx.random.next()); } }); + reg.initAll(); + return { seqA, seqB }; + } + const r1 = run(12345); + const r2 = run(12345); + // 同主 seed → 各插件子流逐项复现。 + assert.deepEqual(r1.seqA, r2.seqA, '同主 seed:rand-a 子流复现'); + assert.deepEqual(r1.seqB, r2.seqB, '同主 seed:rand-b 子流复现'); + // 两插件子流互不相同(独立派生,根治「共享序列互推」)。 + assert.notDeepEqual(r1.seqA, r1.seqB, '两插件子流应独立(互不影响)'); +}); + +test('per-plugin 派生随机:插件 A 先取随机不推进插件 B 的序列(独立性证明)', () => { + // 同一注册器内:A 取多次随机后 B 再取,B 的序列应与「B 单独取」一致(不被 A 推进)。 + function bSeqWith(aDraws) { + const reg = new PluginRegistry(); + const { context } = createHostDevContext({ seed: 777 }); + reg.useContext(context); + const seqB = []; + reg.register({ name: 'a', version: '1.0.0', init(ctx) { for (let i = 0; i < aDraws; i++) ctx.random.next(); } }); + reg.register({ name: 'b', version: '1.0.0', init(ctx) { for (let i = 0; i < 3; i++) seqB.push(ctx.random.next()); } }); + reg.initAll(); + return seqB; + } + const b0 = bSeqWith(0); // A 不取随机 + const b100 = bSeqWith(100); // A 取 100 次随机 + assert.deepEqual(b0, b100, 'B 的序列不受 A 取随机次数影响(派生流独立,非共享)'); +}); + +test('deriveSeed 确定性:同(主种子,插件名)同子种子;异名/异主种子异子种子', () => { + assert.equal(deriveSeed(12345, 'collision'), deriveSeed(12345, 'collision'), '同输入同输出'); + assert.notEqual(deriveSeed(12345, 'collision'), deriveSeed(12345, 'particles'), '异插件名异子种子'); + assert.notEqual(deriveSeed(1, 'collision'), deriveSeed(2, 'collision'), '异主种子异子种子'); + // 子种子为 32 位无符号整数。 + const s = deriveSeed(999, 'gamefeel'); + assert.ok(Number.isInteger(s) && s >= 0 && s <= 0xffffffff, '子种子应为 32 位无符号整数'); +}); + +/* ── 9. Gate0.1 裁定三:useContext 守卫(友好错误而非裸 TypeError)─────────── */ + +test('忘调 useContext 即 initAll:抛含修复提示的友好错误(非裸 TypeError)', () => { + const reg = new PluginRegistry(); + reg.register({ name: 'p', version: '1.0.0', init(ctx) { ctx.getContext2d(); } }); + let caught = null; + assert.throws(() => reg.initAll(), (err) => { caught = err; return true; }); + assert.match(caught.message, /未注入受控上下文/, '错误应点出「未注入受控上下文」'); + assert.match(caught.message, /useContext/, '错误应给出 useContext 修复提示'); + assert.ok(!(caught instanceof TypeError), '应为友好 Error 而非裸 TypeError'); +}); + +test('useContext 传 null/非对象:抛友好错误', () => { + const reg = new PluginRegistry(); + assert.throws(() => reg.useContext(null), /需要一个受控上下文对象/); + assert.throws(() => reg.useContext(undefined), /需要一个受控上下文对象/); + assert.throws(() => reg.useContext(123), /需要一个受控上下文对象/); +}); + +test('useContext 在 initAll 之后调用:抛错并点出当前阶段', () => { + const reg = new PluginRegistry(); + const { context } = createHostDevContext(); + reg.useContext(context); + reg.initAll(); + assert.throws(() => reg.useContext(context), /必须在 initAll/); +}); diff --git a/game-runtime/src/plugins/_example/PLUGIN.md b/game-runtime/src/plugins/_example/PLUGIN.md index df629338..b02552e7 100644 --- a/game-runtime/src/plugins/_example/PLUGIN.md +++ b/game-runtime/src/plugins/_example/PLUGIN.md @@ -9,18 +9,20 @@ 提供: - 一个工厂函数 `createExamplePlugin(opts)`,返回实现 core `Plugin` 接口的插件对象; -- 一个只读探针 `plugin.probe()`,返回内部状态快照(帧数 / 最近随机值 / 是否拿到 2D 上下文 / 是否已 dispose),**仅供测试与诊断**(生产插件可删)。 +- 一个只读探针 `plugin.probe()`,返回内部状态快照(帧数 / 输入事件数 / 最近随机值 / 是否拿到 2D 上下文 / 是否拿到音频上下文 / 是否已 dispose),**仅供测试与诊断**(生产插件可删)。 **不提供**:任何玩法(金币/敌人/关卡…)、任何美术成品、任何 UI、任何真实引擎算法。这些都属 agent 造游戏时的生成域,不进插件。 ## 2. 集成点 - **依赖**:无(`dependencies: []`)。 -- **受控面用法**(用到 `PluginContext` 4 项中的全部,作演示): +- **受控面用法**(用到 `PluginContext` 6 项中的全部,作演示;Gate0.1 由 4 扩 6): - `getContext2d()`:init 时探测一次 2D 上下文;node 侧返回 `null` 时安全降级(探针记 `hasContext2d=false`),**不自行 `document.querySelector`**。 - `onFrame(cb)`:init 时注册一个逐帧回调(累计帧数 / 取随机),拿到的 `FrameHandle` 存起来供 dispose 注销;**绝不直接 `requestAnimationFrame`**。 + - `getInput()`:init 时经 `ctx.getInput().on('pointerdown', cb)` 订阅一类输入事件(累计 `inputEvents`),拿到的 `InputSubscription` 存起来供 dispose 注销;**绝不直接 `addEventListener`**。事件对象为归一化 `{type,x,y,key,tMs}`,时间戳来自受控时间源。 + - `getAudioContext()`:init 时 lazy 取一次音频上下文;无工厂的 node 侧返回 `null` 时安全降级(探针记 `hasAudio=false`),**绝不自行 `new AudioContext`**。 - `time`:回调内经 `ctx.time.elapsedMs()` 取时间,**不读** `Date.now`/`performance.now`。 - - `random`:回调内经 `ctx.random.next()` 取随机,**不读** `Math.random`;如配置带 `seed` 则 init 时 `ctx.random.reseed(seed)` 使序列确定可复现。 + - `random`:回调内经 `ctx.random.next()` 取随机,**不读** `Math.random`;如配置带 `seed` 则 init 时 `ctx.random.reseed(seed)` 使序列确定可复现。注:经注册器 init 时,每插件的 `random` 是按「主 seed + 插件名」派生的独立子流(插件间互不推演)。 - **注册方式**:`registry.register(createExamplePlugin(opts))`。 - **配置项 `opts`**:`{ seed?: number }` —— 给定时重设受控随机源种子,使样例随机序列确定可复现(仅演示「插件可带配置 + 取证友好写法」,无玩法含义)。 @@ -30,24 +32,27 @@ import { PluginRegistry, createHostDevContext } from '../../core/plugin.js'; import { createExamplePlugin } from './impl.js'; -// 1) host 造受控上下文(host-dev:node 侧 context2d 传 null 即可)。 -const { context, tick } = createHostDevContext({ context2d: null }); +// 1) host 造受控上下文(host-dev:node 侧 context2d 传 null;audioFactory 可选,缺省则音频降级为 null)。 +const { context, tick, inputBridge } = createHostDevContext({ context2d: null }); // 2) 注册器编排生命周期。 const reg = new PluginRegistry(); -reg.useContext(context); +reg.useContext(context); // 忘调即 initAll 会给友好报错(Gate0.1 守卫) reg.register(createExamplePlugin({ seed: 42 })); // seed 可选 -// 3) init 全部插件(正序,错误隔离)。 +// 3) init 全部插件(正序,错误隔离;每插件 random 按「主 seed + 插件名」派生独立子流)。 const r = reg.initAll(); console.log(r.ok, r.initialized); // true ['example'] // 4) 驱动若干帧(host-dev 用 tick 模拟引擎主循环;集成段由 LittleJS gameUpdate 驱动)。 tick(0.016); tick(0.016); -console.log(reg.get('example').probe()); // { frames: 2, lastRandom: <[0,1)>, hasContext2d: false, disposed: false } +// 4.1) 经输入桥注入一条输入(集成段由真实 DOM/引擎事件驱动)。 +inputBridge._emit('pointerdown', { x: 10, y: 20 }); +console.log(reg.get('example').probe()); +// { frames: 2, inputEvents: 1, lastRandom: <[0,1)>, hasContext2d: false, hasAudio: false, disposed: false } -// 5) 释放(逆序,幂等)。 +// 5) 释放(逆序,幂等);插件经 getInput 的订阅由注册器在 dispose 时自动回收(防泄漏)。 reg.disposeAll(); ``` @@ -55,14 +60,14 @@ reg.disposeAll(); - 路径:`src/plugins/_example/test/example.test.mjs` - 运行:`node --test src/plugins/_example/test/example.test.mjs`(零依赖,仅 `node:test`/`node:assert`) -- 覆盖:① 被 `PluginRegistry` 全流程接纳(register/initAll/disposeAll 无错);② 受控面 `onFrame` 经 `tick` 驱动后帧数递增、记录最近随机值;③ 受控随机源注种子后**确定可复现**(同种子同序列、异种子异序列);④ `getContext2d` 为 `null` 时安全降级、注入 mock 则探测为 `true`;⑤ `dispose` **幂等**且精确撤销帧回调(dispose 后再 tick 不增帧)。 +- 覆盖:① 被 `PluginRegistry` 全流程接纳(register/initAll/disposeAll 无错);② 受控面 `onFrame` 经 `tick` 驱动后帧数递增、记录最近随机值;③ 受控随机源注种子后**确定可复现**(同种子同序列、异种子异序列);④ `getContext2d` 为 `null` 时安全降级、注入 mock 则探测为 `true`;⑤ `dispose` **幂等**且精确撤销帧回调(dispose 后再 tick 不增帧);⑥(Gate0.1)`getInput` 订阅经桥 `_emit` 驱动后 `inputEvents` 递增、dispose 后不再增(精确撤销);⑦(Gate0.1)`getAudioContext` 无工厂时 `hasAudio=false`(容错降级)、注入工厂则 `hasAudio=true`。 - node 层即可全测(本插件无渲染/发声能力,故无集成段浏览器证据需求)。 ## 5. 边界与字节预算 - **字节预算**:`gzBudgetBytes = 2048`(与 `manifest.json` 一致)。本插件无实际能力,远低于此;克隆真插件后按各自配额(执行版 §5)核对,超配额触发评审。 -- **行为边界**:null 渲染环境降级(`getContext2d()` 返回 null 时不崩,仅记探针);`dispose` 幂等(重复调用安全、不留悬挂帧回调)。 -- **受控面铁律**:只经 `PluginContext` 的 4 项受控面拿引擎能力,**绝不直透 littlejsengine 裸对象 / 全局 canvas / 原生 RAF** —— 保「引擎可换」。 +- **行为边界**:null 渲染环境降级(`getContext2d()` 返回 null 时不崩,仅记探针);无音频工厂降级(`getAudioContext()` 返回 null 时不崩,仅记 `hasAudio=false`);`dispose` 幂等(重复调用安全、不留悬挂帧回调/输入订阅)。 +- **受控面铁律**:只经 `PluginContext` 的 6 项受控面(`getContext2d`/`onFrame`/`getInput`/`getAudioContext`/`time`/`random`)拿引擎能力,**绝不直透 littlejsengine 裸对象 / 全局 canvas / 原生 RAF / 原生 DOM 事件 / 裸 AudioContext** —— 保「引擎可换」。 ## 6. v2 声明 diff --git a/game-runtime/src/plugins/_example/api.d.ts b/game-runtime/src/plugins/_example/api.d.ts index f6dc1336..1e424908 100644 --- a/game-runtime/src/plugins/_example/api.d.ts +++ b/game-runtime/src/plugins/_example/api.d.ts @@ -21,10 +21,12 @@ import type { Plugin, PluginContext } from '../../core/api.d.ts'; /** * 创建一个空插件实例(样例工厂)。 * - * 该插件 init 时仅做「无害的受控面演示」: + * 该插件 init 时仅做「无害的受控面演示」(Gate0.1 六项全覆盖): * - 经 ctx.onFrame 注册一个逐帧回调,用 ctx.time / ctx.random 累计无副作用的内部计数; * - 经 ctx.getContext2d 尝试取 2D 上下文,取不到(node 侧为 null)即安全降级; - * - dispose 时撤销帧回调并复位内部状态(幂等:重复 dispose 安全)。 + * - 经 ctx.getAudioContext 尝试 lazy 取音频上下文,取不到(无工厂的 node 侧为 null)即安全降级; + * - 经 ctx.getInput().on 订阅一类输入事件,回调内累计无副作用计数,拿到的订阅句柄留 dispose 注销; + * - dispose 时撤销帧回调与输入订阅并复位内部状态(幂等:重复 dispose 安全)。 * 它不渲染玩法、不改全局、不假设任何品类 —— 纯协议用法演示。 * * @param opts 可选配置:仅演示「插件可接受自身配置」这一模式,无玩法含义。 @@ -57,10 +59,14 @@ export interface ExamplePlugin extends Plugin { export interface ExampleProbeSnapshot { /** init 后经由 onFrame 累计被驱动的帧数。 */ frames: number; + /** init 后经由 getInput 订阅累计收到的输入事件数(Gate0.1)。 */ + inputEvents: number; /** 最近一帧用 ctx.random.next() 取到的随机值(未驱动过为 null)。 */ lastRandom: number | null; /** init 时 ctx.getContext2d() 是否返回了非 null 上下文(node 侧通常 false)。 */ hasContext2d: boolean; + /** init 时 ctx.getAudioContext() 是否返回了非 null 音频上下文(无工厂的 node 侧通常 false;Gate0.1)。 */ + hasAudio: boolean; /** 是否已 dispose(幂等观察用)。 */ disposed: boolean; } diff --git a/game-runtime/src/plugins/_example/impl.js b/game-runtime/src/plugins/_example/impl.js index a8d18f51..901ab825 100644 --- a/game-runtime/src/plugins/_example/impl.js +++ b/game-runtime/src/plugins/_example/impl.js @@ -14,9 +14,12 @@ * 【它演示了什么(给克隆者看)】 * 1. 工厂模式:createExamplePlugin(opts) 返回插件对象(而非裸对象字面量), * 便于带配置、便于闭包封装内部状态(生产插件普遍如此)。 - * 2. 受控面四项用法:getContext2d(容忍 null 降级)/ onFrame(拿句柄,dispose 时 cancel)/ + * 2. 受控面六项用法(Gate0.1 由 4 扩 6):getContext2d(容忍 null 降级)/ onFrame(拿句柄,dispose 时 cancel)/ + * getInput(订阅输入事件,拿订阅句柄,dispose 时 cancel)/ getAudioContext(lazy 取,容忍 null 降级)/ * time(取时间不读 Date.now)/ random(取随机不读 Math.random,可注种子复现)。 - * 3. dispose 幂等:重复调用安全,且精确撤销 init 申请的帧回调(不留悬挂回调)。 + * 3. dispose 幂等:重复调用安全,且精确撤销 init 申请的帧回调与输入订阅(不留悬挂回调/订阅)。 + * 注:注册器另会在插件 dispose 后兜底回收其输入订阅,但插件自身亦应精确撤销(直接 init 不经 + * 注册器的场景,双保险防泄漏)。 */ 'use strict'; @@ -25,6 +28,7 @@ * @typedef {import('../../core/api.d.ts').Plugin} Plugin * @typedef {import('../../core/api.d.ts').PluginContext} PluginContext * @typedef {import('../../core/api.d.ts').FrameHandle} FrameHandle + * @typedef {import('../../core/api.d.ts').InputSubscription} InputSubscription * @typedef {import('./api.d.ts').ExamplePlugin} ExamplePlugin * @typedef {import('./api.d.ts').ExamplePluginOptions} ExamplePluginOptions * @typedef {import('./api.d.ts').ExampleProbeSnapshot} ExampleProbeSnapshot @@ -41,12 +45,18 @@ export function createExamplePlugin(opts) { // ── 插件内部状态(用闭包封装,不挂全局;这是生产插件管理资源的推荐姿势)── /** @type {FrameHandle|null} init 时申请的帧回调句柄;dispose 时据此精确注销。 */ let frameHandle = null; + /** @type {InputSubscription|null} init 时申请的输入订阅句柄;dispose 时据此精确注销。 */ + let inputSub = null; /** @type {number} 经 onFrame 累计被驱动的帧数(探针用)。 */ let frames = 0; + /** @type {number} 经 getInput 订阅累计收到的输入事件数(探针用)。 */ + let inputEvents = 0; /** @type {number|null} 最近一帧取到的随机值(探针用)。 */ let lastRandom = null; /** @type {boolean} init 时是否拿到了非 null 的 2D 上下文(node 侧通常 false)。 */ let hasContext2d = false; + /** @type {boolean} init 时 lazy 取受控音频上下文是否拿到非 null(无工厂的 node 侧通常 false)。 */ + let hasAudio = false; /** @type {boolean} 是否已 dispose(幂等保护 + 探针用)。 */ let disposed = false; @@ -76,6 +86,18 @@ export function createExamplePlugin(opts) { const c2d = ctx.getContext2d(); hasContext2d = c2d != null; + // 2.1) 受控音频上下文(Gate0.1):lazy 取一次。无工厂的 node 侧返回 null —— 必须容忍降级 + // (演示 P6 容错降级写法;插件**禁止**自行 new AudioContext)。 + const audio = ctx.getAudioContext(); + hasAudio = audio != null; + + // 2.2) 受控输入订阅(Gate0.1):经 ctx.getInput().on 订阅一类输入事件(**禁止**直接 addEventListener)。 + // 回调内只做无副作用计数;拿到的订阅句柄存起来,dispose 时 cancel(注册器另会兜底回收)。 + inputSub = ctx.getInput().on('pointerdown', (e) => { + inputEvents += 1; + void e; // 演示事件对象可用(归一化坐标/键码/受控时间戳),此处不消费,仅示形。 + }); + // 3) 逐帧回调:经 ctx.onFrame 注册(**禁止**插件直接 requestAnimationFrame)。 // 回调内只做无副作用的内部计数:用 ctx.time 取时间、用 ctx.random 取随机, // 演示「逐帧逻辑应如何拿时间/随机」。拿到的句柄存起来,dispose 时 cancel。 @@ -105,6 +127,10 @@ export function createExamplePlugin(opts) { frameHandle.cancel(); // 精确注销 init 申请的帧回调,不留悬挂回调。 frameHandle = null; } + if (inputSub) { + inputSub.cancel(); // 精确注销 init 申请的输入订阅,不留悬挂订阅(注册器另有兜底回收,此处双保险)。 + inputSub = null; + } disposed = true; }, @@ -116,8 +142,10 @@ export function createExamplePlugin(opts) { probe() { return { frames, + inputEvents, lastRandom, hasContext2d, + hasAudio, disposed, }; }, diff --git a/game-runtime/src/plugins/_example/test/example.test.mjs b/game-runtime/src/plugins/_example/test/example.test.mjs index b9197159..f1e91718 100644 --- a/game-runtime/src/plugins/_example/test/example.test.mjs +++ b/game-runtime/src/plugins/_example/test/example.test.mjs @@ -11,7 +11,9 @@ * 1. 插件形状合法(能被 PluginRegistry register/initAll/disposeAll 全流程接纳); * 2. 受控面用法:onFrame 被 tick 驱动 → 探针帧数递增;random 注种子后确定可复现; * getContext2d 为 null 时安全降级(node 侧); - * 3. dispose 幂等 + 精确撤销帧回调(dispose 后再 tick 不再增帧)。 + * 3. dispose 幂等 + 精确撤销帧回调(dispose 后再 tick 不再增帧); + * 4. Gate0.1 新受控面:getInput 订阅经桥 _emit 驱动 → 探针 inputEvents 递增,dispose 后不再增; + * getAudioContext 无工厂降级(hasAudio=false)/ 注入工厂则 hasAudio=true。 */ import { test } from 'node:test'; @@ -22,11 +24,18 @@ import { PluginRegistry, createHostDevContext } from '../../../core/plugin.js'; // 被测样例插件工厂。 import { createExamplePlugin } from '../impl.js'; +/** + * 测试用 mock 音频上下文工厂:注入它即模拟「host 提供了音频」的常态, + * 使样例 init 时 getAudioContext 拿到非 null 实例、不触发「无工厂」降级告警(保 RESULT.txt 干净)。 + * 「无工厂降级」分支由本文件 §7a + core 测试专门覆盖。 + */ +const mockAudioFactory = () => /** @type {any} */ ({ __mockAudioContext: true }); + /* ── 1. 全流程接纳:register → initAll → disposeAll 不报错 ─────────────────── */ test('空插件样例可被 PluginRegistry 全流程接纳(register/initAll/disposeAll)', () => { const reg = new PluginRegistry(); - const { context } = createHostDevContext({ context2d: null }); + const { context } = createHostDevContext({ context2d: null, audioFactory: mockAudioFactory }); reg.useContext(context); reg.register(createExamplePlugin()); @@ -42,7 +51,7 @@ test('空插件样例可被 PluginRegistry 全流程接纳(register/initAll/di /* ── 2. 受控面:onFrame 被 tick 驱动 → 探针帧数递增 ──────────────────────── */ test('受控面 onFrame:tick 驱动后探针帧数递增、记录最近随机值', () => { - const { context, tick } = createHostDevContext({ context2d: null }); + const { context, tick } = createHostDevContext({ context2d: null, audioFactory: mockAudioFactory }); const plugin = createExamplePlugin(); // 直接 init(不经注册器也可,演示插件 init 只依赖受控面 context)。 plugin.init(context); @@ -64,7 +73,7 @@ test('受控面 onFrame:tick 驱动后探针帧数递增、记录最近随机 test('受控随机源注种子后确定可复现:同种子两实例随机序列一致', () => { function run(seed) { - const { context, tick } = createHostDevContext({ context2d: null }); + const { context, tick } = createHostDevContext({ context2d: null, audioFactory: mockAudioFactory }); const plugin = createExamplePlugin({ seed }); plugin.init(context); const vals = []; @@ -87,14 +96,14 @@ test('受控随机源注种子后确定可复现:同种子两实例随机序 test('getContext2d 容忍 null(node 侧);注入 mock 上下文则探针 hasContext2d=true', () => { // 4a. node 侧 null:hasContext2d=false,init 不抛错。 - const b1 = createHostDevContext({ context2d: null }); + const b1 = createHostDevContext({ context2d: null, audioFactory: mockAudioFactory }); const p1 = createExamplePlugin(); assert.doesNotThrow(() => p1.init(b1.context), 'null 上下文 init 不应抛错'); assert.equal(p1.probe().hasContext2d, false, 'null 上下文时 hasContext2d 应为 false'); // 4b. 注入 mock 2D 上下文:探针 hasContext2d=true(演示集成段有 canvas 时的分支)。 const mockCtx = /** @type {any} */ ({ __mock: true }); - const b2 = createHostDevContext({ context2d: mockCtx }); + const b2 = createHostDevContext({ context2d: mockCtx, audioFactory: mockAudioFactory }); const p2 = createExamplePlugin(); p2.init(b2.context); assert.equal(p2.probe().hasContext2d, true, '注入 mock 上下文时 hasContext2d 应为 true'); @@ -103,7 +112,7 @@ test('getContext2d 容忍 null(node 侧);注入 mock 上下文则探针 ha /* ── 5. dispose 幂等 + 精确撤销帧回调 ──────────────────────────────────────── */ test('dispose 幂等且精确撤销帧回调:dispose 后再 tick 不再增帧', () => { - const { context, tick } = createHostDevContext({ context2d: null }); + const { context, tick } = createHostDevContext({ context2d: null, audioFactory: mockAudioFactory }); const plugin = createExamplePlugin(); plugin.init(context); @@ -121,3 +130,55 @@ test('dispose 幂等且精确撤销帧回调:dispose 后再 tick 不再增帧' '重复 dispose 应安全(幂等)'); assert.equal(plugin.probe().frames, 1, '重复 dispose 不应影响帧计数'); }); + +/* ── 6. Gate0.1 受控输入:getInput 订阅经桥 _emit 驱动;dispose 后精确撤销 ──── */ + +test('受控面 getInput:_emit 驱动后探针 inputEvents 递增;dispose 后不再增(精确撤销)', () => { + // 注入 mock audioFactory 避免 init 时无工厂的降级告警污染输出(顺带演示 host 提供音频)。 + const { context, inputBridge } = createHostDevContext({ + context2d: null, + audioFactory: () => /** @type {any} */ ({ __ac: true }), + }); + const plugin = createExamplePlugin(); + plugin.init(context); + + assert.equal(plugin.probe().inputEvents, 0, 'init 后未 emit,输入事件数为 0'); + + // 桥注入两条 pointerdown(样例只订阅了 pointerdown)。 + inputBridge._emit('pointerdown', { x: 5, y: 6 }); + inputBridge._emit('pointerdown', { x: 7, y: 8 }); + // 注入一条 keydown(样例未订阅 keydown)——不应计入。 + inputBridge._emit('keydown', { key: 'Space' }); + assert.equal(plugin.probe().inputEvents, 2, '应仅计入订阅了的 pointerdown(2 条)'); + + plugin.dispose(); + inputBridge._emit('pointerdown', { x: 9, y: 9 }); + assert.equal(plugin.probe().inputEvents, 2, 'dispose 后订阅已撤销,再 emit 不应增加'); +}); + +/* ── 7. Gate0.1 受控音频:无工厂降级 / 注入工厂则 hasAudio=true ──────────────── */ + +test('受控面 getAudioContext:无工厂时 hasAudio=false(降级),注入工厂则 hasAudio=true', () => { + // 7a. 无工厂:init 时 getAudioContext 返回 null → hasAudio=false(容错降级,init 不抛错)。 + // suppress 一次性降级告警,避免污染输出(核心测试已专门覆盖告警仅一次)。 + const origWarn = console.warn; + console.warn = () => {}; + let p1; + try { + const b1 = createHostDevContext({ context2d: null }); // 无 audioFactory + p1 = createExamplePlugin(); + assert.doesNotThrow(() => p1.init(b1.context), '无音频工厂 init 不应抛错'); + } finally { + console.warn = origWarn; + } + assert.equal(p1.probe().hasAudio, false, '无工厂时 hasAudio 应为 false'); + + // 7b. 注入工厂:init 时 getAudioContext 返回实例 → hasAudio=true(演示集成段有音频时的分支)。 + const b2 = createHostDevContext({ + context2d: null, + audioFactory: () => /** @type {any} */ ({ __ac: true }), + }); + const p2 = createExamplePlugin(); + p2.init(b2.context); + assert.equal(p2.probe().hasAudio, true, '注入工厂时 hasAudio 应为 true'); +}); diff --git a/game-runtime/test/RESULT.txt b/game-runtime/test/RESULT.txt index 09472a61..b793a9ec 100644 --- a/game-runtime/test/RESULT.txt +++ b/game-runtime/test/RESULT.txt @@ -1,8 +1,8 @@ # game-runtime 全量单测原始输出(node --test) # 生成命令:bash scripts/test.sh -# 生成时间:2026-06-12 11:05:40 UTC +# 生成时间:2026-06-12 11:26:28 UTC # node 版本:v24.16.0 -# 覆盖:src/core/plugin.test.mjs(core 17)+ src/plugins/_example/test/example.test.mjs(_example 5) +# 覆盖:src/core/plugin.test.mjs(core 31:17 旧 + Gate0.1 新 14)+ src/plugins/_example/test/example.test.mjs(_example 7:5 旧 + Gate0.1 新 2) # ============================================================================ [test.sh] 仓根:/root/games-development-ai/game-runtime @@ -14,9 +14,9 @@ [PluginRegistry] initAll 重复调用,已忽略(已处于 initialized) [PluginRegistry] 插件 init 抛错(已隔离,不连坐):bad:Error: boom-init:bad - at Object.init (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:51:35) - at PluginRegistry.initAll (file:///root/games-development-ai/game-runtime/src/core/plugin.js:360:16) - at TestContext. (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:176:17) + at Object.init (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:58:35) + at PluginRegistry.initAll (file:///root/games-development-ai/game-runtime/src/core/plugin.js:670:16) + at TestContext. (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:183:17) at Test.runInAsyncScope (node:async_hooks:227:14) at Test.run (node:internal/test_runner/test:1306:25) at Test.processPendingSubtests (node:internal/test_runner/test:897:18) @@ -24,8 +24,8 @@ at Test.run (node:internal/test_runner/test:1372:12) at async Test.processPendingSubtests (node:internal/test_runner/test:897:7) [PluginRegistry] 跳过 init(依赖缺失,已隔离):needsX:Error: [PluginRegistry] 插件 needsX 依赖缺失:x(未注册) - at PluginRegistry.initAll (file:///root/games-development-ai/game-runtime/src/core/plugin.js:348:21) - at TestContext. (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:201:17) + at PluginRegistry.initAll (file:///root/games-development-ai/game-runtime/src/core/plugin.js:657:21) + at TestContext. (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:208:17) at Test.runInAsyncScope (node:async_hooks:227:14) at Test.run (node:internal/test_runner/test:1306:25) at Test.processPendingSubtests (node:internal/test_runner/test:897:18) @@ -33,9 +33,9 @@ at Test.run (node:internal/test_runner/test:1372:12) at async Test.processPendingSubtests (node:internal/test_runner/test:897:7) [PluginRegistry] 插件 dispose 抛错(已隔离):bad:Error: boom-dispose:bad - at p.dispose (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:65:38) - at PluginRegistry.disposeAll (file:///root/games-development-ai/game-runtime/src/core/plugin.js:396:16) - at TestContext. (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:233:17) + at p.dispose (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:72:38) + at PluginRegistry.disposeAll (file:///root/games-development-ai/game-runtime/src/core/plugin.js:731:18) + at TestContext. (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:240:17) at Test.runInAsyncScope (node:async_hooks:227:14) at Test.run (node:internal/test_runner/test:1306:25) at Test.processPendingSubtests (node:internal/test_runner/test:897:18) @@ -43,54 +43,92 @@ at Test.run (node:internal/test_runner/test:1372:12) at async Test.processPendingSubtests (node:internal/test_runner/test:897:7) [host-dev tick] 帧回调抛错(已隔离)frame=1:Error: frame-boom - at file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:313:33 - at tick (file:///root/games-development-ai/game-runtime/src/core/plugin.js:222:11) - at file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:316:31 + at file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:320:33 + at tick (file:///root/games-development-ai/game-runtime/src/core/plugin.js:481:11) + at file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:323:31 at getActual (node:assert:611:5) at strict.doesNotThrow (node:assert:779:32) - at TestContext. (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:316:10) + at TestContext. (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:323:10) at Test.runInAsyncScope (node:async_hooks:227:14) at Test.run (node:internal/test_runner/test:1306:25) at Test.processPendingSubtests (node:internal/test_runner/test:897:18) at Test.postRun (node:internal/test_runner/test:1447:19) [host-dev tick] 帧回调抛错(已隔离)frame=2:Error: frame-boom - at file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:313:33 - at tick (file:///root/games-development-ai/game-runtime/src/core/plugin.js:222:11) - at file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:316:44 + at file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:320:33 + at tick (file:///root/games-development-ai/game-runtime/src/core/plugin.js:481:11) + at file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:323:44 at getActual (node:assert:611:5) at strict.doesNotThrow (node:assert:779:32) - at TestContext. (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:316:10) + at TestContext. (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:323:10) at Test.runInAsyncScope (node:async_hooks:227:14) at Test.run (node:internal/test_runner/test:1306:25) at Test.processPendingSubtests (node:internal/test_runner/test:897:18) at Test.postRun (node:internal/test_runner/test:1447:19) -✔ 注册成功 + list/has/get 反映注册态 (5.058605ms) -✔ 重名注册抛错拒绝 (1.550407ms) -✔ 形状非法注册抛错(缺 name/version/init、dispose 非函数) (1.225692ms) -✔ init 按注册正序,dispose 按注册逆序(后进先出) (2.021418ms) -✔ disposeAll 幂等:重复调用不重复触发 dispose (2.878961ms) -✔ initAll 幂等:重复 init 不重复触发 init (1.992275ms) -✔ dispose 后再 initAll 抛错(生命周期非法转移) (0.852558ms) -✔ 单插件 init 抛错被隔离,其它插件照常 init (2.004362ms) -✔ 依赖缺失被隔离,不连坐其它插件 (4.294303ms) -✔ 依赖已注册则正常 init(声明依赖校验通过) (0.715983ms) -✔ 单插件 dispose 抛错被隔离,其它 dispose 照常 (0.794698ms) -✔ SeededRandom 确定性:同种子同序列,reseed 复位 (3.065459ms) -✔ HostDevTime:nowMs 走注入时钟,elapsedMs 自起点递增 (0.314592ms) -✔ getContext2d:注入 null 时返回 null(node 侧无 canvas 可容忍) (0.501272ms) -✔ onFrame + tick:帧回调被驱动,frame 从 1 单调,dt 透传 (0.641373ms) -✔ FrameHandle.cancel 注销帧回调 (0.476426ms) -✔ 帧回调抛错被隔离:不连坐同帧其它回调,也不中断后续帧 (1.18532ms) -✔ 空插件样例可被 PluginRegistry 全流程接纳(register/initAll/disposeAll) (7.965439ms) -✔ 受控面 onFrame:tick 驱动后探针帧数递增、记录最近随机值 (0.9007ms) -✔ 受控随机源注种子后确定可复现:同种子两实例随机序列一致 (1.129704ms) -✔ getContext2d 容忍 null(node 侧);注入 mock 上下文则探针 hasContext2d=true (0.679949ms) -✔ dispose 幂等且精确撤销帧回调:dispose 后再 tick 不再增帧 (0.636861ms) -ℹ tests 22 +[host-dev input] 输入 handler 抛错(已隔离)type=pointerdown:Error: input-boom + at Object.handler (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:442:41) + at HostDevInputBridge._emit (file:///root/games-development-ai/game-runtime/src/core/plugin.js:312:15) + at file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:445:43 + at getActual (node:assert:611:5) + at strict.doesNotThrow (node:assert:779:32) + at TestContext. (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:445:10) + at Test.runInAsyncScope (node:async_hooks:227:14) + at Test.run (node:internal/test_runner/test:1306:25) + at Test.processPendingSubtests (node:internal/test_runner/test:897:18) + at Test.postRun (node:internal/test_runner/test:1447:19) +[host-dev input] 输入 handler 抛错(已隔离)type=pointerdown:Error: input-boom + at Object.handler (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:442:41) + at HostDevInputBridge._emit (file:///root/games-development-ai/game-runtime/src/core/plugin.js:312:15) + at file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:445:81 + at getActual (node:assert:611:5) + at strict.doesNotThrow (node:assert:779:32) + at TestContext. (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:445:10) + at Test.runInAsyncScope (node:async_hooks:227:14) + at Test.run (node:internal/test_runner/test:1306:25) + at Test.processPendingSubtests (node:internal/test_runner/test:897:18) + at Test.postRun (node:internal/test_runner/test:1447:19) +✔ 注册成功 + list/has/get 反映注册态 (8.102551ms) +✔ 重名注册抛错拒绝 (1.760719ms) +✔ 形状非法注册抛错(缺 name/version/init、dispose 非函数) (1.489075ms) +✔ init 按注册正序,dispose 按注册逆序(后进先出) (6.242631ms) +✔ disposeAll 幂等:重复调用不重复触发 dispose (3.960417ms) +✔ initAll 幂等:重复 init 不重复触发 init (2.555656ms) +✔ dispose 后再 initAll 抛错(生命周期非法转移) (0.997158ms) +✔ 单插件 init 抛错被隔离,其它插件照常 init (2.066683ms) +✔ 依赖缺失被隔离,不连坐其它插件 (1.518294ms) +✔ 依赖已注册则正常 init(声明依赖校验通过) (1.263549ms) +✔ 单插件 dispose 抛错被隔离,其它 dispose 照常 (1.127486ms) +✔ SeededRandom 确定性:同种子同序列,reseed 复位 (0.992655ms) +✔ HostDevTime:nowMs 走注入时钟,elapsedMs 自起点递增 (1.546431ms) +✔ getContext2d:注入 null 时返回 null(node 侧无 canvas 可容忍) (0.59474ms) +✔ onFrame + tick:帧回调被驱动,frame 从 1 单调,dt 透传 (0.856982ms) +✔ FrameHandle.cancel 注销帧回调 (0.664724ms) +✔ 帧回调抛错被隔离:不连坐同帧其它回调,也不中断后续帧 (1.526119ms) +✔ getInput 订阅:on 收到归一化事件,坐标/键码/时间戳正确(时间戳取自受控时间源) (1.107895ms) +✔ getInput.off / 句柄 cancel 退订:退订后不再收事件 (0.882527ms) +✔ 插件经 getInput 注册的订阅,dispose 时由注册器自动清理(防泄漏) (0.931805ms) +✔ 多插件各自的输入 scope 独立回收:dispose 一个不误伤其它(但本波整体 disposeAll 全回收) (0.564589ms) +✔ getInput 非法事件类型拒绝(防误用裸 DOM 事件名) (0.928869ms) +✔ getInput 单个 handler 抛错被隔离:不连坐同类型其它 handler (1.121242ms) +✔ getAudioContext 无工厂:返回 null 且仅告警一次(P6 容错降级,不抛错) (0.587583ms) +✔ getAudioContext 有工厂:lazy 创建 + 单例(多次调用返回同一实例,工厂只造一次) (0.45052ms) +✔ per-plugin 派生随机:两插件各取随机序列互不影响 + 同 seed 复现 (1.088156ms) +✔ per-plugin 派生随机:插件 A 先取随机不推进插件 B 的序列(独立性证明) (0.884436ms) +✔ deriveSeed 确定性:同(主种子,插件名)同子种子;异名/异主种子异子种子 (0.345888ms) +✔ 忘调 useContext 即 initAll:抛含修复提示的友好错误(非裸 TypeError) (0.617065ms) +✔ useContext 传 null/非对象:抛友好错误 (0.652191ms) +✔ useContext 在 initAll 之后调用:抛错并点出当前阶段 (0.503247ms) +✔ 空插件样例可被 PluginRegistry 全流程接纳(register/initAll/disposeAll) (8.867701ms) +✔ 受控面 onFrame:tick 驱动后探针帧数递增、记录最近随机值 (1.335529ms) +✔ 受控随机源注种子后确定可复现:同种子两实例随机序列一致 (1.654138ms) +✔ getContext2d 容忍 null(node 侧);注入 mock 上下文则探针 hasContext2d=true (0.816929ms) +✔ dispose 幂等且精确撤销帧回调:dispose 后再 tick 不再增帧 (0.623579ms) +✔ 受控面 getInput:_emit 驱动后探针 inputEvents 递增;dispose 后不再增(精确撤销) (0.909204ms) +✔ 受控面 getAudioContext:无工厂时 hasAudio=false(降级),注入工厂则 hasAudio=true (0.905139ms) +ℹ tests 38 ℹ suites 0 -ℹ pass 22 +ℹ pass 38 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 -ℹ duration_ms 211.577139 +ℹ duration_ms 280.755392