feat(game-runtime): core-protocol-v0 冻结(Gate0.1 受控面补丁,38/38 绿)

- 受控面 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 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-12 11:28:52 +00:00
parent f59991b9f3
commit 6f854851f5
11 changed files with 1059 additions and 129 deletions

View File

@ -12,12 +12,13 @@
| 项 | 值 |
|---|---|
| 当前协议版本 | **core-protocol-v0** |
| 当前协议版本 | **v02026-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/+getAudioContextrandom 改 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 # 核心零依赖单测17node --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/

View File

@ -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 单例、§8per-plugin 派生随机:独立性/复现/deriveSeed 确定性、§9useContext 三守卫)——计 14 新测 |
| `src/plugins/_example/{impl.js,api.d.ts,PLUGIN.md,test/example.test.mjs}` | 样板演示新两面init 订阅 `pointerdown`(句柄留 dispose 注销)+ lazy 取音频(容忍 nullprobe 增 `inputEvents`/`hasAudio`;新增 2 例测(输入驱动/音频降级与注入);既有 5 例测注入 mock `audioFactory` 保 RESULT.txt 干净 |
| `README.md` | 协议版本声明位填「v02026-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()` → lazyhost 注入工厂,首调即创建、后续返回同一实例(全 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`

View File

@ -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` 的字段与含义(无则写「无配置」)。

View File

@ -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 {
/** 订阅某类输入事件;返回 InputSubscriptioncancel() 注销。 */
on(type: InputEventType, handler: (e: InputEvent) => void): InputSubscription;
/** 退订:移除该 type 上等值的 handler移除第一处匹配未订阅过则静默无操作。 */
off(type: InputEventType, handler: (e: InputEvent) => void): void;
}
/*
* PluginContext 6 YAGNIGate0.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<InputEventType>;
/** 协议版本标识(冻结时 = 'core-protocol-v0')。 */
export declare const CORE_PROTOCOL_VERSION: string;

View File

@ -18,12 +18,22 @@
* 因此本协议只提供能力编排 + 受控引擎句柄绝不内置任何玩法假设
*
* 三条核心协议取舍详见 REPORT.md
* 1. 受控面宁小勿大YAGNIPluginContext 只暴露 canvas/帧回调/时间源/随机源 4 项最小面
* 禁直透 littlejsengine 裸对象引擎可换其余 lane 按需走扩展申请不在 Gate0 预扩
* 1. 受控面宁小勿大YAGNIPluginContext 只暴露最小受控面禁直透 littlejsengine 裸对象
* 引擎可换Gate0.1 据具名消费者P4 输入缓冲 / P10 t_input_bound / P6 音乐扩两项
* getInput受控事件订阅面/ getAudioContextlazy 音频上下文容错降级其余仍按需走扩展申请
* 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 最小受控面YAGNIGate0.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 gameUpdatehost-dev =手动 tick
* 回调入参dtSeconds=本帧时间步已做封顶钳制由 host 负责frame=单调帧序号 1
* 返回 FrameHandlecancel() 注销**插件禁止直接 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 AudioContexthost-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.1host 把原生事件归一化为本形状后经事件桥分发
* 插件只见归一化字段不见原生 DOM Event保受控
* @property {InputEventType} type 事件类型5 枚举之一
* @property {number} x 归一化指针横坐标指针类事件有意义键盘事件为 0host 约定坐标系通常画布逻辑像素
* @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
* 订阅某类输入事件返回 InputSubscriptioncancel() 注销同一 handler 重复订阅同一 type 会注册多次 DOM 语义异调用方自律
* @property {(type: InputEventType, handler: (e: InputEvent) => void) => void} off
* 退订移除该 type 上等值的 handler移除第一处匹配handler 未订阅过则静默无操作
*/
/**
@ -164,6 +218,152 @@ class HostDevTime {
}
}
/**
* host-dev 输入事件桥Gate0.1InputSource 的本机/集成前实现
*
* 职责
* - 给插件暴露受控订阅面 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 {Map<InputEventType, Array<{handler:(e:InputEvent)=>void, 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<string>}
*/
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 {Map<number, (dt:number, frame:number)=>void>} */
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] 未注入 audioFactorygetAudioContext 返回 nullP6 须容错降级;集成段注入真实工厂)');
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 注入的受控上下文(经 useContextundefined=未注入initAll 时给友好守卫)。
/** @type {PluginContext|undefined} */
this._context = undefined;
// Gate0.1每插件派生上下文表name → 该插件专属 contextrandom 独立 + 输入 scope 化)。
// 用于 init 时分发、dispose 时定向回收该插件的输入订阅(防泄漏)。
/** @type {Map<string, PluginContext>} */
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 内裸 TypeErrorctx 为 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) 为本插件取受控上下文交给 initGate0.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_TYPESSet。 */
export const INPUT_EVENT_TYPE_LIST = Object.freeze(Array.from(INPUT_EVENT_TYPES));
/** 协议版本标识(冻结时 = core-protocol-v0。 */
export const CORE_PROTOCOL_VERSION = 'core-protocol-v0';

View File

@ -2,13 +2,17 @@
* plugin.test.mjs core/plugin.js 零依赖 node 单测node --test 直跑
* ownerT1b-α lane-core 运行node --test src/core/plugin.test.mjs
*
* 覆盖执行版 §2core 注册器自身带零依赖 node 测试四项 + 受控面 + host-dev tick
* 覆盖执行版 §2core 注册器自身带零依赖 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驱动 onFrameframe 单调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 = [];
// 插件 Ainit 时订阅两类输入(不在 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=2a 不再增
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, 'lazygetAudioContext 未调用前不创建');
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, '同主 seedrand-a 子流复现');
assert.deepEqual(r1.seqB, r2.seqB, '同主 seedrand-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/);
});

View File

@ -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-devnode 侧 context2d 传 null 可)。
const { context, tick } = createHostDevContext({ context2d: null });
// 1) host 造受控上下文host-devnode 侧 context2d 传 nullaudioFactory选,缺省则音频降级为 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 声明

View File

@ -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 侧通常 falseGate0.1)。 */
hasAudio: boolean;
/** 是否已 dispose幂等观察用。 */
disposed: boolean;
}

View File

@ -14,9 +14,12 @@
* 它演示了什么给克隆者看
* 1. 工厂模式createExamplePlugin(opts) 返回插件对象而非裸对象字面量
* 便于带配置便于闭包封装内部状态生产插件普遍如此
* 2. 受控面四项用法getContext2d容忍 null 降级/ onFrame拿句柄dispose cancel/
* 2. 受控面六项用法Gate0.1 4 6getContext2d容忍 null 降级/ onFrame拿句柄dispose cancel/
* getInput订阅输入事件拿订阅句柄dispose cancel/ getAudioContextlazy 容忍 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.1lazy 取一次。无工厂的 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,
};
},

View File

@ -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('受控面 onFrametick 驱动后探针帧数递增、记录最近随机值', () => {
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('受控面 onFrametick 驱动后探针帧数递增、记录最近随机
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 容忍 nullnode 侧);注入 mock 上下文则探针 hasContext2d=true', () => {
// 4a. node 侧 nullhasContext2d=falseinit 不抛错。
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 容忍 nullnode 侧);注入 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, '应仅计入订阅了的 pointerdown2 条)');
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');
});

View File

@ -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.mjscore 17+ src/plugins/_example/test/example.test.mjs_example 5
# 覆盖src/core/plugin.test.mjscore 3117 旧 + Gate0.1 新 14+ src/plugins/_example/test/example.test.mjs_example 75 旧 + Gate0.1 新 2
# ============================================================================
[test.sh] 仓根:/root/games-development-ai/game-runtime
@ -14,9 +14,9 @@
[PluginRegistry] initAll 重复调用,已忽略(已处于 initialized
[PluginRegistry] 插件 init 抛错已隔离不连坐badError: 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.<anonymous> (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.<anonymous> (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依赖缺失已隔离needsXError: [PluginRegistry] 插件 needsX 依赖缺失x未注册
at PluginRegistry.initAll (file:///root/games-development-ai/game-runtime/src/core/plugin.js:348:21)
at TestContext.<anonymous> (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.<anonymous> (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 抛错已隔离badError: 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.<anonymous> (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.<anonymous> (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=1Error: 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.<anonymous> (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:316:10)
at TestContext.<anonymous> (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=2Error: 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.<anonymous> (file:///root/games-development-ai/game-runtime/src/core/plugin.test.mjs:316:10)
at TestContext.<anonymous> (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)
✔ HostDevTimenowMs 走注入时钟elapsedMs 自起点递增 (0.314592ms)
✔ getContext2d注入 null 时返回 nullnode 侧无 canvas 可容忍) (0.501272ms)
✔ onFrame + tick帧回调被驱动frame 从 1 单调dt 透传 (0.641373ms)
✔ FrameHandle.cancel 注销帧回调 (0.476426ms)
✔ 帧回调抛错被隔离:不连坐同帧其它回调,也不中断后续帧 (1.18532ms)
✔ 空插件样例可被 PluginRegistry 全流程接纳register/initAll/disposeAll (7.965439ms)
✔ 受控面 onFrametick 驱动后探针帧数递增、记录最近随机值 (0.9007ms)
✔ 受控随机源注种子后确定可复现:同种子两实例随机序列一致 (1.129704ms)
✔ getContext2d 容忍 nullnode 侧);注入 mock 上下文则探针 hasContext2d=true (0.679949ms)
✔ dispose 幂等且精确撤销帧回调dispose 后再 tick 不再增帧 (0.636861ms)
tests 22
[host-dev input] 输入 handler 抛错已隔离type=pointerdownError: 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.<anonymous> (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=pointerdownError: 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.<anonymous> (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)
✔ HostDevTimenowMs 走注入时钟elapsedMs 自起点递增 (1.546431ms)
✔ getContext2d注入 null 时返回 nullnode 侧无 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)
✔ 受控面 onFrametick 驱动后探针帧数递增、记录最近随机值 (1.335529ms)
✔ 受控随机源注种子后确定可复现:同种子两实例随机序列一致 (1.654138ms)
✔ getContext2d 容忍 nullnode 侧);注入 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