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:
parent
f59991b9f3
commit
6f854851f5
@ -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/
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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` 的字段与含义(无则写「无配置」)。
|
||||
|
||||
|
||||
135
game-runtime/src/core/api.d.ts
vendored
135
game-runtime/src/core/api.d.ts
vendored
@ -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<InputEventType>;
|
||||
|
||||
/** 协议版本标识(冻结时 = 'core-protocol-v0')。 */
|
||||
export declare const CORE_PROTOCOL_VERSION: string;
|
||||
|
||||
@ -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 {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] 未注入 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<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 内裸 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';
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
|
||||
@ -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 声明
|
||||
|
||||
|
||||
10
game-runtime/src/plugins/_example/api.d.ts
vendored
10
game-runtime/src/plugins/_example/api.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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.<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(依赖缺失,已隔离):needsX:Error: [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 抛错(已隔离):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.<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=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.<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=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.<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)
|
||||
✔ 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.<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=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.<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)
|
||||
✔ 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user