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:
zizi 2026-06-12 11:10:12 +00:00
parent 8d3cf1383f
commit f59991b9f3
18 changed files with 2485 additions and 0 deletions

95
game-runtime/README.md Normal file
View 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
View 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。

View 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(机器契约)为准并在此修正。

View 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
}
}
}

View 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);
});
}

View 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
View 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[@]}"

View 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
View 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;

View 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';

View 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]);
});

View 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 边界声明(如某能力的进阶版留待实证后做)。

View 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';

View 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;
}

View 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 受控面用法,无任何玩法语义,可直接克隆为新插件骨架。"
}

View 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 不应影响帧计数');
});

View 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

View 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,
};