feat(game-runtime): T1b-α Gate0 七件落地(core-protocol-v0-rc)
- core 协议:Plugin 接口/注册器(重名拒绝/生命周期正序逆序/dispose幂等/错误隔离不连坐)+受控面 PluginContext(禁直透 littlejsengine)——前任agent核心设计17测绿收编(死于收尾的工具调用解析失败,续作字节级未碰) - _example 空插件样例五件全(克隆母本)+PLUGIN-TEMPLATE 六节规格+manifest schema+零依赖校验器(负例自检逮6违规) - scripts:esbuild锁参build/增量法size/test.sh遍历;browser-evidence harness纯计算口径冻结(FNV-1a/直方图/几何,CDP四函数=集成段占位) - 22/22 测全绿(RESULT.txt 留档);ESM JS+JSDoc+手写d.ts 零工具链纪律全程 - rc 状态:待 Gate0.1 受控面补丁(input/audio+per-plugin派生RNG+useContext守卫)后冻结 core-protocol-v0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8d3cf1383f
commit
f59991b9f3
95
game-runtime/README.md
Normal file
95
game-runtime/README.md
Normal file
@ -0,0 +1,95 @@
|
||||
# game-runtime —— LittleJS 增强发行版(插件协议地基)
|
||||
|
||||
> **协议版本:`core-protocol-v0`**(见 `src/core/plugin.js` 导出的 `CORE_PROTOCOL_VERSION`)。
|
||||
> owner:T1b-α lane-core(Gate0 奠基)。配对 spec:`docs/agent-specs/2026-06-12-T1b-α插件库与参考件-execution.md`(v1.1)/ `…W-T1b-Runner与双层模板-review.md`(v2.1)。
|
||||
|
||||
平台维护一套「LittleJS 增强发行版」:每个**插件 = 一项引擎能力**(碰撞/粒子/物理/手感/调色/音频…),即插即用、可组合、版本化。
|
||||
**插件不含玩法、不含美术成品、不含关卡、不含 UI —— 这四者是 agent 造游戏时的生成域。** 本仓只提供「能力编排 + 受控引擎句柄」,绝不内置任何玩法假设。
|
||||
|
||||
---
|
||||
|
||||
## 协议版本声明
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 当前协议版本 | **core-protocol-v0** |
|
||||
| 标识来源 | `src/core/plugin.js` → `export const CORE_PROTOCOL_VERSION = 'core-protocol-v0'`;`schema/plugin-manifest.schema.json` → `$id .../v0` |
|
||||
| 冻结状态 | Gate0 评审通过即冻结;冻结后受控面/Plugin 接口/manifest 字段的任何变更 = 新版本号 + 走评审 |
|
||||
| 升级规则 | 受控面只增不破坏既有 API;破坏性变更须 bump 版本(v0→v1)并在本表登记 |
|
||||
|
||||
> **扩展受控面 / 改 manifest 字段 = 改协议**,必须走 Gate0 后的扩展申请,不得 lane 私自加。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
game-runtime/
|
||||
├── README.md # 本文件:目录 + 证据约定 + 协议版本声明位
|
||||
├── src/
|
||||
│ ├── core/ # 协议核心(core-protocol-v0,已冻结对象)
|
||||
│ │ ├── plugin.js # Plugin 接口 / PluginRegistry 生命周期 / PluginContext 受控面 + host-dev 桩
|
||||
│ │ ├── api.d.ts # 手写类型门面(lane 读契约的一等入口,与 plugin.js JSDoc 同步)
|
||||
│ │ └── plugin.test.mjs # 核心零依赖单测(17 测,node --test)
|
||||
│ └── plugins/
|
||||
│ └── _example/ # 空插件样例(五件全,可直接克隆为新插件)
|
||||
│ ├── impl.js # ESM 实现(演示受控面用法,无玩法语义)
|
||||
│ ├── api.d.ts # 该插件类型门面
|
||||
│ ├── manifest.json # 插件机器契约(过 schema 校验)
|
||||
│ ├── PLUGIN.md # 给 agent 读的能力文档(PLUGIN-TEMPLATE 活样板)
|
||||
│ └── test/example.test.mjs # 该插件零依赖单测(5 测)
|
||||
├── schema/
|
||||
│ └── plugin-manifest.schema.json # manifest.json 契约(additionalProperties:false)
|
||||
├── docs/
|
||||
│ └── PLUGIN-TEMPLATE.md # PLUGIN.md 规格模板(六节固定骨架)
|
||||
├── scripts/
|
||||
│ ├── build.mjs # esbuild 锁参打包(bundle+min+iife+es2019)——⚠️ 仅集成段跑
|
||||
│ ├── size.mjs # 增量法字节测量(zlib gz level9)→ SIZES.md ——⚠️ 打包部分仅集成段
|
||||
│ ├── validate-manifest.mjs # 零依赖 manifest schema 校验(本机可跑)
|
||||
│ └── test.sh # 遍历 core+全插件 node --test(本机可跑)
|
||||
└── test/
|
||||
└── harness/
|
||||
└── browser-evidence.cjs # 浏览器视觉证据 harness(纯计算口径已实现 + CDP 占位,⚠️ 真跑仅集成段)
|
||||
```
|
||||
|
||||
> 下列目录在对应阶段产生,本阶段尚未创建:`src/plugins/<P1..P10>/`(各 lane 插件)、`games/`(参考件)、`host-dev/`(本地挂载页)、`dist/`(集成段构建产物)、`evidence/`(集成段/参考件证据)、`SIZES.md`(集成段 size.mjs 产出)。
|
||||
|
||||
---
|
||||
|
||||
## 源码形态与运行纪律
|
||||
|
||||
- **源码形态**:发行版 = **ESM JS + JSDoc 类型注释**,公开能力面 = **手写 `api.d.ts`**。**零工具链**——lane 阶段 `node --test` 本地直跑,本机零 npm 依赖、不跑 git。
|
||||
- **本机可跑**(lane 阶段):
|
||||
- `bash scripts/test.sh` —— 跑 core + 全插件单测;
|
||||
- `node scripts/validate-manifest.mjs` —— 校验全部 `manifest.json` 合 schema;
|
||||
- `node scripts/size.mjs --selftest` —— gz 量算口径自检。
|
||||
- **仅集成段(mini-desktop)跑**(需 esbuild / 真 Chrome):
|
||||
- `node scripts/build.mjs <in> <out>` —— 锁参打包;
|
||||
- `node scripts/size.mjs` —— 增量法实测 → `SIZES.md`;
|
||||
- `test/harness/browser-evidence.cjs` 的 CDP 真跑(连 Chrome 抓 ImageData/截图)。本机(6c6g)禁启 headless Chrome(OOM 红线)。
|
||||
|
||||
---
|
||||
|
||||
## 证据与目录约定(沿 T1 结构)
|
||||
|
||||
| 阶段 | 证据落点 | 内容 |
|
||||
|---|---|---|
|
||||
| 每 lane | `<lane>/REPORT.md` | 实现说明 + 边界 + 红线声明 |
|
||||
| 每 lane | `<lane>/test/RESULT.txt` | `node --test` 原始输出 |
|
||||
| 每插件 | `<plugin>/PLUGIN.md` | 给 agent 读的能力文档(六节,见 `docs/PLUGIN-TEMPLATE.md`) |
|
||||
| 集成段 | `SIZES.md` + `evidence/integration/` | 构建日志 / 浏览器证据哈希 + 截图 |
|
||||
| 参考件 | `evidence/round-N/` + `LOOP-LOG.md` + `sha256-manifest.txt` | 四件套(输入轨迹/截图+canvas 哈希/probe JSONL/checklist)+ serve 日志 |
|
||||
|
||||
- **截图路径约定**:`evidence/<bucket>/<name>.png`(集成段 bucket=`integration`,参考件 bucket=`round-N`),由 `browser-evidence.cjs` 的 `screenshotPath()` 统一给出。
|
||||
- **哈希族**:全发行版证据用 **FNV-1a 32-bit**(ImageData 哈希与 P10 RuntimeProbe 哈希链同口径)。
|
||||
- **字节预算**:每插件 `manifest.json` 声 `gzBudgetBytes`,集成段 `size.mjs` **增量法**实测对照;单件超配额→评审,合计>60K→创始人放行(执行版 §5)。
|
||||
|
||||
---
|
||||
|
||||
## 给 lane 开工者的最短路径
|
||||
|
||||
1. 读 `src/core/api.d.ts`(协议类型契约,一等入口)+ `src/core/plugin.js`(实现与受控面注释)。
|
||||
2. 复制 `src/plugins/_example/` 整目录 → 改名为你的插件 → 按 `docs/PLUGIN-TEMPLATE.md` 六节改写 `PLUGIN.md` → 改 `manifest.json`(`node scripts/validate-manifest.mjs` 必须过)。
|
||||
3. 实现 `impl.js`:只经 `PluginContext` 4 项受控面拿引擎能力,**绝不直透 littlejsengine 裸对象**。
|
||||
4. 写 `test/*.test.mjs`:零依赖 `node:test`,经 `createHostDevContext` 拿受控面 + `tick` 驱动帧(vfx/audio 的渲染/发声证据留集成段 harness)。
|
||||
5. `bash scripts/test.sh` 全绿后,产 `REPORT.md` + `test/RESULT.txt`。
|
||||
107
game-runtime/REPORT.md
Normal file
107
game-runtime/REPORT.md
Normal file
@ -0,0 +1,107 @@
|
||||
# Gate0 报告 —— T1b-α lane-core 插件协议地基(core-protocol-v0)
|
||||
|
||||
> owner:T1b-α lane-core(续作工位)|状态:Gate0 七件齐备,待主会话审 → 冻结为 `core-protocol-v0`。
|
||||
> 配对 spec:`docs/agent-specs/2026-06-12-T1b-α插件库与参考件-execution.md`(v1.1 §2)。
|
||||
> 纪律:ESM JS + JSDoc + 手写 d.ts,零 npm 依赖,本机只跑 `node`,不跑 git,不碰 game-runtime/ 外文件。
|
||||
|
||||
---
|
||||
|
||||
## 1. 七件清单状态(执行版 §2)
|
||||
|
||||
| # | 件 | 路径 | 状态 | 验证 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Plugin 接口 + 注册器 + 受控面 + d.ts | `src/core/plugin.js` + `src/core/api.d.ts` | 前任交付(收编) | core 17 测全绿 |
|
||||
| 2 | manifest schema | `schema/plugin-manifest.schema.json` | 前任交付(收编) | 正反向校验通过(见 §4) |
|
||||
| 3 | `_example` 空插件(五件全) | `src/plugins/_example/{impl.js,api.d.ts,manifest.json,PLUGIN.md,test/example.test.mjs}` | 本工位补齐 | _example 5 测全绿 + manifest 过 schema |
|
||||
| 4 | PLUGIN.md 规格模板 | `docs/PLUGIN-TEMPLATE.md` | 本工位补齐 | `_example/PLUGIN.md` 即其活样板 |
|
||||
| 5 | scripts(build/size/test + 校验) | `scripts/{build.mjs,size.mjs,test.sh,validate-manifest.mjs}` | 本工位补齐 | test.sh/validate 本机跑绿;size `--selftest` 绿;build/size 打包段仅集成段 |
|
||||
| 6 | 浏览器证据 harness 骨架 | `test/harness/browser-evidence.cjs` | 本工位补齐(纯计算口径已实现 + CDP 占位) | 纯计算口径自检通过;CDP 真跑留集成段 |
|
||||
| 7 | 目录与证据约定 README | `README.md` | 本工位补齐 | 含协议版本声明位 + 证据约定表 |
|
||||
|
||||
**附加件(超出七件、为守纪律而加)**:`scripts/validate-manifest.mjs`(零依赖 manifest 校验器,替代 ajv 守「本机零依赖」纪律)。
|
||||
|
||||
> 命名取舍:执行版 §2 第3条示例文件名给的是 `test/`,prompt 允许 `.cjs 或 .mjs`。本工位选 **`test/example.test.mjs`**(ESM),与 core 的 `plugin.test.mjs` 同形、与 `impl.js` 的 ESM 导出最自然对接。harness 按执行版明确要求用 **`.cjs`**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 前任 4 件收编声明
|
||||
|
||||
本工位以前任落的 4 件为**既成权威**,逐件精读后**未改一字**,所有补件严格对齐其 API/风格/约定:
|
||||
|
||||
- `src/core/plugin.js`(注册器/生命周期,17 测全绿)—— 收编,作为协议唯一实现源。
|
||||
- `src/core/api.d.ts` —— 收编,作为类型契约一等入口;`_example/api.d.ts` 用 `import type { Plugin, PluginContext } from '../../core/api.d.ts'` 复用其类型,不重复定义。
|
||||
- `src/core/plugin.test.mjs` —— 收编,本工位重跑确认 17/17 绿(见 `test/RESULT.txt`)。
|
||||
- `schema/plugin-manifest.schema.json` —— 收编,`_example/manifest.json` 与 `validate-manifest.mjs` 均以它为契约源。
|
||||
|
||||
**无冲突**:本工位补件与 4 件无任何 API/字段冲突。`_example/manifest.json` 字段(name/version/gzBudgetBytes/dependencies/exports{impl,types,named}/description)与 schema 实际字段逐项对齐 —— 即 prompt 所给「前任死前草稿」与 schema 校验对齐后的落地版(草稿值全部合法,已采纳)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三个关键协议取舍(从既有 api.d.ts / plugin.js 反读 + 本工位判断)
|
||||
|
||||
### 取舍一:受控面宁小勿大(YAGNI)—— PluginContext 只 4 项
|
||||
**反读依据**:`api.d.ts` 的 `PluginContext` 仅声明 `getContext2d / onFrame / time / random` 四项,且注释钉死「插件能从引擎拿到的**全部**能力,仅此 4 项」「绝不暴露 littlejsengine 裸类型——保引擎可换」。
|
||||
**取舍**:能力面做减法而非加法;插件要更多能力须走 Gate0 后扩展申请,不在地基预扩。
|
||||
**代价/收益**:代价 = lane 可能短期觉得不够用(如缺直接的输入/音频句柄);收益 = 「引擎可换」(换引擎只换 host 实现,插件零改)+ 受控面是取证可复现的唯一闸口(time/random 全可 mock)。本工位 `_example` 严格只用这 4 项,作减法纪律的活证。
|
||||
|
||||
### 取舍二:错误隔离不连坐 —— 单插件崩不带崩全局
|
||||
**反读依据**:`api.d.ts` 的 `InitResult.errors` 是数组(`Array<{name,error}>`)而非「首错即抛」;`initAll` 注释「重复调用幂等」「依赖声明校验」;`plugin.js` 的 `initAll` 对单插件 init 抛错 `try/catch` 记 `_initErrors` 后 `continue`,host-dev `tick` 对帧回调抛错亦逐个隔离。
|
||||
**取舍**:init/dispose/帧回调三处都「单点故障隔离,记错误继续」,不回滚已 init 的插件(回滚语义复杂,留 v2 边界)。
|
||||
**代价/收益**:代价 = 部分 init 失败时系统处于「部分可用」态(须靠 `InitResult.ok=false` + `errors` 显式感知,不能假设全成功);收益 = 取证/运行不被单插件崩溃带垮,符合「可调试、可审计」红线。**lane 须注意**:插件不能假设「同批其它插件都 init 成功了」,跨插件依赖要靠 `dependencies` 声明 + 运行时防御。
|
||||
|
||||
### 取舍三:生命周期严格有序 + dispose 幂等,但依赖不做拓扑重排
|
||||
**反读依据**:`api.d.ts` 注释「init 正序/dispose 逆序」「dispose 逆序、幂等」「本波不做拓扑重排(注册顺序即 init 顺序)」;`plugin.js` 的 `disposeAll` 用 `_disposed` 标记保幂等、逆序遍历 `_initOrder`,`_missingDeps` 只校验「声明依赖是否已注册」不重排。
|
||||
**取舍**:注册顺序 = init 顺序(正序)、dispose 逆序(后进先出,符合资源释放直觉);`dependencies` 仅作「缺失即隔离」的声明式校验,**不**自动拓扑排序。
|
||||
**代价/收益**:代价 = 有依赖关系的插件**必须由 host 按正确顺序 register**(先注册被依赖者),地基不替你排;收益 = 地基最小(Gate0 骨架不背拓扑排序复杂度),幂等 dispose 防重复释放。**lane 须注意**:若你的插件 A 依赖 B,务必保证 register(B) 在 register(A) 之前,否则 A 会因依赖缺失被隔离。
|
||||
|
||||
---
|
||||
|
||||
## 4. schema 校验证据
|
||||
|
||||
```
|
||||
正向:node scripts/validate-manifest.mjs
|
||||
OK src/plugins/_example/manifest.json [example@1.0.0, gzBudget=2048]
|
||||
共 1 件,通过 1,失败 0
|
||||
|
||||
反向自检(故意构造非法 manifest,校验器须拒绝,防空过假阳性):
|
||||
逮住 6 处违规:未声明字段 evil / name 非 kebab-case / version 非 semver /
|
||||
gzBudgetBytes 超 65536 / exports 缺 types / exports.impl 非 .js -> 退出码 1(正确拦截)
|
||||
```
|
||||
|
||||
> 校验器为零依赖手写(守「本机零 npm 依赖」纪律),覆盖本 schema 实际用到的全部约束(required / additionalProperties:false / type / pattern / min-max / uniqueItems)。集成段如需 draft-07 全量校验可另接 ajv(属工具链,集成段跑)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 其余 lane 开工前,主会话该审的 3 个重点
|
||||
|
||||
### 重点一:受控面够不够(YAGNI 边界是否需在 Gate0 松动)
|
||||
seed 子集 lane(lane-math/vfx/sys)的真实需求是否能被 **4 项受控面**满足?已知潜在缺口:
|
||||
- **输入**:gamefeel(P4) 的「输入缓冲/coyote」需要输入事件源 —— 当前受控面无 input 项。是经 `onFrame` 内自取输入(host 注入输入快照到哪?),还是必须扩 `PluginContext.input`?**这是最可能逼迫 Gate0 受控面扩项的点,须先裁。**
|
||||
- **音频**:audio-music(P6) 的发声需要 AudioContext 句柄 —— 当前受控面无。spec 说「P6 node 层只测序列器数据流,发声=集成段」,故 Gate0 可不扩,但须确认「发声句柄走哪条受控面」的方向,免得 lane-sys 卡壳。
|
||||
**建议**:开 lane 前,主会话先就「input 受控面是否纳入 v0」拍板(要么扩为 5 项并补 core 测试与 d.ts,要么明确 lane 走何种 workaround 并记边界)。
|
||||
|
||||
### 重点二:context 注入模型(单 context 共享 vs 每插件独享)
|
||||
当前 `plugin.js` 的 `initAll` 把**同一份 `this._context`**(host 经 `useContext` 注入)传给所有插件 init。隐含约定:**全插件共享一个受控上下文**(共享 time/random/canvas)。须审:
|
||||
- 共享 `random` 意味着插件 A 取随机会推进序列、影响插件 B 的随机 —— 对「每插件确定性」是否构成问题?(取证复现时要不要每插件独立子随机源?)
|
||||
- `useContext` 必须在 `initAll` 前调用,否则 `this._context` 为 undefined -> 插件 init 时 `ctx` 为 undefined 崩。**core 当前未对「忘记 useContext」给出友好报错**(会以 TypeError 形式在插件内崩)。建议主会话评估是否要在 `initAll` 入口加「未注入 context 即显式报错」的守卫(属 core 微调,需补测试,归 lane-core)。
|
||||
|
||||
### 重点三:vfx/audio 视觉/发声证据的集成段闭环(禁 node-canvas 的落地)
|
||||
spec §2/§5 钉死:vfx 渲染哈希、audio 发声证据**必须**集成段浏览器出,**禁 node-canvas 兜底**。`browser-evidence.cjs` 已把**纯计算口径**(FNV-1a 哈希/非空/直方图/几何)在 Gate0 实现并冻结,但 **CDP 连真 Chrome 的 4 个占位函数**(connectCdp/driveFrames/captureImageData/saveScreenshot)尚未实现。须审:
|
||||
- 这 4 个占位的实现归属(lane-vfx 自己接,还是 lane-core 在集成段统一接?)—— 建议**集成段由主会话统一接**,避免各 lane 重复造 CDP 轮子、口径漂移。
|
||||
- 「mock 时钟接管 onFrame 驱动」在集成段如何与 LittleJS 主循环对接(当前 host-dev 用 `tick`,集成段需把 tick 桥到引擎更新且时钟可注入)—— 这是 vfx 确定性证据的前提,须在 lane-vfx 开工前定方案。
|
||||
|
||||
---
|
||||
|
||||
## 6. 本工位自验汇总
|
||||
|
||||
| 验证项 | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| core 17 测 | `node --test src/core/plugin.test.mjs` | 17/17 |
|
||||
| _example 5 测 | `node --test src/plugins/_example/test/example.test.mjs` | 5/5 |
|
||||
| 全量遍历 | `bash scripts/test.sh` | 22/22(原始输出见 `test/RESULT.txt`) |
|
||||
| manifest 正向校验 | `node scripts/validate-manifest.mjs` | 通过 |
|
||||
| manifest 反向自检 | 非法样例 | 逮 6 违规、退出码 1 |
|
||||
| size gz 口径 | `node scripts/size.mjs --selftest` | 通过 |
|
||||
| harness 纯计算口径 | 临时自检(哈希/非空/几何/占位/路径) | 通过 |
|
||||
|
||||
**未在本机跑(按纪律留集成段)**:esbuild 打包(build.mjs/size.mjs 打包段)、CDP 浏览器真跑(harness 4 占位)—— 均仅 mini-desktop 跑,本机无 esbuild、禁 headless Chrome。
|
||||
53
game-runtime/docs/PLUGIN-TEMPLATE.md
Normal file
53
game-runtime/docs/PLUGIN-TEMPLATE.md
Normal file
@ -0,0 +1,53 @@
|
||||
# PLUGIN.md 规格模板(core-protocol-v0)
|
||||
|
||||
> owner:T1b-α lane-core(Gate0 奠基件)|消费方:所有 lane 写插件文档时照此结构 + agent 读插件能力时按此找信息。
|
||||
> **这是「插件使用文档」的固定骨架**:每个插件目录下的 `PLUGIN.md` 必须包含下列六节,章节标题与顺序不变(便于 agent / 索引脚本机器读取)。活样板见 `src/plugins/_example/PLUGIN.md`。
|
||||
|
||||
---
|
||||
|
||||
## 为什么需要统一规格
|
||||
|
||||
- 插件库是「给 agent 造游戏用的能力面」。agent 不读源码,**只读 PLUGIN.md** 决定「这个插件能干什么、怎么接、边界在哪」。
|
||||
- 文档结构不统一 → agent 找信息成本高、易误用。固定六节 = 让每个插件的能力面以同一形状被检索。
|
||||
- 与 `manifest.json`(机器契约)互补:manifest 给「名称/版本/字节预算/导出入口」等结构化元数据;PLUGIN.md 给「人/agent 读的能力说明与用法」。两者的 `name`/`version` 必须一致。
|
||||
|
||||
---
|
||||
|
||||
## 必含六节(标题照抄,顺序不变)
|
||||
|
||||
### 1. 能力(Capabilities)
|
||||
一句话定位 + 能力清单。**engine 级、无玩法语义**(不得出现「金币/敌人/关卡/商店」等品类词;只说「碰撞查询/粒子发射/调色映射」等引擎能力)。
|
||||
- 写清:这个插件提供哪些可调用的能力面;
|
||||
- 写清:**不提供**什么(划清与「agent 生成域 = 玩法/美术成品/关卡/UI」的边界)。
|
||||
|
||||
### 2. 集成点(Integration)
|
||||
插件如何接入宿主与受控面。必须覆盖:
|
||||
- **依赖**:`dependencies` 声明了哪些其它插件(无则写「无」);
|
||||
- **受控面用法**:用到 `PluginContext` 的哪几项(`getContext2d`/`onFrame`/`time`/`random`),各自用途;
|
||||
- **注册方式**:`registry.register(createXxxPlugin(opts))` 的标准接法;
|
||||
- **配置项**:工厂 `opts` 的字段与含义(无则写「无配置」)。
|
||||
|
||||
### 3. 示例(Usage)
|
||||
最小可跑代码片段(ESM)。展示:构造受控上下文(host-dev:`createHostDevContext`)→ register → initAll → 驱动(tick/或集成段引擎主循环)→ disposeAll。片段须与该插件 `test/` 下的真实测试用法一致,不得是「伪代码」。
|
||||
|
||||
### 4. 测试(Test)
|
||||
- 测试文件路径 + 运行命令(`node --test <path>`);
|
||||
- 覆盖了哪些性质(确定性/边界/错误隔离/生命周期…);
|
||||
- node 层测什么、什么留到集成段浏览器证据(vfx/audio 类插件尤其要写清「渲染/发声哈希在集成段 harness 出」)。
|
||||
|
||||
### 5. 边界与字节预算(Boundaries & Budget)
|
||||
- **字节预算**:`gzBudgetBytes`(与 manifest 一致);超配额触发评审(执行版 §5);
|
||||
- **行为边界**:null 渲染环境如何降级、错误如何隔离、不做什么(如「不做拓扑重排」「不内置成品色板」);
|
||||
- **受控面铁律**:声明「只经 PluginContext 拿引擎能力,绝不直透 littlejsengine 裸对象」。
|
||||
|
||||
### 6. v2 声明(v2 Notes)
|
||||
本版(v1 / seed)**刻意未做**、留给后续的能力或扩展点,逐条列出(YAGNI 边界透明化)。无则写「本版能力已闭环,暂无 v2 待办」。
|
||||
|
||||
---
|
||||
|
||||
## 写作约束
|
||||
|
||||
- 全文简体中文;标题用上述中文节名(括号内英文仅本模板示意,正文 PLUGIN.md 可省略英文)。
|
||||
- 不堆术语、不写营销话术;以「agent 能照着接上」为唯一标准。
|
||||
- 任何代码片段必须能跑(与 `test/` 真实用法对齐),不放无法验证的示例。
|
||||
- 与 `manifest.json` 的 `name`/`version`/`dependencies`/`gzBudgetBytes` 保持一致,冲突时以 manifest(机器契约)为准并在此修正。
|
||||
72
game-runtime/schema/plugin-manifest.schema.json
Normal file
72
game-runtime/schema/plugin-manifest.schema.json
Normal file
@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://zaomeng.ai/schemas/plugin-manifest/v0",
|
||||
"title": "Plugin Manifest (core-protocol-v0)",
|
||||
"description": "LittleJS 增强发行版每个插件的 manifest.json 契约。owner=T1b-α lane-core。字段封闭(additionalProperties:false),新增字段须改本 schema 走 Gate0 评审,防 lane 私自塞玩法/美术语义入插件元数据。",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "version", "gzBudgetBytes", "exports"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "插件唯一名(注册键,与 Plugin.name 一致)。kebab-case,无玩法/品类语义。",
|
||||
"pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
|
||||
"minLength": 2,
|
||||
"maxLength": 40
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "插件 semver 版本(与 Plugin.version 一致)。随发行版整体演进;只增不破坏既有 API。",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?$"
|
||||
},
|
||||
"gzBudgetBytes": {
|
||||
"type": "integer",
|
||||
"description": "本插件 gz 字节配额(自律线,集成段 size.mjs 增量法实测对照)。超配额触发评审(执行版 §5)。",
|
||||
"minimum": 1,
|
||||
"maximum": 65536
|
||||
},
|
||||
"dependencies": {
|
||||
"type": "array",
|
||||
"description": "声明依赖的其它插件 name 列表(与 Plugin.dependencies 一致)。注册器 init 前校验是否已注册;本波不做拓扑重排。可空。",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
},
|
||||
"exports": {
|
||||
"type": "object",
|
||||
"description": "导出规则:声明本插件对外暴露的能力面入口(供 agent/lane 读取与构建索引)。只描述能力面位置与命名,不含实现。",
|
||||
"additionalProperties": false,
|
||||
"required": ["impl", "types"],
|
||||
"properties": {
|
||||
"impl": {
|
||||
"type": "string",
|
||||
"description": "实现入口文件(相对插件目录),ESM JS。如 'impl.js'。",
|
||||
"pattern": "^[A-Za-z0-9_.-]+\\.js$"
|
||||
},
|
||||
"types": {
|
||||
"type": "string",
|
||||
"description": "手写类型门面文件(相对插件目录),.d.ts。如 'api.d.ts'。",
|
||||
"pattern": "^[A-Za-z0-9_.-]+\\.d\\.ts$"
|
||||
},
|
||||
"named": {
|
||||
"type": "array",
|
||||
"description": "本插件导出的具名符号清单(供索引/校验「能力面无玩法语义」)。可空。",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "一句话能力描述(engine 级,无玩法语义)。可选。",
|
||||
"maxLength": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
75
game-runtime/scripts/build.mjs
Normal file
75
game-runtime/scripts/build.mjs
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* scripts/build.mjs — LittleJS 增强发行版打包脚本(esbuild 锁参,core-protocol-v0)
|
||||
*
|
||||
* ⚠️ 运行环境纪律(执行版 §1/§5):
|
||||
* **本脚本仅在「集成段(mini-desktop)」运行**,不在 lane 本机跑。lane 阶段一切测试 =
|
||||
* 零依赖 `node --test`;esbuild 是工具链依赖(需 `npm i -D esbuild`),只在 mini-desktop 装与跑。
|
||||
* 在无 esbuild 的本机执行本脚本会**显式报错并给出安装指引**(不静默失败)。
|
||||
*
|
||||
* 锁定参数(§5,不得擅改 —— 改动影响 SIZES 口径与 channel-spike 对照):
|
||||
* bundle + minify + format=iife + target=es2019 + sourcemap=false
|
||||
*
|
||||
* 用法(集成段):
|
||||
* node scripts/build.mjs <入口.js> <输出.js> [--global-name=NAME]
|
||||
* 例:node scripts/build.mjs src/core/plugin.js dist/core.iife.js --global-name=GameRuntimeCore
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { argv, exit } from 'node:process';
|
||||
|
||||
/** 锁定的 esbuild 构建参数(§5 写死,集成段与 size.mjs 必须同口径)。 */
|
||||
export const LOCKED_BUILD_OPTIONS = {
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: 'iife',
|
||||
target: 'es2019',
|
||||
sourcemap: false,
|
||||
// legalComments:'none' 去掉许可证注释,保证 minify 后字节口径稳定(不影响合规,许可证在仓内 LICENSE 留存)。
|
||||
legalComments: 'none',
|
||||
};
|
||||
|
||||
/**
|
||||
* 用锁定参数构建单个入口。集成段调用;本机无 esbuild 时抛错给指引。
|
||||
* @param {string} entry 入口文件路径
|
||||
* @param {string} outfile 输出文件路径
|
||||
* @param {string} [globalName] iife 全局变量名(可选)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function build(entry, outfile, globalName) {
|
||||
let esbuild;
|
||||
try {
|
||||
// 动态 import:仅集成段装了 esbuild 才解析得到;本机解析失败走下方指引。
|
||||
esbuild = await import('esbuild');
|
||||
} catch {
|
||||
console.error(
|
||||
'[build] 未找到 esbuild —— 本脚本仅在集成段(mini-desktop)运行。\n' +
|
||||
' 请在集成段执行:npm i -D esbuild,再重跑本脚本。\n' +
|
||||
' lane 本机请勿运行构建(纪律:本机零依赖、只跑 node --test)。'
|
||||
);
|
||||
throw new Error('esbuild 不可用(非集成段环境)');
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
...LOCKED_BUILD_OPTIONS,
|
||||
entryPoints: [entry],
|
||||
outfile,
|
||||
...(globalName ? { globalName } : {}),
|
||||
});
|
||||
console.log(`[build] OK ${entry} → ${outfile} (iife/es2019/min/no-sourcemap)`);
|
||||
}
|
||||
|
||||
// ── CLI 入口(仅集成段使用)──────────────────────────────────────────────────
|
||||
// 仅当作为脚本直接运行时执行 CLI;被 import(如 size.mjs 复用 LOCKED_BUILD_OPTIONS)时不触发。
|
||||
if (import.meta.url === `file://${argv[1]}`) {
|
||||
const [entry, outfile, ...rest] = argv.slice(2);
|
||||
if (!entry || !outfile) {
|
||||
console.error('用法:node scripts/build.mjs <入口.js> <输出.js> [--global-name=NAME]');
|
||||
exit(1);
|
||||
}
|
||||
const gn = (rest.find((a) => a.startsWith('--global-name=')) || '').split('=')[1];
|
||||
build(entry, outfile, gn).catch((e) => {
|
||||
console.error('[build] 失败:' + (e && e.message ? e.message : e));
|
||||
exit(1);
|
||||
});
|
||||
}
|
||||
184
game-runtime/scripts/size.mjs
Normal file
184
game-runtime/scripts/size.mjs
Normal file
@ -0,0 +1,184 @@
|
||||
/**
|
||||
* scripts/size.mjs — 发行版字节预算「增量法」测量器(core-protocol-v0)
|
||||
*
|
||||
* 口径(执行版 §5,与 channel-spike 同策略,写死不得擅改):
|
||||
* - 打包:复用 build.mjs 的锁定参数(bundle+minify+iife+es2019+sourcemap=false);
|
||||
* - gz:**zlib.gzipSync(buf, { level: 9 })**(node 内置;等价 `gzip -9`,但纯内置免外部进程);
|
||||
* - **增量法**:基线壳 → +core → 逐插件叠加,每步实测「当前累计 bundle 的 raw/gz」,
|
||||
* 相邻两步差值 = 该新增件的「增量 raw/gz」(树摇后的真实净增,非孤立打包值);
|
||||
* - 输出 `SIZES.md`:分项 raw/gz 双列 + 命令注记 + 各插件 manifest 的 gzBudgetBytes 对照(超配额标 ⚠️)。
|
||||
*
|
||||
* ⚠️ 运行环境:真实打包需 esbuild → **仅集成段(mini-desktop)跑**;本机无 esbuild 会报指引。
|
||||
* gz 量算(zlib)逻辑本机可独立自检:`node scripts/size.mjs --selftest`(不需 esbuild)。
|
||||
*
|
||||
* 用法(集成段):node scripts/size.mjs → 生成 game-runtime/SIZES.md
|
||||
* 本机自检 :node scripts/size.mjs --selftest
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { writeFileSync, readFileSync, readdirSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { LOCKED_BUILD_OPTIONS } from './build.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
const PLUGINS_DIR = join(ROOT, 'src', 'plugins');
|
||||
const CORE_ENTRY = join(ROOT, 'src', 'core', 'plugin.js');
|
||||
|
||||
/**
|
||||
* 量算一段 buffer 的 raw 与 gz(level 9)字节数。这是 SIZES 的最底层口径,纯内置可独立测。
|
||||
* @param {Buffer|Uint8Array|string} content
|
||||
* @returns {{ raw: number, gz: number }}
|
||||
*/
|
||||
export function measureBytes(content) {
|
||||
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||
return { raw: buf.length, gz: gzipSync(buf, { level: 9 }).length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 用锁定参数把一组入口打成「单 iife bundle」并返回其字节数(增量法的一档)。
|
||||
* 集成段用;需 esbuild。多入口时用一个聚合虚拟入口把它们 import 到一起,量整包累计大小。
|
||||
* @param {string[]} entries 该档要叠加进来的入口文件(绝对路径)
|
||||
* @param {object} esbuild 已 import 的 esbuild 模块
|
||||
* @returns {Promise<{ raw:number, gz:number }>}
|
||||
*/
|
||||
async function bundleSize(entries, esbuild) {
|
||||
if (entries.length === 0) return { raw: 0, gz: 0 };
|
||||
// 聚合入口:把多个文件 import 进一个临时入口,模拟「整包累计」。
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'rt-size-'));
|
||||
try {
|
||||
const aggregate = join(tmp, 'aggregate.js');
|
||||
const body = entries.map((e, i) => `import * as m${i} from ${JSON.stringify(e)};`).join('\n')
|
||||
+ '\n// 防树摇整体消除:把各模块挂到全局副作用点。\n'
|
||||
+ 'globalThis.__rt_keep = [' + entries.map((_, i) => `m${i}`).join(', ') + '];\n';
|
||||
writeFileSync(aggregate, body);
|
||||
const out = await esbuild.build({
|
||||
...LOCKED_BUILD_OPTIONS,
|
||||
entryPoints: [aggregate],
|
||||
bundle: true,
|
||||
write: false, // 不落盘,直接拿产物 buffer 量字节
|
||||
globalName: '__RT_SIZE_PROBE',
|
||||
});
|
||||
const code = out.outputFiles[0].contents; // Uint8Array
|
||||
return measureBytes(Buffer.from(code));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫描 src/plugins/<*>(排除 _example 可选)→ 返回 [{name, entry, budget}],按 name 排序稳定。 */
|
||||
function collectPlugins() {
|
||||
const list = [];
|
||||
if (!existsSync(PLUGINS_DIR)) return list;
|
||||
for (const name of readdirSync(PLUGINS_DIR).sort()) {
|
||||
const dir = join(PLUGINS_DIR, name);
|
||||
const mf = join(dir, 'manifest.json');
|
||||
if (!existsSync(mf)) continue;
|
||||
const manifest = JSON.parse(readFileSync(mf, 'utf8'));
|
||||
const impl = join(dir, (manifest.exports && manifest.exports.impl) || 'impl.js');
|
||||
list.push({ name: manifest.name, dir: name, entry: impl, budget: manifest.gzBudgetBytes });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 生成 SIZES.md 文本。 */
|
||||
function renderSizesMd(rows, baseline) {
|
||||
const lines = [];
|
||||
lines.push('# SIZES.md —— 发行版字节预算实测(增量法,core-protocol-v0)');
|
||||
lines.push('');
|
||||
lines.push('> 由 `scripts/size.mjs` 在集成段(mini-desktop)生成。**勿手改**,改源后重跑覆盖。');
|
||||
lines.push('> 口径:esbuild `bundle+minify+format=iife+target=es2019+sourcemap=false`;');
|
||||
lines.push('> gz = `zlib.gzipSync(buf,{level:9})`(等价 `gzip -9`);**增量法** = 基线壳→+core→逐插件叠加,差值为各件净增。');
|
||||
lines.push('');
|
||||
lines.push('| 叠加项 | 累计 raw | 累计 gz | 增量 raw | 增量 gz | gz 预算 | 判定 |');
|
||||
lines.push('|---|--:|--:|--:|--:|--:|:--|');
|
||||
lines.push(`| 基线壳 | ${baseline.raw} | ${baseline.gz} | — | — | — | 基准 |`);
|
||||
let totalGz = 0;
|
||||
for (const r of rows) {
|
||||
const verdict = r.budget == null ? '—'
|
||||
: r.deltaGz <= r.budget ? `✔ ≤${r.budget}` : `⚠️ 超 ${r.deltaGz - r.budget}B`;
|
||||
if (r.budget != null) totalGz += r.deltaGz;
|
||||
lines.push(`| ${r.label} | ${r.cumRaw} | ${r.cumGz} | ${r.deltaRaw} | ${r.deltaGz} | ${r.budget ?? '—'} | ${verdict} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(`合计插件 gz 增量 ≈ **${totalGz}B**(自律线 60K;超 60K 须创始人显式放行,执行版 §5)。`);
|
||||
lines.push('');
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/** --selftest:仅验 zlib gz 量算口径(不需 esbuild,本机可跑留证)。 */
|
||||
function selftest() {
|
||||
const cases = ['', 'a', 'hello world', 'x'.repeat(1000)];
|
||||
console.log('[size --selftest] zlib gzipSync(level:9) 量算自检:');
|
||||
for (const s of cases) {
|
||||
const m = measureBytes(s);
|
||||
// 基本不变式:raw=字节长度;gz>0(gzip 头即非零);gz 对高度可压缩串应远小于 raw。
|
||||
if (m.raw !== Buffer.from(s).length) throw new Error('raw 口径错');
|
||||
if (m.gz <= 0) throw new Error('gz 应 >0');
|
||||
console.log(` len=${String(m.raw).padStart(4)} raw=${m.raw} gz=${m.gz}`);
|
||||
}
|
||||
// 可压缩性断言:1000 个相同字符 gz 必远小于 raw。
|
||||
const big = measureBytes('x'.repeat(1000));
|
||||
if (!(big.gz < big.raw / 5)) throw new Error('高度可压缩串 gz 未显著小于 raw,口径可疑');
|
||||
console.log('[size --selftest] OK:gz 量算口径正确(高压缩串 gz << raw)。');
|
||||
}
|
||||
|
||||
// ── 主流程 ──────────────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
if (process.argv.includes('--selftest')) {
|
||||
selftest();
|
||||
return;
|
||||
}
|
||||
|
||||
let esbuild;
|
||||
try {
|
||||
esbuild = await import('esbuild');
|
||||
} catch {
|
||||
console.error(
|
||||
'[size] 未找到 esbuild —— 增量法实测仅在集成段(mini-desktop)跑。\n' +
|
||||
' 请在集成段执行:npm i -D esbuild,再重跑生成 SIZES.md。\n' +
|
||||
' 本机仅可跑 gz 量算自检:node scripts/size.mjs --selftest'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 基线壳:本波无独立「空壳」入口,以「core 之前」为 0 基线(增量法起点)。
|
||||
// 若后续有引擎壳入口,可在此把 baseline 换成壳的实测值(口径不变)。
|
||||
const baseline = { raw: 0, gz: 0 };
|
||||
|
||||
// 第一档:+core
|
||||
const coreSize = await bundleSize([CORE_ENTRY], esbuild);
|
||||
const rows = [];
|
||||
rows.push({
|
||||
label: '+core', cumRaw: coreSize.raw, cumGz: coreSize.gz,
|
||||
deltaRaw: coreSize.raw - baseline.raw, deltaGz: coreSize.gz - baseline.gz, budget: null,
|
||||
});
|
||||
|
||||
// 逐插件叠加(累计 = core + 已叠插件 + 本插件),差值 = 本插件增量。
|
||||
const plugins = collectPlugins();
|
||||
let prev = coreSize;
|
||||
const cumulativeEntries = [CORE_ENTRY];
|
||||
for (const p of plugins) {
|
||||
cumulativeEntries.push(p.entry);
|
||||
const cur = await bundleSize(cumulativeEntries, esbuild);
|
||||
rows.push({
|
||||
label: `+${p.name}`, cumRaw: cur.raw, cumGz: cur.gz,
|
||||
deltaRaw: cur.raw - prev.raw, deltaGz: cur.gz - prev.gz, budget: p.budget,
|
||||
});
|
||||
prev = cur;
|
||||
}
|
||||
|
||||
const md = renderSizesMd(rows, baseline);
|
||||
const outPath = join(ROOT, 'SIZES.md');
|
||||
writeFileSync(outPath, md);
|
||||
console.log(`[size] 已写 ${outPath}(${plugins.length} 插件,增量法)`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[size] 失败:' + (e && e.message ? e.message : e));
|
||||
process.exit(1);
|
||||
});
|
||||
34
game-runtime/scripts/test.sh
Executable file
34
game-runtime/scripts/test.sh
Executable file
@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/test.sh — 遍历 core + 全插件,零依赖 node --test 跑全部单测(core-protocol-v0)
|
||||
# owner:T1b-α lane-core | 运行:bash scripts/test.sh(在 game-runtime/ 下或任意路径,脚本自定位仓根)
|
||||
#
|
||||
# 纪律(执行版 §1):lane 阶段一切测试 = 本机零依赖 node --test;本脚本不装任何依赖、不跑 git。
|
||||
# 收集对象:src/core/**/*.test.mjs|cjs + src/plugins/<*>/test/**/*.test.mjs|cjs。
|
||||
# 退出码:全绿=0;任一失败=node --test 的非零码(CI/收口据此 gate)。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# 自定位仓根(脚本在 game-runtime/scripts/ 下,根 = 上一级),保证任意 cwd 可跑。
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${ROOT}"
|
||||
|
||||
echo "[test.sh] 仓根:${ROOT}"
|
||||
echo "[test.sh] 收集测试文件(*.test.mjs / *.test.cjs)..."
|
||||
|
||||
# 收集 core 与插件测试文件(find 跨平台稳妥;排除 node_modules)。
|
||||
# 用数组承载,避免文件名含空格出错。
|
||||
mapfile -t TEST_FILES < <(find src -type f \( -name '*.test.mjs' -o -name '*.test.cjs' \) -not -path '*/node_modules/*' | sort)
|
||||
|
||||
if [ "${#TEST_FILES[@]}" -eq 0 ]; then
|
||||
echo "[test.sh] 未发现任何测试文件,视为失败(避免空过假绿)。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[test.sh] 共 ${#TEST_FILES[@]} 个测试文件:"
|
||||
for f in "${TEST_FILES[@]}"; do echo " - ${f}"; done
|
||||
echo "[test.sh] 开始 node --test ..."
|
||||
echo
|
||||
|
||||
# 一次性把全部文件交给 node --test,得到合并的 TAP/汇总(pass/fail 总计一目了然)。
|
||||
node --test "${TEST_FILES[@]}"
|
||||
132
game-runtime/scripts/validate-manifest.mjs
Normal file
132
game-runtime/scripts/validate-manifest.mjs
Normal file
@ -0,0 +1,132 @@
|
||||
/**
|
||||
* scripts/validate-manifest.mjs — 插件 manifest.json 零依赖校验器(core-protocol-v0)
|
||||
* owner:T1b-α lane-core | 运行:node scripts/validate-manifest.mjs [<manifest 路径>...]
|
||||
* 缺省:自动扫描 src/plugins/<*>/manifest.json 全部校验。
|
||||
*
|
||||
* 【为什么不引 ajv】
|
||||
* 本机零 npm 依赖纪律(执行版 §1)。draft-07 全量校验需 ajv —— 违纪。本脚本只对
|
||||
* `schema/plugin-manifest.schema.json` 的**关键约束**做手写校验(required / additionalProperties:false /
|
||||
* type / pattern / minimum-maximum / enum-uniqueItems),覆盖该 schema 实际用到的全部规则,
|
||||
* 足够把「manifest 是否合契约」判明并留证。集成段如需全量 draft-07 校验可另接 ajv(属工具链,集成段跑)。
|
||||
*
|
||||
* 退出码:全部通过=0;任一不合规=1(CI/收口可据此 gate)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
// 定位仓内路径(脚本在 game-runtime/scripts/ 下,根 = 上一级)。
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
const SCHEMA_PATH = join(ROOT, 'schema', 'plugin-manifest.schema.json');
|
||||
const PLUGINS_DIR = join(ROOT, 'src', 'plugins');
|
||||
|
||||
// 读 schema(仅取本校验器需要的字段;schema 是单一事实源,约束改动须同步本校验器)。
|
||||
const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
|
||||
|
||||
/**
|
||||
* 针对一个对象按(子)schema 做关键约束校验,收集错误到 errs。
|
||||
* @param {*} value 待校验值
|
||||
* @param {object} sch (子)schema
|
||||
* @param {string} path 字段路径(错误定位用)
|
||||
* @param {string[]} errs 错误累加数组
|
||||
*/
|
||||
function validate(value, sch, path, errs) {
|
||||
// type 校验(支持 object/array/string/integer)
|
||||
if (sch.type === 'object') {
|
||||
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
errs.push(`${path}: 应为 object`);
|
||||
return;
|
||||
}
|
||||
// required
|
||||
for (const key of sch.required || []) {
|
||||
if (!(key in value)) errs.push(`${path}: 缺少必填字段 "${key}"`);
|
||||
}
|
||||
// additionalProperties:false → 不允许未声明字段
|
||||
if (sch.additionalProperties === false) {
|
||||
const allowed = new Set(Object.keys(sch.properties || {}));
|
||||
for (const k of Object.keys(value)) {
|
||||
if (!allowed.has(k)) errs.push(`${path}: 出现未声明字段 "${k}"(additionalProperties:false)`);
|
||||
}
|
||||
}
|
||||
// 递归校验各属性
|
||||
for (const [k, subSch] of Object.entries(sch.properties || {})) {
|
||||
if (k in value) validate(value[k], subSch, path === '$' ? k : `${path}.${k}`, errs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sch.type === 'array') {
|
||||
if (!Array.isArray(value)) { errs.push(`${path}: 应为 array`); return; }
|
||||
if (sch.uniqueItems && new Set(value).size !== value.length) {
|
||||
errs.push(`${path}: 数组元素须唯一(uniqueItems)`);
|
||||
}
|
||||
value.forEach((item, i) => validate(item, sch.items || {}, `${path}[${i}]`, errs));
|
||||
return;
|
||||
}
|
||||
|
||||
if (sch.type === 'string') {
|
||||
if (typeof value !== 'string') { errs.push(`${path}: 应为 string`); return; }
|
||||
if (sch.minLength != null && value.length < sch.minLength) errs.push(`${path}: 长度 < ${sch.minLength}`);
|
||||
if (sch.maxLength != null && value.length > sch.maxLength) errs.push(`${path}: 长度 > ${sch.maxLength}`);
|
||||
if (sch.pattern && !new RegExp(sch.pattern).test(value)) {
|
||||
errs.push(`${path}: 不匹配 pattern /${sch.pattern}/(值="${value}")`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sch.type === 'integer') {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) { errs.push(`${path}: 应为 integer`); return; }
|
||||
if (sch.minimum != null && value < sch.minimum) errs.push(`${path}: < minimum ${sch.minimum}`);
|
||||
if (sch.maximum != null && value > sch.maximum) errs.push(`${path}: > maximum ${sch.maximum}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** 收集待校验的 manifest 路径:命令行给定优先,否则扫描 src/plugins/<*>/manifest.json。 */
|
||||
function collectTargets() {
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.length > 0) return argv.map((p) => resolve(p));
|
||||
const targets = [];
|
||||
if (existsSync(PLUGINS_DIR)) {
|
||||
for (const name of readdirSync(PLUGINS_DIR)) {
|
||||
const mf = join(PLUGINS_DIR, name, 'manifest.json');
|
||||
if (existsSync(mf)) targets.push(mf);
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
// ── 主流程 ──────────────────────────────────────────────────────────────────
|
||||
const targets = collectTargets();
|
||||
if (targets.length === 0) {
|
||||
console.error('[validate-manifest] 未找到任何 manifest.json(src/plugins/<*>/manifest.json)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
for (const file of targets) {
|
||||
const errs = [];
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(readFileSync(file, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(`✘ ${file}: JSON 解析失败 —— ${e.message}`);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
validate(data, schema, '$', errs);
|
||||
if (errs.length === 0) {
|
||||
console.log(`✔ ${file} [${data.name}@${data.version}, gzBudget=${data.gzBudgetBytes}]`);
|
||||
} else {
|
||||
console.error(`✘ ${file}`);
|
||||
for (const e of errs) console.error(` - ${e}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n[validate-manifest] 共 ${targets.length} 件,通过 ${targets.length - failed},失败 ${failed}`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
186
game-runtime/src/core/api.d.ts
vendored
Normal file
186
game-runtime/src/core/api.d.ts
vendored
Normal file
@ -0,0 +1,186 @@
|
||||
/**
|
||||
* core/api.d.ts — 插件协议核心公开能力面(手写门面,core-protocol-v0)
|
||||
* owner:T1b-α lane-core | 消费方:所有 lane 的插件 + 集成段 host
|
||||
*
|
||||
* 【为什么手写 .d.ts】
|
||||
* 发行版源码 = ESM JS + JSDoc(零工具链,node --test 直跑)。对外暴露的「类型契约」
|
||||
* 不靠 tsc 从 JS 推导,而是手写本文件——这是 agent / lane 读契约的一等公民入口,
|
||||
* 等价于 channel-probe 的「字段级定义」。本文件与 plugin.js 的 JSDoc 必须同形同步。
|
||||
*
|
||||
* 【受控面铁律】
|
||||
* PluginContext 是插件能从引擎拿到的**全部**能力,仅 4 项(canvas/帧回调/时间/随机)。
|
||||
* 绝不在此暴露 littlejsengine 裸类型——保「引擎可换」。扩展受控面需走 Gate0 后的扩展申请。
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 受控时间源 / 随机源
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 受控时间源:插件取时间一律走它,不读 Date.now/performance.now(支持取证 mock)。 */
|
||||
export interface TimeSource {
|
||||
/** 单调毫秒读数(仅用于间隔计算,禁跨设备比绝对值)。 */
|
||||
nowMs(): number;
|
||||
/** 自 host 标记起点经过的毫秒数。 */
|
||||
elapsedMs(): number;
|
||||
}
|
||||
|
||||
/** 受控随机源:插件取随机一律走它,不读 Math.random(确定性可复现,取证/fuzz 依赖)。 */
|
||||
export interface RandomSource {
|
||||
/** 返回 [0,1) 确定性浮点(同 Math.random 语义)。 */
|
||||
next(): number;
|
||||
/** 返回 [min,max) 确定性浮点。 */
|
||||
range(min: number, max: number): number;
|
||||
/** 重设种子并复位序列(取证复现)。 */
|
||||
reseed(seed: number): void;
|
||||
}
|
||||
|
||||
/** 帧回调注册句柄:cancel() 注销该帧回调。 */
|
||||
export interface FrameHandle {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* PluginContext —— 受控引擎能力面(最小 4 项,YAGNI)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* 受控上下文:插件能从引擎拿到的全部能力。
|
||||
* 设计铁律:绝不直透 littlejsengine 裸对象 / 全局 canvas / 原生 RAF;一切经此面,引擎可换。
|
||||
*/
|
||||
export interface PluginContext {
|
||||
/**
|
||||
* 获取受控 2D 画布上下文。host 决定底层来源(集成段=LittleJS overlay;host-dev/node 可能返回 null)。
|
||||
* 插件必须容忍 null(无渲染环境时降级,见 PLUGIN-TEMPLATE)。
|
||||
*/
|
||||
getContext2d(): CanvasRenderingContext2D | null;
|
||||
/**
|
||||
* 注册逐帧回调。host 用引擎主循环驱动;插件**禁止**直接 requestAnimationFrame。
|
||||
* @param cb 回调,入参 dtSeconds=本帧时间步(秒)、frame=单调帧序号(从 1 起)。
|
||||
* @returns 句柄,cancel() 注销。
|
||||
*/
|
||||
onFrame(cb: (dtSeconds: number, frame: number) => void): FrameHandle;
|
||||
/** 受控时间源。 */
|
||||
time: TimeSource;
|
||||
/** 受控随机源(可注种子)。 */
|
||||
random: RandomSource;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* Plugin —— 插件契约(所有 lane 实现本形状)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 一个插件 = 一项引擎能力。即插即用、可组合、版本化;不含玩法/美术/关卡/UI。 */
|
||||
export interface Plugin {
|
||||
/** 插件唯一名(注册键,重名拒绝);建议 kebab-case。 */
|
||||
name: string;
|
||||
/** 插件版本(semver);随发行版整体演进。 */
|
||||
version: string;
|
||||
/**
|
||||
* 初始化钩子:拿受控上下文,完成自身资源/帧回调注册。
|
||||
* 禁止在此触碰 littlejsengine 裸对象;init 抛错由注册器隔离(不连坐其它插件)。
|
||||
*/
|
||||
init(ctx: PluginContext): void;
|
||||
/**
|
||||
* 释放钩子(可选):释放 init 申请的资源。注册器保证逆序触发且幂等调用安全;
|
||||
* 插件内部亦应自保幂等(重复 dispose 不报错)。
|
||||
*/
|
||||
dispose?(): void;
|
||||
/**
|
||||
* 依赖的其它插件 name(声明式)。注册器 init 前校验「声明依赖是否已注册」,
|
||||
* 本波不做拓扑重排(注册顺序即 init 顺序)。
|
||||
*/
|
||||
dependencies?: string[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* PluginRegistry —— 注册器与生命周期编排
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** initAll 返回结构。 */
|
||||
export interface InitResult {
|
||||
/** 无任何 init 错误为 true。 */
|
||||
ok: boolean;
|
||||
/** 成功 init 的插件 name(注册正序)。 */
|
||||
initialized: string[];
|
||||
/** 被隔离的 init 错误(含依赖缺失 / init 抛错)。 */
|
||||
errors: Array<{ name: string; error: Error }>;
|
||||
}
|
||||
|
||||
/** disposeAll 返回结构。 */
|
||||
export interface DisposeResult {
|
||||
/** 成功 dispose 的插件 name(注册逆序)。 */
|
||||
disposed: string[];
|
||||
/** 被隔离的 dispose 错误。 */
|
||||
errors: Array<{ name: string; error: Error }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件注册器。编排插件集生命周期:注册(重名拒绝)/依赖声明校验/init 正序(错误隔离)/dispose 逆序(幂等)。
|
||||
*/
|
||||
export declare class PluginRegistry {
|
||||
constructor();
|
||||
/** 注册插件(重名抛错;形状非法抛错)。不触发 init。 */
|
||||
register(plugin: Plugin): this;
|
||||
/** 是否已注册。 */
|
||||
has(name: string): boolean;
|
||||
/** 取已注册插件。 */
|
||||
get(name: string): Plugin | undefined;
|
||||
/** 已注册插件 name(注册顺序)。 */
|
||||
list(): string[];
|
||||
/** 绑定受控上下文(host 注入),必须在 initAll 之前。 */
|
||||
useContext(context: PluginContext): this;
|
||||
/** 依次 init 全部插件(正序,错误隔离,依赖声明校验)。重复调用幂等。 */
|
||||
initAll(): InitResult;
|
||||
/** 释放全部已 init 插件(逆序,幂等,错误隔离)。 */
|
||||
disposeAll(): DisposeResult;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* host-dev 桩工厂(Gate0 阶段的 context 实现;集成段换 LittleJS 包装)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** createHostDevContext 选项。 */
|
||||
export interface HostDevContextOptions {
|
||||
/** 注入的 2D 上下文(node 侧通常 null/mock)。 */
|
||||
context2d?: CanvasRenderingContext2D | null;
|
||||
/** 注入单调时钟(测试用步进时钟可复现)。 */
|
||||
clock?: () => number;
|
||||
/** 随机源种子(确定性)。 */
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
/** createHostDevContext 返回:受控上下文 + host 私有 tick 驱动器 + 帧数读取。 */
|
||||
export interface HostDevContextBundle {
|
||||
/** 交给插件的受控上下文。 */
|
||||
context: PluginContext;
|
||||
/** host 私有:推进一帧,驱动所有 onFrame 回调(模拟引擎主循环)。插件不可见。 */
|
||||
tick(dtSeconds: number): void;
|
||||
/** 当前帧数。 */
|
||||
frameCount(): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 host-dev 版 PluginContext(接口 + 桩)。集成段用真实 LittleJS 句柄实现同形上下文替换。
|
||||
*/
|
||||
export declare function createHostDevContext(opts?: HostDevContextOptions): HostDevContextBundle;
|
||||
|
||||
/** 确定性随机源(mulberry32),可注种子,跨端一致。 */
|
||||
export declare class SeededRandom implements RandomSource {
|
||||
constructor(seed?: number);
|
||||
next(): number;
|
||||
range(min: number, max: number): number;
|
||||
reseed(seed: number): void;
|
||||
}
|
||||
|
||||
/** host-dev 时间源;集成段换引擎主循环虚拟时钟。 */
|
||||
export declare class HostDevTime implements TimeSource {
|
||||
constructor(clock?: () => number);
|
||||
nowMs(): number;
|
||||
elapsedMs(): number;
|
||||
}
|
||||
|
||||
/** 单调时钟选择器(performance.now > hrtime > Date.now)。 */
|
||||
export declare function defaultMonotonicClock(): () => number;
|
||||
|
||||
/** 协议版本标识(冻结时 = 'core-protocol-v0')。 */
|
||||
export declare const CORE_PROTOCOL_VERSION: string;
|
||||
498
game-runtime/src/core/plugin.js
Normal file
498
game-runtime/src/core/plugin.js
Normal file
@ -0,0 +1,498 @@
|
||||
/**
|
||||
* plugin.js — LittleJS 增强发行版 · 插件协议核心(core-protocol-v0 地基)
|
||||
* owner:T1b-α lane-core(Gate0 奠基件)|消费方:lane-math / lane-vfx / lane-sys / lane-ref / 集成段
|
||||
*
|
||||
* 【本文件定位】
|
||||
* 这是「LittleJS 增强发行版」插件协议的唯一权威实现。其余 lane 的插件都实现本文件
|
||||
* 定义的 Plugin 接口、经 PluginContext 受控面拿引擎能力、由 PluginRegistry 统一编排
|
||||
* 生命周期。本文件冻结后即 core-protocol-v0;冻结前为 Gate0 评审对象。
|
||||
*
|
||||
* 【源码形态纪律(执行版 §1)】
|
||||
* ESM JS + JSDoc 类型注释 + 手写 api.d.ts;零工具链;本机 `node --test` 直跑必须全绿;
|
||||
* esbuild 打包压缩只在集成段(mini-desktop)跑。本文件不 import littlejsengine——
|
||||
* PluginContext 以「接口 + host-dev 桩」形态定义,真引擎接线在集成段。
|
||||
*
|
||||
* 【模板哲学(评审版宪法)】
|
||||
* 插件 = 一项引擎能力(碰撞/粒子/物理/手感…),即插即用、可组合、版本化;
|
||||
* 插件「不含玩法、不含美术成品、不含关卡、不含 UI」——四者是 agent 造游戏时的生成域。
|
||||
* 因此本协议只提供「能力编排 + 受控引擎句柄」,绝不内置任何玩法假设。
|
||||
*
|
||||
* 【三条核心协议取舍(详见 REPORT.md)】
|
||||
* 1. 受控面宁小勿大(YAGNI):PluginContext 只暴露 canvas/帧回调/时间源/随机源 4 项最小面,
|
||||
* 禁直透 littlejsengine 裸对象——保「引擎可换」。其余 lane 按需走扩展申请,不在 Gate0 预扩。
|
||||
* 2. 错误隔离不连坐:单插件 init 抛错只隔离该插件(记 errors[]),其余已注册插件照常 init,
|
||||
* 整个注册流程不因一个插件崩溃而中断——取证/运行不被单点故障带崩。
|
||||
* 3. 生命周期严格有序 + dispose 幂等:init 按注册顺序正序、dispose 按注册逆序(后进先出,
|
||||
* 符合资源依赖释放直觉);dispose 可重复调用无副作用(幂等),防重复释放。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* ============================================================================
|
||||
* 一、Plugin / PluginContext 形状说明(JS 无接口,靠 JSDoc + api.d.ts 约束)
|
||||
* 下面用 @typedef 把契约写清楚;真正的类型门面在手写 api.d.ts。
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* @typedef {Object} Plugin
|
||||
* 一个插件 = 一项引擎能力。所有 lane 的插件都实现本形状。
|
||||
* @property {string} name 插件唯一名(注册键,重名拒绝);建议 kebab-case,如 'collision'。
|
||||
* @property {string} version 插件版本(semver 字符串,如 '1.0.0');随发行版整体演进。
|
||||
* @property {(ctx: PluginContext) => void} init
|
||||
* 初始化钩子:拿到受控上下文 ctx,完成自身资源/帧回调注册。**禁止**在此直接触碰
|
||||
* littlejsengine 裸对象——只能经 ctx 暴露的受控面。init 抛错由注册器隔离(不连坐)。
|
||||
* @property {() => void} [dispose]
|
||||
* 释放钩子(可选):释放 init 申请的资源。注册器保证 dispose **幂等**调用安全,
|
||||
* 且按注册逆序触发。插件内部亦应自保幂等(重复 dispose 不报错)。
|
||||
* @property {string[]} [dependencies]
|
||||
* 依赖的其它插件 name 列表(声明式)。注册器据此在 init 前做「依赖缺失」检查;
|
||||
* 本波只校验「声明的依赖是否已注册」,不做拓扑重排(注册顺序即 init 顺序,详见 §3)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FrameHandle
|
||||
* 帧回调注册句柄;调用即注销该帧回调(便于插件 dispose 时精确撤销)。
|
||||
* @property {() => void} cancel 注销本帧回调。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PluginContext
|
||||
* 受控上下文 —— 插件能从引擎拿到的**全部**能力,仅此 4 项(最小受控面,YAGNI)。
|
||||
* 设计铁律:**绝不**把 littlejsengine 裸对象、全局 canvas、原生 RAF 直接交给插件,
|
||||
* 一切经此受控面,从而「引擎可换」(换引擎只需换 host 实现,插件零改)。
|
||||
*
|
||||
* @property {() => CanvasRenderingContext2D | null} getContext2d
|
||||
* 获取受控 2D 画布上下文。host 决定底层 canvas 来源(集成段=LittleJS overlay canvas;
|
||||
* host-dev 桩=node 侧返回 null 或 mock)。插件不得自行 document.querySelector。
|
||||
* @property {(cb: (dtSeconds: number, frame: number) => void) => FrameHandle} onFrame
|
||||
* 注册逐帧回调。host 用引擎主循环驱动(集成段=LittleJS gameUpdate;host-dev 桩=手动 tick)。
|
||||
* 回调入参:dtSeconds=本帧时间步(秒,已做封顶钳制由 host 负责)、frame=单调帧序号(从 1 起)。
|
||||
* 返回 FrameHandle,cancel() 注销。**插件禁止直接 requestAnimationFrame**。
|
||||
* @property {TimeSource} time
|
||||
* 受控时间源。插件取「现在」「自启动经过」都走它,不读 Date.now/performance.now,
|
||||
* 从而支持集成段的时间 mock(取证可复现)。
|
||||
* @property {RandomSource} random
|
||||
* 受控随机源(可注种子)。插件需要随机走它,不读 Math.random,从而确定性可复现
|
||||
* (取证哈希、fuzz 测试都依赖此)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TimeSource 受控时间源。
|
||||
* @property {() => number} nowMs 单调毫秒读数(仅用于间隔计算,禁跨设备比绝对值)。
|
||||
* @property {() => number} elapsedMs 自 host 标记的起点经过的毫秒数。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RandomSource 受控随机源(确定性,可注种子)。
|
||||
* @property {() => number} next 返回 [0,1) 浮点(同 Math.random 语义,但确定性)。
|
||||
* @property {(min:number, max:number) => number} range 返回 [min,max) 浮点。
|
||||
* @property {(seed:number) => void} reseed 重设种子(复位序列,取证复现用)。
|
||||
*/
|
||||
|
||||
/* ============================================================================
|
||||
* 二、host-dev 桩 —— PluginContext 的本机/集成前实现
|
||||
* Gate0 阶段不引入 littlejsengine;用纯 JS 桩满足「接口可定义、可单测、可 host-dev 挂载」。
|
||||
* 集成段把这套桩替换为「LittleJS 真实句柄包装」,插件侧零改(受控面同形)。
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* 确定性随机源(mulberry32)——零依赖、可注种子、跨端一致。
|
||||
* 选 mulberry32 是因为它单文件、状态仅一个 32 位整数、序列质量足够游戏/取证用,
|
||||
* 且与 LittleJS 自带 RNG 无耦合(换引擎不影响确定性口径)。
|
||||
* @implements {RandomSource}
|
||||
*/
|
||||
class SeededRandom {
|
||||
/**
|
||||
* @param {number} [seed] 初始种子(缺省 0x9e3779b9,黄金比例常数,避免全 0 退化)。
|
||||
*/
|
||||
constructor(seed) {
|
||||
// 内部 32 位状态;reseed 复位它
|
||||
this._state = (seed == null ? 0x9e3779b9 : seed) >>> 0;
|
||||
}
|
||||
|
||||
/** @returns {number} [0,1) 确定性浮点。 */
|
||||
next() {
|
||||
// mulberry32 核心:状态自增黄金比例增量,经位混淆产出
|
||||
this._state = (this._state + 0x6d2b79f5) >>> 0;
|
||||
let t = this._state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} min 下界(含)
|
||||
* @param {number} max 上界(不含)
|
||||
* @returns {number} [min,max) 确定性浮点
|
||||
*/
|
||||
range(min, max) {
|
||||
return min + this.next() * (max - min);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重设种子并复位序列(取证复现的关键能力)。
|
||||
* @param {number} seed 新种子
|
||||
*/
|
||||
reseed(seed) {
|
||||
this._state = (seed == null ? 0x9e3779b9 : seed) >>> 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* host-dev 时间源:node 侧用注入时钟,缺省回退 performance.now / Date.now。
|
||||
* 集成段会换成「引擎主循环驱动的虚拟时钟」以支持取证 mock。
|
||||
* @implements {TimeSource}
|
||||
*/
|
||||
class HostDevTime {
|
||||
/**
|
||||
* @param {() => number} [clock] 单调毫秒时钟(缺省自动选)。
|
||||
*/
|
||||
constructor(clock) {
|
||||
this._clock = clock || defaultMonotonicClock();
|
||||
// elapsed 起点:构造即记 origin
|
||||
this._origin = this._clock();
|
||||
}
|
||||
|
||||
/** @returns {number} 单调毫秒读数。 */
|
||||
nowMs() {
|
||||
return this._clock();
|
||||
}
|
||||
|
||||
/** @returns {number} 自构造起点经过的毫秒数。 */
|
||||
elapsedMs() {
|
||||
return this._clock() - this._origin;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个 host-dev 版 PluginContext(接口 + 桩)。
|
||||
* 集成段用真实 LittleJS 句柄实现同形上下文替换它;插件侧零改。
|
||||
*
|
||||
* @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 编排用。
|
||||
*/
|
||||
function createHostDevContext(opts) {
|
||||
const o = opts || {};
|
||||
const time = new HostDevTime(o.clock);
|
||||
const random = new SeededRandom(o.seed);
|
||||
// 帧回调表:用 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,
|
||||
};
|
||||
|
||||
return {
|
||||
context,
|
||||
/**
|
||||
* host 私有:推进一帧,驱动所有 onFrame 回调(模拟引擎主循环的一次 update)。
|
||||
* 单个回调抛错被隔离(不连坐其它回调),与注册器错误隔离哲学一致。
|
||||
* @param {number} dtSeconds 本帧时间步(秒)
|
||||
*/
|
||||
tick(dtSeconds) {
|
||||
frame += 1;
|
||||
// 复制一份避免回调内增删表导致迭代异常
|
||||
const snapshot = Array.from(frameCbs.values());
|
||||
for (const cb of snapshot) {
|
||||
try {
|
||||
cb(dtSeconds, frame);
|
||||
} catch (e) {
|
||||
// 帧回调抛错隔离:记到 stderr,不中断本帧其它回调,也不中断后续帧。
|
||||
// 生产 host 应改为走遥测/错误上报;host-dev 桩落 stderr 即可(执行版「错误路径有日志」)。
|
||||
logError('[host-dev tick] 帧回调抛错(已隔离)frame=' + frame, e);
|
||||
}
|
||||
}
|
||||
},
|
||||
frameCount() {
|
||||
return frame;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 三、PluginRegistry —— 插件注册器与生命周期编排
|
||||
* 职责:注册(重名拒绝)/ 依赖声明校验 / init 正序(错误隔离)/ dispose 逆序(幂等)。
|
||||
*
|
||||
* 生命周期顺序约定(本波,刻意简单):
|
||||
* - register 顺序 = init 顺序(正序);dispose 顺序 = 注册逆序(后进先出)。
|
||||
* - 依赖声明(dependencies)本波只做「init 时校验声明的依赖是否已注册」,
|
||||
* **不做**拓扑排序自动重排——保持 Gate0 骨架最小(YAGNI);若 lane 实证需要
|
||||
* 拓扑重排,走扩展申请(REPORT.md 已记此边界)。
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* 插件注册器。一个游戏/宿主持有一个 registry,编排其插件集的生命周期。
|
||||
*/
|
||||
class PluginRegistry {
|
||||
constructor() {
|
||||
// 注册表:name → plugin(保持插入顺序,Map 天然有序)
|
||||
/** @type {Map<string, Plugin>} */
|
||||
this._plugins = new Map();
|
||||
// 已 init 的插件 name(正序),dispose 时逆序遍历
|
||||
/** @type {string[]} */
|
||||
this._initOrder = [];
|
||||
// init 阶段被隔离的错误:[{ name, error }]
|
||||
/** @type {Array<{name:string, error:Error}>} */
|
||||
this._initErrors = [];
|
||||
// 生命周期阶段标记:'idle' | 'initialized' | 'disposed'
|
||||
this._phase = 'idle';
|
||||
// dispose 幂等标记
|
||||
this._disposed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册一个插件。**重名拒绝**(抛错),因为重名意味着能力覆盖语义不明,必须显式暴露。
|
||||
* 注册只入表,不触发 init(init 由 initAll 统一编排,保证顺序与错误隔离)。
|
||||
*
|
||||
* @param {Plugin} plugin 待注册插件
|
||||
* @returns {PluginRegistry} this(链式)
|
||||
* @throws {Error} 插件形状非法 / 重名
|
||||
*/
|
||||
register(plugin) {
|
||||
// 形状校验(最小必要:name/version/init 必须有且类型对)
|
||||
if (!plugin || typeof plugin !== 'object') {
|
||||
throw new Error('[PluginRegistry] register 需要一个插件对象');
|
||||
}
|
||||
if (typeof plugin.name !== 'string' || plugin.name.length === 0) {
|
||||
throw new Error('[PluginRegistry] 插件缺少合法 name');
|
||||
}
|
||||
if (typeof plugin.version !== 'string' || plugin.version.length === 0) {
|
||||
throw new Error('[PluginRegistry] 插件 ' + plugin.name + ' 缺少合法 version');
|
||||
}
|
||||
if (typeof plugin.init !== 'function') {
|
||||
throw new Error('[PluginRegistry] 插件 ' + plugin.name + ' 缺少 init 函数');
|
||||
}
|
||||
if (plugin.dispose != null && typeof plugin.dispose !== 'function') {
|
||||
throw new Error('[PluginRegistry] 插件 ' + plugin.name + ' 的 dispose 必须是函数或省略');
|
||||
}
|
||||
// 重名拒绝
|
||||
if (this._plugins.has(plugin.name)) {
|
||||
throw new Error('[PluginRegistry] 插件重名拒绝:' + plugin.name + ' 已注册');
|
||||
}
|
||||
this._plugins.set(plugin.name, plugin);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已注册某插件。
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(name) {
|
||||
return this._plugins.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取已注册插件(未注册返回 undefined)。
|
||||
* @param {string} name
|
||||
* @returns {Plugin|undefined}
|
||||
*/
|
||||
get(name) {
|
||||
return this._plugins.get(name);
|
||||
}
|
||||
|
||||
/** @returns {string[]} 已注册插件 name(注册顺序)。 */
|
||||
list() {
|
||||
return Array.from(this._plugins.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 依次 init 全部已注册插件(按注册顺序正序)。
|
||||
*
|
||||
* 错误隔离铁律:单个插件 init 抛错 → 记入 _initErrors,**不**中断后续插件 init,
|
||||
* 也**不**回滚已 init 的插件(回滚语义复杂且本波无需,留作 v2 边界)。
|
||||
* init 前做依赖声明校验:若插件声明 dependencies 含未注册项 → 视为该插件 init 失败
|
||||
* (记错误并跳过其 init),不连坐其它插件。
|
||||
*
|
||||
* @returns {{ ok: boolean, initialized: string[], errors: Array<{name:string, error:Error}> }}
|
||||
* ok=无任何 init 错误;initialized=成功 init 的插件 name(正序);errors=被隔离的错误。
|
||||
*/
|
||||
initAll() {
|
||||
if (this._phase === 'initialized') {
|
||||
// 防重复 init(幂等保护):已 init 过直接返回既有结果
|
||||
logWarn('[PluginRegistry] initAll 重复调用,已忽略(已处于 initialized)');
|
||||
return this._initResult();
|
||||
}
|
||||
if (this._phase === 'disposed') {
|
||||
throw new Error('[PluginRegistry] 已 dispose 的注册器不可再 initAll');
|
||||
}
|
||||
|
||||
for (const [name, plugin] of this._plugins) {
|
||||
// 1) 依赖声明校验:声明的依赖必须已注册
|
||||
const missing = this._missingDeps(plugin);
|
||||
if (missing.length > 0) {
|
||||
const err = new Error(
|
||||
'[PluginRegistry] 插件 ' + name + ' 依赖缺失:' + missing.join(', ') + '(未注册)'
|
||||
);
|
||||
this._initErrors.push({ name, error: err });
|
||||
logError('[PluginRegistry] 跳过 init(依赖缺失,已隔离):' + name, err);
|
||||
continue; // 隔离:不 init 本插件,不连坐其它
|
||||
}
|
||||
// 2) 构造受控上下文交给插件 init
|
||||
// 本波 Gate0:每插件拿到的是「同一份 host-dev 受控面」由外部 host 注入。
|
||||
// 注册器本身不造 context——context 由 host 在 initAll(ctx) 传入更合理,
|
||||
// 但为保持 Gate0 骨架自洽 + 可单测,这里允许注册器持有一个 context 工厂。
|
||||
try {
|
||||
plugin.init(this._context);
|
||||
this._initOrder.push(name);
|
||||
} catch (e) {
|
||||
// 错误隔离:单插件 init 抛错只隔离该插件
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
this._initErrors.push({ name, error: err });
|
||||
logError('[PluginRegistry] 插件 init 抛错(已隔离,不连坐):' + name, err);
|
||||
}
|
||||
}
|
||||
|
||||
this._phase = 'initialized';
|
||||
return this._initResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放全部已 init 的插件(按注册**逆序**,后进先出)。
|
||||
* **幂等**:重复调用安全(第二次起直接返回,不重复触发 dispose)。
|
||||
* 单个 dispose 抛错被隔离(记 stderr),不中断其它 dispose。
|
||||
*
|
||||
* @returns {{ disposed: string[], errors: Array<{name:string, error:Error}> }}
|
||||
*/
|
||||
disposeAll() {
|
||||
if (this._disposed) {
|
||||
// 幂等:已 dispose 过,直接返回空结果(不重复释放)
|
||||
return { disposed: [], errors: [] };
|
||||
}
|
||||
const disposed = [];
|
||||
const errors = [];
|
||||
// 逆序释放(后 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 可选)
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
this._disposed = true;
|
||||
this._phase = 'disposed';
|
||||
return { disposed, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定受控上下文(host 注入)。必须在 initAll 之前调用。
|
||||
* 设计说明:注册器不自造引擎句柄——context 来自 host(集成段=LittleJS 包装,
|
||||
* host-dev=createHostDevContext),从而「引擎可换」。
|
||||
* @param {PluginContext} context
|
||||
* @returns {PluginRegistry} this
|
||||
*/
|
||||
useContext(context) {
|
||||
if (this._phase !== 'idle') {
|
||||
throw new Error('[PluginRegistry] useContext 必须在 initAll 之前');
|
||||
}
|
||||
this._context = context;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 计算插件声明依赖中「未注册」的部分。 */
|
||||
_missingDeps(plugin) {
|
||||
if (!Array.isArray(plugin.dependencies) || plugin.dependencies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return plugin.dependencies.filter((dep) => !this._plugins.has(dep));
|
||||
}
|
||||
|
||||
/** 组装 initAll 返回结构。 */
|
||||
_initResult() {
|
||||
return {
|
||||
ok: this._initErrors.length === 0,
|
||||
initialized: this._initOrder.slice(),
|
||||
errors: this._initErrors.slice(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 四、零依赖运行时缺省能力 + 日志(错误路径留痕)
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* 选择单调时钟:performance.now > process.hrtime.bigint > Date.now(同 channel-probe 口径)。
|
||||
* @returns {() => number}
|
||||
*/
|
||||
function defaultMonotonicClock() {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.performance && typeof globalThis.performance.now === 'function') {
|
||||
return () => globalThis.performance.now();
|
||||
}
|
||||
if (typeof process !== 'undefined' && process.hrtime && typeof process.hrtime.bigint === 'function') {
|
||||
const origin = process.hrtime.bigint();
|
||||
return () => Number(process.hrtime.bigint() - origin) / 1e6;
|
||||
}
|
||||
return () => Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误日志(错误路径必须留痕)。host-dev/node 走 stderr;无 console 时静默吞(绝不二次抛)。
|
||||
* @param {string} msg 描述
|
||||
* @param {*} [err] 错误对象
|
||||
*/
|
||||
function logError(msg, err) {
|
||||
const detail = err && err.stack ? err.stack : err && err.message ? err.message : err;
|
||||
if (typeof console !== 'undefined' && typeof console.error === 'function') {
|
||||
console.error(msg + (detail ? ':' + detail : ''));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 告警日志(非致命提示)。
|
||||
* @param {string} msg
|
||||
*/
|
||||
function logWarn(msg) {
|
||||
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
||||
console.warn(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 五、导出(ESM)
|
||||
* core-protocol-v0 的公开面 = 下列符号;手写门面见 ./api.d.ts。
|
||||
* ========================================================================== */
|
||||
|
||||
export {
|
||||
PluginRegistry,
|
||||
createHostDevContext,
|
||||
SeededRandom,
|
||||
HostDevTime,
|
||||
// 内部工具导出,便于单测对确定性口径做断言(与 channel-probe 同策略)
|
||||
defaultMonotonicClock,
|
||||
};
|
||||
|
||||
/** 协议版本标识(冻结时 = core-protocol-v0)。 */
|
||||
export const CORE_PROTOCOL_VERSION = 'core-protocol-v0';
|
||||
318
game-runtime/src/core/plugin.test.mjs
Normal file
318
game-runtime/src/core/plugin.test.mjs
Normal file
@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 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):
|
||||
* 1. 注册 / 重名拒绝 / 形状非法拒绝;
|
||||
* 2. 生命周期顺序:init 正序、dispose 逆序(后进先出);
|
||||
* 3. dispose 幂等(重复 dispose 无副作用);
|
||||
* 4. 错误隔离:单插件 init 抛错不连坐其它插件;依赖缺失隔离不连坐;
|
||||
* 5. 受控面:createHostDevContext 的 time/random/onFrame/getContext2d 行为 + 确定性随机可复现;
|
||||
* 6. host-dev tick:驱动 onFrame、frame 单调、cancel 注销、回调抛错隔离。
|
||||
*
|
||||
* 测试基建:用步进时钟使 time 可预测;用计数数组记录生命周期顺序。零第三方依赖,仅 node:test/node:assert。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
PluginRegistry,
|
||||
createHostDevContext,
|
||||
SeededRandom,
|
||||
HostDevTime,
|
||||
} from './plugin.js';
|
||||
|
||||
/* ── 测试基建 ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 造一个步进时钟(每次 +step ms),保证 time 读数单调可预测。 */
|
||||
function stepClock(start, step) {
|
||||
let t = start;
|
||||
return () => {
|
||||
const cur = t;
|
||||
t += step;
|
||||
return cur;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 造一个记录生命周期顺序的「探针插件」。
|
||||
* @param {string} name
|
||||
* @param {string[]} log 共享顺序记录数组(init/dispose 各 push 一条)
|
||||
* @param {object} [opts] { throwOnInit?:boolean, throwOnDispose?:boolean, dependencies?:string[], noDispose?:boolean }
|
||||
*/
|
||||
function makeProbePlugin(name, log, opts = {}) {
|
||||
/** @type {import('./api.d.ts').Plugin} */
|
||||
const p = {
|
||||
name,
|
||||
version: '1.0.0',
|
||||
init(ctx) {
|
||||
log.push('init:' + name);
|
||||
if (opts.throwOnInit) throw new Error('boom-init:' + name);
|
||||
// 顺手验证 ctx 受控面存在(不直透裸引擎)
|
||||
assert.equal(typeof ctx.getContext2d, 'function');
|
||||
assert.equal(typeof ctx.onFrame, 'function');
|
||||
assert.equal(typeof ctx.time.nowMs, 'function');
|
||||
assert.equal(typeof ctx.random.next, 'function');
|
||||
},
|
||||
};
|
||||
if (opts.dependencies) p.dependencies = opts.dependencies;
|
||||
if (!opts.noDispose) {
|
||||
let disposedCount = 0;
|
||||
p.dispose = () => {
|
||||
disposedCount += 1;
|
||||
log.push('dispose:' + name + '#' + disposedCount);
|
||||
if (opts.throwOnDispose) throw new Error('boom-dispose:' + name);
|
||||
};
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/* ── 1. 注册 / 重名拒绝 / 形状校验 ──────────────────────────────────────────── */
|
||||
|
||||
test('注册成功 + list/has/get 反映注册态', () => {
|
||||
const reg = new PluginRegistry();
|
||||
const log = [];
|
||||
reg.register(makeProbePlugin('a', log)).register(makeProbePlugin('b', log));
|
||||
assert.deepEqual(reg.list(), ['a', 'b']);
|
||||
assert.equal(reg.has('a'), true);
|
||||
assert.equal(reg.has('zzz'), false);
|
||||
assert.equal(reg.get('b').version, '1.0.0');
|
||||
});
|
||||
|
||||
test('重名注册抛错拒绝', () => {
|
||||
const reg = new PluginRegistry();
|
||||
const log = [];
|
||||
reg.register(makeProbePlugin('dup', log));
|
||||
assert.throws(() => reg.register(makeProbePlugin('dup', log)), /重名拒绝/);
|
||||
});
|
||||
|
||||
test('形状非法注册抛错(缺 name/version/init、dispose 非函数)', () => {
|
||||
const reg = new PluginRegistry();
|
||||
assert.throws(() => reg.register(null), /需要一个插件对象/);
|
||||
assert.throws(() => reg.register({ version: '1.0.0', init() {} }), /缺少合法 name/);
|
||||
assert.throws(() => reg.register({ name: 'x', init() {} }), /缺少合法 version/);
|
||||
assert.throws(() => reg.register({ name: 'x', version: '1.0.0' }), /缺少 init/);
|
||||
assert.throws(
|
||||
() => reg.register({ name: 'x', version: '1.0.0', init() {}, dispose: 123 }),
|
||||
/dispose 必须是函数/
|
||||
);
|
||||
});
|
||||
|
||||
/* ── 2. 生命周期顺序:init 正序、dispose 逆序 ───────────────────────────────── */
|
||||
|
||||
test('init 按注册正序,dispose 按注册逆序(后进先出)', () => {
|
||||
const reg = new PluginRegistry();
|
||||
const log = [];
|
||||
const { context } = createHostDevContext({ clock: stepClock(0, 1) });
|
||||
reg.useContext(context);
|
||||
reg.register(makeProbePlugin('a', log));
|
||||
reg.register(makeProbePlugin('b', log));
|
||||
reg.register(makeProbePlugin('c', log));
|
||||
|
||||
const r = reg.initAll();
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.initialized, ['a', 'b', 'c']);
|
||||
assert.deepEqual(log, ['init:a', 'init:b', 'init:c']);
|
||||
|
||||
const d = reg.disposeAll();
|
||||
assert.deepEqual(d.disposed, ['c', 'b', 'a']);
|
||||
// dispose 顺序应为逆序
|
||||
assert.deepEqual(log.slice(3), ['dispose:c#1', 'dispose:b#1', 'dispose:a#1']);
|
||||
});
|
||||
|
||||
/* ── 3. dispose 幂等 ───────────────────────────────────────────────────────── */
|
||||
|
||||
test('disposeAll 幂等:重复调用不重复触发 dispose', () => {
|
||||
const reg = new PluginRegistry();
|
||||
const log = [];
|
||||
const { context } = createHostDevContext();
|
||||
reg.useContext(context);
|
||||
reg.register(makeProbePlugin('a', log));
|
||||
reg.initAll();
|
||||
|
||||
const d1 = reg.disposeAll();
|
||||
assert.deepEqual(d1.disposed, ['a']);
|
||||
// 第二次 dispose:幂等,返回空,且 log 不再增加 dispose 记录
|
||||
const before = log.length;
|
||||
const d2 = reg.disposeAll();
|
||||
assert.deepEqual(d2.disposed, []);
|
||||
assert.equal(log.length, before, '重复 dispose 不应再触发插件 dispose');
|
||||
});
|
||||
|
||||
test('initAll 幂等:重复 init 不重复触发 init', () => {
|
||||
const reg = new PluginRegistry();
|
||||
const log = [];
|
||||
const { context } = createHostDevContext();
|
||||
reg.useContext(context);
|
||||
reg.register(makeProbePlugin('a', log));
|
||||
reg.initAll();
|
||||
const before = log.length;
|
||||
reg.initAll(); // 第二次:应被幂等忽略
|
||||
assert.equal(log.length, before, '重复 init 不应再触发插件 init');
|
||||
});
|
||||
|
||||
test('dispose 后再 initAll 抛错(生命周期非法转移)', () => {
|
||||
const reg = new PluginRegistry();
|
||||
const { context } = createHostDevContext();
|
||||
reg.useContext(context);
|
||||
reg.register(makeProbePlugin('a', []));
|
||||
reg.initAll();
|
||||
reg.disposeAll();
|
||||
assert.throws(() => reg.initAll(), /已 dispose.*不可再 initAll/);
|
||||
});
|
||||
|
||||
/* ── 4. 错误隔离:单插件 init 抛错不连坐 ────────────────────────────────────── */
|
||||
|
||||
test('单插件 init 抛错被隔离,其它插件照常 init', () => {
|
||||
const reg = new PluginRegistry();
|
||||
const log = [];
|
||||
const { context } = createHostDevContext();
|
||||
reg.useContext(context);
|
||||
reg.register(makeProbePlugin('a', log));
|
||||
reg.register(makeProbePlugin('bad', log, { throwOnInit: true }));
|
||||
reg.register(makeProbePlugin('c', log));
|
||||
|
||||
const r = reg.initAll();
|
||||
// bad init 抛错被隔离:a、c 仍成功 init
|
||||
assert.equal(r.ok, false);
|
||||
assert.deepEqual(r.initialized, ['a', 'c']);
|
||||
assert.equal(r.errors.length, 1);
|
||||
assert.equal(r.errors[0].name, 'bad');
|
||||
assert.match(r.errors[0].error.message, /boom-init:bad/);
|
||||
// log 中三个 init 都被尝试(顺序正确),但 bad 未进 initialized
|
||||
assert.deepEqual(log, ['init:a', 'init:bad', 'init:c']);
|
||||
|
||||
// dispose 只逆序释放成功 init 的(a、c),不含 bad
|
||||
const d = reg.disposeAll();
|
||||
assert.deepEqual(d.disposed, ['c', 'a']);
|
||||
});
|
||||
|
||||
test('依赖缺失被隔离,不连坐其它插件', () => {
|
||||
const reg = new PluginRegistry();
|
||||
const log = [];
|
||||
const { context } = createHostDevContext();
|
||||
reg.useContext(context);
|
||||
// needsX 声明依赖 'x',但 x 未注册 → needsX 被隔离
|
||||
reg.register(makeProbePlugin('a', log));
|
||||
reg.register(makeProbePlugin('needsX', log, { dependencies: ['x'] }));
|
||||
reg.register(makeProbePlugin('b', log));
|
||||
|
||||
const r = reg.initAll();
|
||||
assert.equal(r.ok, false);
|
||||
assert.deepEqual(r.initialized, ['a', 'b']);
|
||||
assert.equal(r.errors.length, 1);
|
||||
assert.equal(r.errors[0].name, 'needsX');
|
||||
assert.match(r.errors[0].error.message, /依赖缺失.*x/);
|
||||
// needsX 的 init 未被调用(依赖缺失在 init 前拦截)
|
||||
assert.deepEqual(log, ['init:a', 'init:b']);
|
||||
});
|
||||
|
||||
test('依赖已注册则正常 init(声明依赖校验通过)', () => {
|
||||
const reg = new PluginRegistry();
|
||||
const log = [];
|
||||
const { context } = createHostDevContext();
|
||||
reg.useContext(context);
|
||||
reg.register(makeProbePlugin('base', log));
|
||||
reg.register(makeProbePlugin('dependent', log, { dependencies: ['base'] }));
|
||||
const r = reg.initAll();
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.initialized, ['base', 'dependent']);
|
||||
});
|
||||
|
||||
test('单插件 dispose 抛错被隔离,其它 dispose 照常', () => {
|
||||
const reg = new PluginRegistry();
|
||||
const log = [];
|
||||
const { context } = createHostDevContext();
|
||||
reg.useContext(context);
|
||||
reg.register(makeProbePlugin('a', log));
|
||||
reg.register(makeProbePlugin('bad', log, { throwOnDispose: true }));
|
||||
reg.register(makeProbePlugin('c', log));
|
||||
reg.initAll();
|
||||
|
||||
const d = reg.disposeAll();
|
||||
// bad dispose 抛错被隔离:a、c 仍成功 dispose
|
||||
assert.deepEqual(d.disposed, ['c', 'a']);
|
||||
assert.equal(d.errors.length, 1);
|
||||
assert.equal(d.errors[0].name, 'bad');
|
||||
});
|
||||
|
||||
/* ── 5. 受控面:time / random / 确定性可复现 ────────────────────────────────── */
|
||||
|
||||
test('SeededRandom 确定性:同种子同序列,reseed 复位', () => {
|
||||
const r1 = new SeededRandom(42);
|
||||
const r2 = new SeededRandom(42);
|
||||
const seq1 = [r1.next(), r1.next(), r1.next()];
|
||||
const seq2 = [r2.next(), r2.next(), r2.next()];
|
||||
assert.deepEqual(seq1, seq2, '同种子产同序列');
|
||||
// 全部落 [0,1)
|
||||
for (const v of seq1) assert.ok(v >= 0 && v < 1, 'next 落 [0,1)');
|
||||
// reseed 复位
|
||||
r1.reseed(42);
|
||||
assert.equal(r1.next(), seq1[0], 'reseed 后从头复现');
|
||||
// range 落 [min,max)
|
||||
r1.reseed(7);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const v = r1.range(10, 20);
|
||||
assert.ok(v >= 10 && v < 20, 'range 落 [10,20)');
|
||||
}
|
||||
// 不同种子大概率不同序列
|
||||
const rA = new SeededRandom(1);
|
||||
const rB = new SeededRandom(2);
|
||||
assert.notEqual(rA.next(), rB.next(), '不同种子序列不同');
|
||||
});
|
||||
|
||||
test('HostDevTime:nowMs 走注入时钟,elapsedMs 自起点递增', () => {
|
||||
const time = new HostDevTime(stepClock(1000, 5));
|
||||
// 构造时记 origin=1000(第一次 clock 调用)
|
||||
const a = time.nowMs(); // 1005
|
||||
const b = time.nowMs(); // 1010
|
||||
assert.ok(b > a, 'nowMs 单调递增');
|
||||
const e = time.elapsedMs(); // 1015 - 1000 = 15
|
||||
assert.ok(e > 0, 'elapsedMs 自起点为正');
|
||||
});
|
||||
|
||||
test('getContext2d:注入 null 时返回 null(node 侧无 canvas 可容忍)', () => {
|
||||
const { context } = createHostDevContext({ context2d: null });
|
||||
assert.equal(context.getContext2d(), null);
|
||||
// 注入 mock 上下文则透传
|
||||
const mockCtx = { __mock: true };
|
||||
const bundle2 = createHostDevContext({ context2d: mockCtx });
|
||||
assert.equal(bundle2.context.getContext2d(), mockCtx);
|
||||
});
|
||||
|
||||
/* ── 6. host-dev tick:驱动 onFrame、frame 单调、cancel、抛错隔离 ──────────── */
|
||||
|
||||
test('onFrame + tick:帧回调被驱动,frame 从 1 单调,dt 透传', () => {
|
||||
const { context, tick, frameCount } = createHostDevContext();
|
||||
const seen = [];
|
||||
context.onFrame((dt, frame) => seen.push({ dt, frame }));
|
||||
assert.equal(frameCount(), 0);
|
||||
tick(0.016);
|
||||
tick(0.017);
|
||||
assert.equal(frameCount(), 2);
|
||||
assert.deepEqual(seen, [
|
||||
{ dt: 0.016, frame: 1 },
|
||||
{ dt: 0.017, frame: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('FrameHandle.cancel 注销帧回调', () => {
|
||||
const { context, tick } = createHostDevContext();
|
||||
let count = 0;
|
||||
const handle = context.onFrame(() => { count += 1; });
|
||||
tick(0.016); // count=1
|
||||
handle.cancel();
|
||||
tick(0.016); // 已注销,不再增加
|
||||
assert.equal(count, 1);
|
||||
});
|
||||
|
||||
test('帧回调抛错被隔离:不连坐同帧其它回调,也不中断后续帧', () => {
|
||||
const { context, tick } = createHostDevContext();
|
||||
const good = [];
|
||||
context.onFrame(() => { throw new Error('frame-boom'); });
|
||||
context.onFrame((dt, frame) => good.push(frame));
|
||||
// 抛错回调不应让 tick 抛出,也不应阻止 good 回调
|
||||
assert.doesNotThrow(() => { tick(0.016); tick(0.016); });
|
||||
assert.deepEqual(good, [1, 2]);
|
||||
});
|
||||
69
game-runtime/src/plugins/_example/PLUGIN.md
Normal file
69
game-runtime/src/plugins/_example/PLUGIN.md
Normal file
@ -0,0 +1,69 @@
|
||||
# example —— 空插件样例(core-protocol-v0)
|
||||
|
||||
> owner:T1b-α lane-core(Gate0 奠基件)|本件 = `docs/PLUGIN-TEMPLATE.md` 六节规格的**活样板**。
|
||||
> 克隆新插件时:复制 `src/plugins/_example/` 整目录 → 改 `name`/版本/能力面 → 按本结构改写六节。
|
||||
|
||||
## 1. 能力
|
||||
|
||||
**定位**:一个**不含任何玩法语义**的最小空插件,专门演示「一个插件该如何实现 core 的 Plugin 接口、如何经 PluginContext 受控面拿引擎能力」。它是新插件的克隆起点,**不提供任何真实引擎能力**。
|
||||
|
||||
提供:
|
||||
- 一个工厂函数 `createExamplePlugin(opts)`,返回实现 core `Plugin` 接口的插件对象;
|
||||
- 一个只读探针 `plugin.probe()`,返回内部状态快照(帧数 / 最近随机值 / 是否拿到 2D 上下文 / 是否已 dispose),**仅供测试与诊断**(生产插件可删)。
|
||||
|
||||
**不提供**:任何玩法(金币/敌人/关卡…)、任何美术成品、任何 UI、任何真实引擎算法。这些都属 agent 造游戏时的生成域,不进插件。
|
||||
|
||||
## 2. 集成点
|
||||
|
||||
- **依赖**:无(`dependencies: []`)。
|
||||
- **受控面用法**(用到 `PluginContext` 4 项中的全部,作演示):
|
||||
- `getContext2d()`:init 时探测一次 2D 上下文;node 侧返回 `null` 时安全降级(探针记 `hasContext2d=false`),**不自行 `document.querySelector`**。
|
||||
- `onFrame(cb)`:init 时注册一个逐帧回调(累计帧数 / 取随机),拿到的 `FrameHandle` 存起来供 dispose 注销;**绝不直接 `requestAnimationFrame`**。
|
||||
- `time`:回调内经 `ctx.time.elapsedMs()` 取时间,**不读** `Date.now`/`performance.now`。
|
||||
- `random`:回调内经 `ctx.random.next()` 取随机,**不读** `Math.random`;如配置带 `seed` 则 init 时 `ctx.random.reseed(seed)` 使序列确定可复现。
|
||||
- **注册方式**:`registry.register(createExamplePlugin(opts))`。
|
||||
- **配置项 `opts`**:`{ seed?: number }` —— 给定时重设受控随机源种子,使样例随机序列确定可复现(仅演示「插件可带配置 + 取证友好写法」,无玩法含义)。
|
||||
|
||||
## 3. 示例
|
||||
|
||||
```js
|
||||
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 });
|
||||
|
||||
// 2) 注册器编排生命周期。
|
||||
const reg = new PluginRegistry();
|
||||
reg.useContext(context);
|
||||
reg.register(createExamplePlugin({ seed: 42 })); // seed 可选
|
||||
|
||||
// 3) init 全部插件(正序,错误隔离)。
|
||||
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 }
|
||||
|
||||
// 5) 释放(逆序,幂等)。
|
||||
reg.disposeAll();
|
||||
```
|
||||
|
||||
## 4. 测试
|
||||
|
||||
- 路径:`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 不增帧)。
|
||||
- node 层即可全测(本插件无渲染/发声能力,故无集成段浏览器证据需求)。
|
||||
|
||||
## 5. 边界与字节预算
|
||||
|
||||
- **字节预算**:`gzBudgetBytes = 2048`(与 `manifest.json` 一致)。本插件无实际能力,远低于此;克隆真插件后按各自配额(执行版 §5)核对,超配额触发评审。
|
||||
- **行为边界**:null 渲染环境降级(`getContext2d()` 返回 null 时不崩,仅记探针);`dispose` 幂等(重复调用安全、不留悬挂帧回调)。
|
||||
- **受控面铁律**:只经 `PluginContext` 的 4 项受控面拿引擎能力,**绝不直透 littlejsengine 裸对象 / 全局 canvas / 原生 RAF** —— 保「引擎可换」。
|
||||
|
||||
## 6. v2 声明
|
||||
|
||||
本件是「永久空样板」,**刻意不实现任何真实引擎能力**,因此无功能性 v2 待办。克隆者请删除本节并改为「克隆得到的真插件」自身的 v2 边界声明(如某能力的进阶版留待实证后做)。
|
||||
69
game-runtime/src/plugins/_example/api.d.ts
vendored
Normal file
69
game-runtime/src/plugins/_example/api.d.ts
vendored
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* _example/api.d.ts — 空插件样例的公开类型门面(手写,对齐 core-protocol-v0)
|
||||
* owner:T1b-α lane-core(Gate0 奠基件 · 可直接克隆为新插件骨架)
|
||||
* 消费方:所有 lane(读本件了解「一个插件的 d.ts 该长什么样」)+ 集成段 host
|
||||
*
|
||||
* 【本件定位】
|
||||
* 这是「最小空插件」的类型契约样板。它不含任何玩法语义,只演示:
|
||||
* 1) 如何复用 core 的 Plugin / PluginContext 类型(从 ../../core/api.d.ts 引入);
|
||||
* 2) 一个插件工厂函数(createXxxPlugin)的对外签名应如何手写声明;
|
||||
* 3) manifest.json 的 exports.named 与本件导出的具名符号如何一一对应。
|
||||
* 克隆新插件时:复制本目录 → 改 name/版本/能力面,d.ts 的写法照搬。
|
||||
*
|
||||
* 【受控面铁律(继承自 core)】
|
||||
* 插件只经 PluginContext 的 4 项受控面(getContext2d/onFrame/time/random)拿引擎能力,
|
||||
* 绝不直透 littlejsengine 裸对象 —— 本样例严格示范此约束。
|
||||
*/
|
||||
|
||||
// 复用 core 的协议类型,不在插件层重复定义(单一事实源 = core/api.d.ts)。
|
||||
import type { Plugin, PluginContext } from '../../core/api.d.ts';
|
||||
|
||||
/**
|
||||
* 创建一个空插件实例(样例工厂)。
|
||||
*
|
||||
* 该插件 init 时仅做「无害的受控面演示」:
|
||||
* - 经 ctx.onFrame 注册一个逐帧回调,用 ctx.time / ctx.random 累计无副作用的内部计数;
|
||||
* - 经 ctx.getContext2d 尝试取 2D 上下文,取不到(node 侧为 null)即安全降级;
|
||||
* - dispose 时撤销帧回调并复位内部状态(幂等:重复 dispose 安全)。
|
||||
* 它不渲染玩法、不改全局、不假设任何品类 —— 纯协议用法演示。
|
||||
*
|
||||
* @param opts 可选配置:仅演示「插件可接受自身配置」这一模式,无玩法含义。
|
||||
* @returns 一个实现 core Plugin 接口的插件对象,可直接 register 进 PluginRegistry。
|
||||
*/
|
||||
export declare function createExamplePlugin(opts?: ExamplePluginOptions): ExamplePlugin;
|
||||
|
||||
/** 空插件的可选配置(仅示范「插件可带配置」,字段无玩法语义)。 */
|
||||
export interface ExamplePluginOptions {
|
||||
/**
|
||||
* 演示用的初始随机种子。给定时,插件 init 会对 ctx.random.reseed(seed),
|
||||
* 使「样例帧回调里取的随机序列」确定可复现(示范取证友好写法)。缺省不重设种子。
|
||||
*/
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 空插件实例类型 —— 即 core Plugin,外加样例自暴露的只读探针字段,
|
||||
* 便于单测断言「受控面确实被驱动」(生产插件可不暴露此类内省字段,这里仅为可测性示范)。
|
||||
*/
|
||||
export interface ExamplePlugin extends Plugin {
|
||||
/**
|
||||
* 只读探针:返回插件内部累计状态快照(帧数 / 最近一次随机值 / 是否拿到 2D 上下文)。
|
||||
* 仅供测试与诊断观察,**不是**插件对玩法层的能力面(生产插件按需删除)。
|
||||
*/
|
||||
readonly probe: () => ExampleProbeSnapshot;
|
||||
}
|
||||
|
||||
/** 样例探针快照(仅测试/诊断用)。 */
|
||||
export interface ExampleProbeSnapshot {
|
||||
/** init 后经由 onFrame 累计被驱动的帧数。 */
|
||||
frames: number;
|
||||
/** 最近一帧用 ctx.random.next() 取到的随机值(未驱动过为 null)。 */
|
||||
lastRandom: number | null;
|
||||
/** init 时 ctx.getContext2d() 是否返回了非 null 上下文(node 侧通常 false)。 */
|
||||
hasContext2d: boolean;
|
||||
/** 是否已 dispose(幂等观察用)。 */
|
||||
disposed: boolean;
|
||||
}
|
||||
|
||||
// 同时把复用的协议类型再导出,方便克隆者「只 import 本插件 d.ts」即可拿到全套类型。
|
||||
export type { Plugin, PluginContext } from '../../core/api.d.ts';
|
||||
127
game-runtime/src/plugins/_example/impl.js
Normal file
127
game-runtime/src/plugins/_example/impl.js
Normal file
@ -0,0 +1,127 @@
|
||||
/**
|
||||
* _example/impl.js — 空插件样例实现(ESM + JSDoc,core-protocol-v0)
|
||||
* owner:T1b-α lane-core(Gate0 奠基件 · 新插件克隆起点)
|
||||
*
|
||||
* 【本文件定位】
|
||||
* 「最小可注册插件」的参考实现。它实现 core 定义的 Plugin 接口,**只演示 PluginContext
|
||||
* 受控面的正确用法**,不含任何玩法/美术/关卡/UI 语义。其余 lane 写真插件时,照搬本文件
|
||||
* 的骨架(工厂函数 + init 拿受控面 + dispose 幂等清理 + 容忍 null 渲染环境)。
|
||||
*
|
||||
* 【源码形态纪律(执行版 §1)】
|
||||
* ESM JS + JSDoc,零工具链;类型门面在手写 ./api.d.ts;本机 `node --test` 直跑。
|
||||
* 不 import littlejsengine —— 一切引擎能力经 ctx 受控面拿。
|
||||
*
|
||||
* 【它演示了什么(给克隆者看)】
|
||||
* 1. 工厂模式:createExamplePlugin(opts) 返回插件对象(而非裸对象字面量),
|
||||
* 便于带配置、便于闭包封装内部状态(生产插件普遍如此)。
|
||||
* 2. 受控面四项用法:getContext2d(容忍 null 降级)/ onFrame(拿句柄,dispose 时 cancel)/
|
||||
* time(取时间不读 Date.now)/ random(取随机不读 Math.random,可注种子复现)。
|
||||
* 3. dispose 幂等:重复调用安全,且精确撤销 init 申请的帧回调(不留悬挂回调)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @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('./api.d.ts').ExamplePlugin} ExamplePlugin
|
||||
* @typedef {import('./api.d.ts').ExamplePluginOptions} ExamplePluginOptions
|
||||
* @typedef {import('./api.d.ts').ExampleProbeSnapshot} ExampleProbeSnapshot
|
||||
*/
|
||||
|
||||
/**
|
||||
* 创建一个空插件实例(样例工厂)。
|
||||
* @param {ExamplePluginOptions} [opts] 可选配置(仅演示「插件可带配置」,无玩法语义)。
|
||||
* @returns {ExamplePlugin} 实现 core Plugin 接口的插件对象。
|
||||
*/
|
||||
export function createExamplePlugin(opts) {
|
||||
const options = opts || {};
|
||||
|
||||
// ── 插件内部状态(用闭包封装,不挂全局;这是生产插件管理资源的推荐姿势)──
|
||||
/** @type {FrameHandle|null} init 时申请的帧回调句柄;dispose 时据此精确注销。 */
|
||||
let frameHandle = null;
|
||||
/** @type {number} 经 onFrame 累计被驱动的帧数(探针用)。 */
|
||||
let frames = 0;
|
||||
/** @type {number|null} 最近一帧取到的随机值(探针用)。 */
|
||||
let lastRandom = null;
|
||||
/** @type {boolean} init 时是否拿到了非 null 的 2D 上下文(node 侧通常 false)。 */
|
||||
let hasContext2d = false;
|
||||
/** @type {boolean} 是否已 dispose(幂等保护 + 探针用)。 */
|
||||
let disposed = false;
|
||||
|
||||
/** @type {ExamplePlugin} */
|
||||
const plugin = {
|
||||
// 插件唯一名:kebab-case,无玩法语义;与 manifest.json 的 name 一致。
|
||||
name: 'example',
|
||||
// 插件版本:semver,随发行版整体演进;与 manifest.json 的 version 一致。
|
||||
version: '1.0.0',
|
||||
// 本样例无依赖;克隆真插件时按需声明 dependencies: ['collision', ...]。
|
||||
dependencies: [],
|
||||
|
||||
/**
|
||||
* 初始化:拿受控上下文 ctx,完成自身资源/帧回调注册。
|
||||
* 禁止在此触碰 littlejsengine 裸对象 —— 只用 ctx 暴露的 4 项受控面。
|
||||
* @param {PluginContext} ctx 受控上下文(host 注入:集成段=LittleJS 包装,host-dev=桩)。
|
||||
*/
|
||||
init(ctx) {
|
||||
// 1) 受控随机源:如配置给了种子,重设以使样例序列确定可复现(取证友好示范)。
|
||||
// 插件取随机一律走 ctx.random,绝不读 Math.random。
|
||||
if (typeof options.seed === 'number') {
|
||||
ctx.random.reseed(options.seed);
|
||||
}
|
||||
|
||||
// 2) 受控 2D 上下文:host 决定底层来源;node 侧可能为 null —— 必须容忍并降级。
|
||||
// 这里仅探测一次,不持有裸 canvas(演示「插件不自行 document.querySelector」)。
|
||||
const c2d = ctx.getContext2d();
|
||||
hasContext2d = c2d != null;
|
||||
|
||||
// 3) 逐帧回调:经 ctx.onFrame 注册(**禁止**插件直接 requestAnimationFrame)。
|
||||
// 回调内只做无副作用的内部计数:用 ctx.time 取时间、用 ctx.random 取随机,
|
||||
// 演示「逐帧逻辑应如何拿时间/随机」。拿到的句柄存起来,dispose 时 cancel。
|
||||
frameHandle = ctx.onFrame((dtSeconds, frame) => {
|
||||
frames += 1;
|
||||
// 取「现在」走受控时间源(不读 performance.now/Date.now),仅做无害读取演示。
|
||||
// 注:此处刻意不缓存 now,只演示调用合法性;生产插件按需用 dtSeconds/elapsedMs 推进逻辑。
|
||||
void ctx.time.elapsedMs();
|
||||
// 取随机走受控随机源(确定性,可复现),记录最近一次值供探针断言。
|
||||
lastRandom = ctx.random.next();
|
||||
// 演示帧入参可用:dtSeconds=本帧时间步、frame=单调帧序号(从 1 起)。此处不消费,仅示形。
|
||||
void dtSeconds;
|
||||
void frame;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 释放:撤销 init 申请的帧回调并复位内部状态。
|
||||
* 幂等:重复调用安全(第二次起直接返回,不重复 cancel、不报错)。
|
||||
* 注册器另保证「逆序触发 + 幂等调用」,插件内部亦自保幂等(双保险)。
|
||||
*/
|
||||
dispose() {
|
||||
if (disposed) {
|
||||
return; // 幂等:已 dispose 过,直接返回(不二次撤销)。
|
||||
}
|
||||
if (frameHandle) {
|
||||
frameHandle.cancel(); // 精确注销 init 申请的帧回调,不留悬挂回调。
|
||||
frameHandle = null;
|
||||
}
|
||||
disposed = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 只读探针:返回内部状态快照(仅供测试/诊断观察)。
|
||||
* 生产插件可删除此字段 —— 它不是给玩法层的能力面,只为本样例可测性而存在。
|
||||
* @returns {ExampleProbeSnapshot}
|
||||
*/
|
||||
probe() {
|
||||
return {
|
||||
frames,
|
||||
lastRandom,
|
||||
hasContext2d,
|
||||
disposed,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return plugin;
|
||||
}
|
||||
12
game-runtime/src/plugins/_example/manifest.json
Normal file
12
game-runtime/src/plugins/_example/manifest.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "example",
|
||||
"version": "1.0.0",
|
||||
"gzBudgetBytes": 2048,
|
||||
"dependencies": [],
|
||||
"exports": {
|
||||
"impl": "impl.js",
|
||||
"types": "api.d.ts",
|
||||
"named": ["createExamplePlugin"]
|
||||
},
|
||||
"description": "空插件样例:演示 Plugin 接口与 PluginContext 受控面用法,无任何玩法语义,可直接克隆为新插件骨架。"
|
||||
}
|
||||
123
game-runtime/src/plugins/_example/test/example.test.mjs
Normal file
123
game-runtime/src/plugins/_example/test/example.test.mjs
Normal file
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* _example/test/example.test.mjs — 空插件样例零依赖单测(node --test 直跑)
|
||||
* owner:T1b-α lane-core | 运行:node --test src/plugins/_example/test/example.test.mjs
|
||||
*
|
||||
* 【本测定位】
|
||||
* 既验证「空插件样例实现正确」,又作为 lane 写插件测试的**模板**:
|
||||
* 零第三方依赖(仅 node:test/node:assert),经 core 的 createHostDevContext 拿受控面 +
|
||||
* host 私有 tick 驱动帧回调,断言插件在受控面下的行为与生命周期。
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. 插件形状合法(能被 PluginRegistry register/initAll/disposeAll 全流程接纳);
|
||||
* 2. 受控面用法:onFrame 被 tick 驱动 → 探针帧数递增;random 注种子后确定可复现;
|
||||
* getContext2d 为 null 时安全降级(node 侧);
|
||||
* 3. dispose 幂等 + 精确撤销帧回调(dispose 后再 tick 不再增帧)。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// 经 core 协议入口拿注册器与 host-dev 受控面工厂(单一事实源)。
|
||||
import { PluginRegistry, createHostDevContext } from '../../../core/plugin.js';
|
||||
// 被测样例插件工厂。
|
||||
import { createExamplePlugin } from '../impl.js';
|
||||
|
||||
/* ── 1. 全流程接纳:register → initAll → disposeAll 不报错 ─────────────────── */
|
||||
|
||||
test('空插件样例可被 PluginRegistry 全流程接纳(register/initAll/disposeAll)', () => {
|
||||
const reg = new PluginRegistry();
|
||||
const { context } = createHostDevContext({ context2d: null });
|
||||
reg.useContext(context);
|
||||
reg.register(createExamplePlugin());
|
||||
|
||||
const r = reg.initAll();
|
||||
assert.equal(r.ok, true, 'init 应无错误');
|
||||
assert.deepEqual(r.initialized, ['example'], 'example 应成功 init');
|
||||
|
||||
const d = reg.disposeAll();
|
||||
assert.deepEqual(d.disposed, ['example'], 'example 应成功 dispose');
|
||||
assert.equal(d.errors.length, 0, 'dispose 应无错误');
|
||||
});
|
||||
|
||||
/* ── 2. 受控面:onFrame 被 tick 驱动 → 探针帧数递增 ──────────────────────── */
|
||||
|
||||
test('受控面 onFrame:tick 驱动后探针帧数递增、记录最近随机值', () => {
|
||||
const { context, tick } = createHostDevContext({ context2d: null });
|
||||
const plugin = createExamplePlugin();
|
||||
// 直接 init(不经注册器也可,演示插件 init 只依赖受控面 context)。
|
||||
plugin.init(context);
|
||||
|
||||
assert.equal(plugin.probe().frames, 0, 'init 后未 tick,帧数为 0');
|
||||
assert.equal(plugin.probe().lastRandom, null, '未 tick 时最近随机值为 null');
|
||||
|
||||
tick(0.016);
|
||||
tick(0.016);
|
||||
tick(0.016);
|
||||
|
||||
const snap = plugin.probe();
|
||||
assert.equal(snap.frames, 3, '三次 tick 后帧数应为 3');
|
||||
assert.ok(snap.lastRandom !== null && snap.lastRandom >= 0 && snap.lastRandom < 1,
|
||||
'最近随机值应落 [0,1)');
|
||||
});
|
||||
|
||||
/* ── 3. 确定性:同种子两实例,随机序列逐帧一致(取证友好示范)──────────────── */
|
||||
|
||||
test('受控随机源注种子后确定可复现:同种子两实例随机序列一致', () => {
|
||||
function run(seed) {
|
||||
const { context, tick } = createHostDevContext({ context2d: null });
|
||||
const plugin = createExamplePlugin({ seed });
|
||||
plugin.init(context);
|
||||
const vals = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
tick(0.016);
|
||||
vals.push(plugin.probe().lastRandom);
|
||||
}
|
||||
return vals;
|
||||
}
|
||||
const a = run(12345);
|
||||
const b = run(12345);
|
||||
assert.deepEqual(a, b, '同种子应产出逐帧一致的随机序列');
|
||||
|
||||
// 不同种子大概率不同序列(防「种子没生效」假阳性)。
|
||||
const c = run(999);
|
||||
assert.notDeepEqual(a, c, '不同种子序列应不同');
|
||||
});
|
||||
|
||||
/* ── 4. getContext2d 降级:node 侧 null 安全,注入 mock 则探测为 true ──────── */
|
||||
|
||||
test('getContext2d 容忍 null(node 侧);注入 mock 上下文则探针 hasContext2d=true', () => {
|
||||
// 4a. node 侧 null:hasContext2d=false,init 不抛错。
|
||||
const b1 = createHostDevContext({ context2d: null });
|
||||
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 p2 = createExamplePlugin();
|
||||
p2.init(b2.context);
|
||||
assert.equal(p2.probe().hasContext2d, true, '注入 mock 上下文时 hasContext2d 应为 true');
|
||||
});
|
||||
|
||||
/* ── 5. dispose 幂等 + 精确撤销帧回调 ──────────────────────────────────────── */
|
||||
|
||||
test('dispose 幂等且精确撤销帧回调:dispose 后再 tick 不再增帧', () => {
|
||||
const { context, tick } = createHostDevContext({ context2d: null });
|
||||
const plugin = createExamplePlugin();
|
||||
plugin.init(context);
|
||||
|
||||
tick(0.016); // frames=1
|
||||
assert.equal(plugin.probe().frames, 1);
|
||||
|
||||
plugin.dispose();
|
||||
assert.equal(plugin.probe().disposed, true, 'dispose 后 disposed 应为 true');
|
||||
|
||||
tick(0.016); // 帧回调已撤销,不应再增帧
|
||||
assert.equal(plugin.probe().frames, 1, 'dispose 后再 tick 帧数不应增加(回调已注销)');
|
||||
|
||||
// 幂等:重复 dispose 不抛错、不改变状态。
|
||||
assert.doesNotThrow(() => { plugin.dispose(); plugin.dispose(); },
|
||||
'重复 dispose 应安全(幂等)');
|
||||
assert.equal(plugin.probe().frames, 1, '重复 dispose 不应影响帧计数');
|
||||
});
|
||||
96
game-runtime/test/RESULT.txt
Normal file
96
game-runtime/test/RESULT.txt
Normal file
@ -0,0 +1,96 @@
|
||||
# game-runtime 全量单测原始输出(node --test)
|
||||
# 生成命令:bash scripts/test.sh
|
||||
# 生成时间:2026-06-12 11:05:40 UTC
|
||||
# node 版本:v24.16.0
|
||||
# 覆盖:src/core/plugin.test.mjs(core 17)+ src/plugins/_example/test/example.test.mjs(_example 5)
|
||||
# ============================================================================
|
||||
|
||||
[test.sh] 仓根:/root/games-development-ai/game-runtime
|
||||
[test.sh] 收集测试文件(*.test.mjs / *.test.cjs)...
|
||||
[test.sh] 共 2 个测试文件:
|
||||
- src/core/plugin.test.mjs
|
||||
- src/plugins/_example/test/example.test.mjs
|
||||
[test.sh] 开始 node --test ...
|
||||
|
||||
[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 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)
|
||||
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 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)
|
||||
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 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)
|
||||
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 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 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 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 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
|
||||
ℹ suites 0
|
||||
ℹ pass 22
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 211.577139
|
||||
235
game-runtime/test/harness/browser-evidence.cjs
Normal file
235
game-runtime/test/harness/browser-evidence.cjs
Normal file
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* test/harness/browser-evidence.cjs — 浏览器视觉证据 harness(骨架契约,core-protocol-v0)
|
||||
* owner:T1b-α lane-core(Gate0 奠基件)|消费方:集成段(mini-desktop Chrome)+ lane-vfx/lane-sys 视觉/渲染证据
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【本阶段交付边界 —— 重要】
|
||||
* Gate0 **只交本骨架 + 契约 + 使用说明**,**不要求本机真跑**。理由(执行版 §1/§5/§2):
|
||||
* · 真跑需 CDP 连真 Chrome(headless)+ 渲染真 canvas → **仅集成段(mini-desktop)**;
|
||||
* · 本机(6c6g)禁启 headless Chrome(OOM/exit144,CLAUDE.md 红线);
|
||||
* · vfx/audio 的渲染/发声证据按 spec **必须**在集成段浏览器出,**禁 node-canvas 兜底**。
|
||||
* 因此本文件中「连 CDP / 抓 ImageData / 截图」均为**契约占位(throw NOT_IMPLEMENTED)**,
|
||||
* 集成段 lane 接真 CDP 后实现这些占位即可;而**纯计算口径**(FNV-1a 哈希 / 非空 / 色彩分布 /
|
||||
* 几何断言)是**完整可跑实现**(不依赖浏览器),集成段直接复用,保证证据口径在 Gate0 即冻结。
|
||||
*
|
||||
* 【固定取证环境(集成段实现时必须照此设,§2 第6条)】
|
||||
* viewport = 390×844(移动竖屏基准);DPR = 2;
|
||||
* seed = 注入受控随机源种子(复现);时间源 mock = 注入虚拟时钟驱动帧(复现,不依赖真实墙钟)。
|
||||
* → 同一 build + 同 seed + 同 mock 时钟 ⇒ 渲染逐像素确定 ⇒ ImageData 哈希可作回归断言。
|
||||
*
|
||||
* 【证据四件(与参考件/集成段口径对齐,§4/§6)】
|
||||
* ① ImageData FNV-1a 哈希(逐像素,回归对照);② 非空断言(画面有不透明像素,非全透);
|
||||
* ③ 色彩分布(直方图,防「画了但全黑」);④ 几何断言(指定区域命中预期像素,验「画在了该画的位置」);
|
||||
* 截图落 evidence/<round|integration>/<name>.png(路径约定见 §6)。
|
||||
*
|
||||
* 【哈希口径】FNV-1a 32-bit —— 与 P10 RuntimeProbe 的哈希链同口径(§3),全发行版证据用同一哈希族。
|
||||
*
|
||||
* 用法(集成段,伪流程):
|
||||
* const H = require('./browser-evidence.cjs');
|
||||
* const cdp = await H.connectCdp({ url, viewport:{w:390,h:844}, dpr:2, seed, mockClock:true });
|
||||
* await H.driveFrames(cdp, 60); // 用 mock 时钟推进 60 帧
|
||||
* const img = await H.captureImageData(cdp, 'canvas'); // 抓 canvas 像素
|
||||
* const ev = H.buildEvidence(img, { geometry:[{x,y,w,h,expect:'non-empty'}] });
|
||||
* await H.saveScreenshot(cdp, H.screenshotPath('integration','first-paint'));
|
||||
* // ev = { hash, nonEmpty, histogramTop, geometry:[...] } → 落 evidence + sha256-manifest
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
/** 占位错误:集成段需实现的浏览器侧能力,本阶段调用即抛(不静默假成功)。 */
|
||||
function notImplemented(what) {
|
||||
const e = new Error(
|
||||
`[browser-evidence] ${what} 为集成段占位 —— 本骨架不在本机真跑(须 mini-desktop Chrome via CDP)。` +
|
||||
` 集成段 lane 实现本函数后即可启用。`
|
||||
);
|
||||
e.code = 'NOT_IMPLEMENTED_IN_GATE0';
|
||||
return e;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 一、固定取证环境常量(集成段实现 connectCdp 时必须照此设)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 固定取证环境(§2 第6条)。集成段建会话时照此设置,保证证据可复现。 */
|
||||
const FIXED_ENV = Object.freeze({
|
||||
viewport: Object.freeze({ width: 390, height: 844 }), // 移动竖屏基准
|
||||
deviceScaleFactor: 2, // DPR 2
|
||||
// seed / mockClock 由调用方按用例传入;此处声明「必须可注入」这一契约。
|
||||
requiresSeed: true,
|
||||
requiresMockClock: true,
|
||||
});
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 二、纯计算口径(不依赖浏览器,Gate0 即完整实现并冻结口径,集成段直接复用)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* FNV-1a 32-bit 哈希(与 P10 probe 哈希链同口径)。对 ImageData 的 RGBA 字节序列逐字节哈希。
|
||||
* @param {Uint8Array|Buffer|number[]} bytes 字节序列(如 ImageData.data)
|
||||
* @returns {string} 8 位十六进制哈希串(如 'a3f1c0de')
|
||||
*/
|
||||
function fnv1a32(bytes) {
|
||||
let h = 0x811c9dc5; // FNV offset basis
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
h ^= bytes[i] & 0xff;
|
||||
// FNV prime 16777619,用 Math.imul 保 32 位乘法
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return (h >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 非空断言:画面是否「有内容」(防「成功抓图但全透明,啥也没画」)。
|
||||
* 判据:存在任一不透明像素(alpha>0)即视为非空。
|
||||
* 注:本断言只判「画面非全透」;「画面非纯一色」(如背景同色没画东西)由 colorHistogram
|
||||
* 的主色集中度 + assertGeometry 的区域命中覆盖,不混入本断言,避免「画了单色物体反被判空」。
|
||||
* @param {{ data:(Uint8Array|number[]), width:number, height:number }} img ImageData 形
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function assertNonEmpty(img) {
|
||||
const d = img.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
if (d[i + 3] > 0) return true; // 存在不透明像素 → 画面有内容
|
||||
}
|
||||
return false; // 全透明 → 视为空
|
||||
}
|
||||
|
||||
/**
|
||||
* 色彩分布直方图:把 RGB 量化到粗粒度桶,统计 topN 主色(防「画了但全黑/全白」)。
|
||||
* @param {{ data:(Uint8Array|number[]) }} img
|
||||
* @param {number} [topN=5] 返回前 N 主色
|
||||
* @returns {Array<{ color:string, count:number }>} 形如 [{color:'r0g128b255', count:1234}, ...]
|
||||
*/
|
||||
function colorHistogram(img, topN = 5) {
|
||||
const d = img.data;
|
||||
/** @type {Map<string, number>} */
|
||||
const buckets = new Map();
|
||||
// 量化到 32 级/通道(>>3),降桶数;只统计不透明像素。
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
if (d[i + 3] === 0) continue;
|
||||
const key = `r${d[i] >> 3}g${d[i + 1] >> 3}b${d[i + 2] >> 3}`;
|
||||
buckets.set(key, (buckets.get(key) || 0) + 1);
|
||||
}
|
||||
return Array.from(buckets.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, topN)
|
||||
.map(([color, count]) => ({ color, count }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 几何断言:检查指定矩形区域是否命中预期('non-empty' = 区域内有不透明像素)。
|
||||
* 用于验「画在了该画的位置」(如:飞机应出现在屏幕下方中央矩形内)。
|
||||
* @param {{ data:(Uint8Array|number[]), width:number, height:number }} img
|
||||
* @param {Array<{ x:number, y:number, w:number, h:number, expect:'non-empty'|'empty' }>} regions
|
||||
* @returns {Array<{ region:object, pass:boolean, opaquePixels:number }>}
|
||||
*/
|
||||
function assertGeometry(img, regions) {
|
||||
const d = img.data;
|
||||
const W = img.width;
|
||||
return (regions || []).map((reg) => {
|
||||
let opaque = 0;
|
||||
for (let yy = reg.y; yy < reg.y + reg.h; yy++) {
|
||||
for (let xx = reg.x; xx < reg.x + reg.w; xx++) {
|
||||
const idx = (yy * W + xx) * 4;
|
||||
if (d[idx + 3] > 0) opaque++;
|
||||
}
|
||||
}
|
||||
const hasContent = opaque > 0;
|
||||
const pass = reg.expect === 'empty' ? !hasContent : hasContent;
|
||||
return { region: reg, pass, opaquePixels: opaque };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一张 ImageData 汇成「证据对象」(四件套:哈希/非空/色彩分布/几何)。
|
||||
* 这是集成段把渲染结果落证的统一入口;纯计算,Gate0 即可用。
|
||||
* @param {{ data:(Uint8Array|number[]), width:number, height:number }} img
|
||||
* @param {{ geometry?: Array<object> }} [opts]
|
||||
* @returns {{ hash:string, nonEmpty:boolean, histogramTop:Array, geometry:Array, width:number, height:number }}
|
||||
*/
|
||||
function buildEvidence(img, opts = {}) {
|
||||
return {
|
||||
hash: fnv1a32(img.data),
|
||||
nonEmpty: assertNonEmpty(img),
|
||||
histogramTop: colorHistogram(img, 5),
|
||||
geometry: assertGeometry(img, opts.geometry || []),
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图路径约定(§6):evidence/<bucket>/<name>.png。
|
||||
* @param {'integration'|`round-${number}`|string} bucket 证据桶(集成段=integration;参考件=round-N)
|
||||
* @param {string} name 截图名(不含扩展名)
|
||||
* @returns {string} 相对仓根的截图路径
|
||||
*/
|
||||
function screenshotPath(bucket, name) {
|
||||
return path.posix.join('evidence', bucket, `${name}.png`);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 三、浏览器侧能力(集成段占位 —— 本阶段抛 NOT_IMPLEMENTED,不在本机跑)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* 连接 CDP 并按固定环境建会话(集成段实现)。
|
||||
* 集成段须:用 FIXED_ENV 设 viewport/DPR;注入 seed 到发行版受控随机源;
|
||||
* 注入 mock 时钟接管 onFrame 驱动(不走真实 RAF/墙钟)。
|
||||
* @param {{ url:string, seed:number, viewport?:object, dpr?:number, mockClock?:boolean }} _opts
|
||||
* @returns {Promise<object>} CDP 会话句柄(集成段定义形状)
|
||||
*/
|
||||
async function connectCdp(_opts) {
|
||||
throw notImplemented('connectCdp');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 mock 时钟推进 N 帧(集成段实现:驱动发行版 onFrame,确定步进)。
|
||||
* @param {object} _cdp connectCdp 返回的会话
|
||||
* @param {number} _frames 帧数
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function driveFrames(_cdp, _frames) {
|
||||
throw notImplemented('driveFrames');
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓取指定 canvas 的 ImageData(集成段实现:CDP Runtime.evaluate 读 getImageData)。
|
||||
* 返回 { data, width, height },交 buildEvidence 汇证。
|
||||
* @param {object} _cdp 会话
|
||||
* @param {string} _selector canvas 选择器
|
||||
* @returns {Promise<{ data:Uint8Array, width:number, height:number }>}
|
||||
*/
|
||||
async function captureImageData(_cdp, _selector) {
|
||||
throw notImplemented('captureImageData');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存截图到约定路径(集成段实现:CDP Page.captureScreenshot 落盘)。
|
||||
* @param {object} _cdp 会话
|
||||
* @param {string} _outPath screenshotPath() 给出的路径
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function saveScreenshot(_cdp, _outPath) {
|
||||
throw notImplemented('saveScreenshot');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// 常量
|
||||
FIXED_ENV,
|
||||
// 纯计算口径(Gate0 完整实现,集成段复用)
|
||||
fnv1a32,
|
||||
assertNonEmpty,
|
||||
colorHistogram,
|
||||
assertGeometry,
|
||||
buildEvidence,
|
||||
screenshotPath,
|
||||
// 浏览器侧占位(集成段实现)
|
||||
connectCdp,
|
||||
driveFrames,
|
||||
captureImageData,
|
||||
saveScreenshot,
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user