197 lines
14 KiB
Markdown
197 lines
14 KiB
Markdown
# `plugins/` — 可拔插客户端扩展插件架构
|
||
|
||
`plugins/` 目录承载 **Diablo II: Lord of Destruction (v1.13c) Web Port** 的所有非 1.13c 原版增强插件。所有插件均位于核心 `src/` 之外,仅通过公共宿主契约([`src/client/plugin-host/api.ts`](../src/client/plugin-host/api.ts))访问只读会话视图(`PluginSessionView`)、配置存储与工具栏插槽,支持用户在顶部工具栏 **插件 / Plugins** 弹窗(`PluginMenu`,`[data-d2-modal-overlay="plugins"]`)中热插拔启停插件、调整插件设置项或打开独立插件页面。
|
||
|
||
---
|
||
|
||
## 1. 组合根与架构边界规则(`tests/arch/boundaries.test.ts`)
|
||
|
||
插件系统严格遵循核心零耦合原则,由 [`tests/arch/boundaries.test.ts`](../tests/arch/boundaries.test.ts)(Rules 1–10,零白名单例外)强制守卫:
|
||
|
||
1. **组合根(Composition Root — `play.html`)**:
|
||
- [`play.html`](../play.html) 是唯一的客户端组合根,通过内联 `<script type="module">` 同时导入 `/src/client/main.ts` 的 `startPlayApp` 与 `/plugins/index.ts` 的 `BUILTIN_PLUGINS`,并调用 `startPlayApp({ plugins: BUILTIN_PLUGINS })`。
|
||
- **`src/**` 对具体插件 100% 无感知**:`src/**`(含 `src/client/main.ts`)包含 **0 个** 指向 `plugins/` 的导入语句,且包含 **0 个** `'plugins/'` 或具体插件 ID(如 `'kill-stats'`)的字符串字面量。
|
||
2. **Rule 7 — 根 HTML 入口与内联组合根校验**:
|
||
- 仓库根目录唯一的 HTML 入口文件为 `play.html`(原根目录 `stats.html` 已迁移至 `plugins/kill-stats/index.html`,5 个历史遗留 HTML 文件保持删除)。
|
||
- `play.html` 声明本地 `D2Exocet`(`/ui/fonts/D2Exocet.ttf`)与 `D2Formal436`(`/ui/fonts/D2Formal436.ttf`)字体,且其内联 `<script type="module">` 仅导入 `['/src/client/main.ts', '/plugins/index.ts']`。
|
||
3. **Rule 9 — `plugins/` 依赖白名单、物理隔离与核心零耦合**:
|
||
- `plugins/index.ts` 仅允许导入 `./<id>/index.ts` 与 `src/client/plugin-host/api.ts`。
|
||
- `plugins/<id>/**` 仅允许导入:
|
||
- 同一插件目录内部模块 `plugins/<id>/**`
|
||
- `src/common/**`(只读 1.13c 数据表与通用解析器)
|
||
- `src/netproto/index.ts`(协议栈唯一公共门面,严禁导入 `src/netproto/` 内部子路径)
|
||
- `src/client/plugin-host/api.ts`(唯一允许导入的 `src/client/` 模块)
|
||
- 严禁跨插件导入(`plugins/<a>/**` 不得导入 `plugins/<b>/**`),严禁导入 `src/client/` 内部实现(如 `world/`、`session/`、`ui/`、`render/`、`i18n/`、`settings/`)或 `src/server/**`、`src/baker/**`。
|
||
- `plugins/**` 严禁包含任何 `node:*` 内置模块导入、严禁包含 `.test.ts` / `.spec.ts` 测试文件、严禁包含非字面量动态 `import(...)` 调用。
|
||
- `src/**` 严禁包含任何指向 `plugins/` 或具体插件 ID(如 `'kill-stats'`)的导入或字符串字面量。
|
||
4. **Rule 10 — 独立插件 HTML 页面规范(`plugins/<id>/index.html`)**:
|
||
- 每个 `plugins/<id>/index.html` 必须声明本地 `D2Exocet`(`/ui/fonts/D2Exocet.ttf`)与 `D2Formal436`(`/ui/fonts/D2Formal436.ttf`)`@font-face` 规则,且包含 **0 个** `http://` 或 `https://` 外部 URL。
|
||
- 页面中的 `<script type="module" src="...">` 必须且只能引用本插件目录下的模块路径 `/plugins/<id>/...`。
|
||
- `vite.config.ts` 会自动扫描所有 `plugins/<id>/index.html` 并注入 `build.rollupOptions.input['plugin-<id>']`。
|
||
|
||
---
|
||
|
||
## 2. 目录结构
|
||
|
||
```text
|
||
plugins/
|
||
├── README.md # 插件系统架构说明、API 契约与后续迁移路线图
|
||
├── index.ts # BUILTIN_PLUGINS 静态注册表(由组合根 play.html 导入并注入 startPlayApp)
|
||
└── kill-stats/ # 内置插件:怪物击杀统计 (id: 'kill-stats')
|
||
├── index.ts # killStatsPlugin 定义、激活/卸载生命周期、状态汇报与工具栏快捷入口管理
|
||
├── index.html # 独立统计页面入口 (/plugins/kill-stats/index.html)
|
||
├── page-main.ts # 独立页面启动引导(调用 bootPluginPage(killStatsPlugin) 并捕获启动异常)
|
||
├── stats-page.ts # 独立统计页面 DOM 控制器(IndexedDB 读取、BroadcastChannel 同步、导入/导出/删除)
|
||
├── kill-tracker.ts # 实时击杀追踪器(监听 D2GS ServerEvent、严格校验角色身份并批量落盘)
|
||
├── kill-stats-model.ts # G-counter CRDT 模型、键值规范化与严格导入校验 (KillStatsImportError)
|
||
├── kill-stats-store.ts # IndexedDB ('d2web-kill-stats' v1) 存储仓库实现
|
||
├── kill-stats-view.ts # 86 行首领/超级精英看板与怪物分类表纯视图模型构建器
|
||
├── monster-kill-classes.ts# 基于 MonStats.txt (Align / killable / neverCount) 的可计数怪物分类表
|
||
└── stats-i18n.ts # 统计页面中英双语词典
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 插件接口契约(`src/client/plugin-host/api.ts`)
|
||
|
||
`src/client/plugin-host/api.ts` 是 `plugins/**` 唯一允许导入的 `src/client/` 模块,导出以下核心类型与函数:
|
||
|
||
```ts
|
||
export type { GameLang }
|
||
|
||
export interface LocalizedText {
|
||
readonly zh: string
|
||
readonly en: string
|
||
}
|
||
|
||
export type PluginSettingSpec =
|
||
| {
|
||
readonly kind: 'boolean'
|
||
readonly key: string
|
||
readonly label: LocalizedText
|
||
readonly default: boolean
|
||
}
|
||
| {
|
||
readonly kind: 'select'
|
||
readonly key: string
|
||
readonly label: LocalizedText
|
||
readonly default: string
|
||
readonly options: readonly {
|
||
readonly value: string
|
||
readonly label: LocalizedText
|
||
}[]
|
||
}
|
||
|
||
export type PluginActionSpec =
|
||
| {
|
||
readonly kind: 'link'
|
||
readonly id: string
|
||
readonly label: LocalizedText
|
||
readonly href: string // 仅限应用内相对路径(如 'plugins/kill-stats/index.html')
|
||
}
|
||
| {
|
||
readonly kind: 'button'
|
||
readonly id: string
|
||
readonly label: LocalizedText
|
||
run(ctx: PluginContext): void
|
||
}
|
||
|
||
export interface D2WebPlugin {
|
||
readonly id: string // kebab-case,全局唯一,必须与 plugins/<id>/ 目录名一致
|
||
readonly name: LocalizedText
|
||
readonly description: LocalizedText
|
||
readonly defaultEnabled: boolean
|
||
readonly settings?: readonly PluginSettingSpec[]
|
||
readonly actions?: readonly PluginActionSpec[]
|
||
activate(ctx: PluginContext): PluginInstance | Promise<PluginInstance>
|
||
}
|
||
|
||
export interface PluginInstance {
|
||
deactivate(): void | Promise<void>
|
||
getStatus?(): PluginStatus
|
||
}
|
||
|
||
export interface PluginStatus {
|
||
readonly level: 'ok' | 'warn' | 'error'
|
||
readonly text: LocalizedText
|
||
}
|
||
|
||
export type PluginSessionView = Pick<
|
||
OnlineSession,
|
||
'phase' | 'world' | 'loginHost' | 'getSnapshot' | 'onServerEvent' | 'onStateChange'
|
||
>
|
||
|
||
export interface PluginContext {
|
||
readonly pluginId: string
|
||
readonly session: PluginSessionView
|
||
readonly settings: {
|
||
get(key: string): boolean | string
|
||
subscribe(fn: (key: string) => void): () => void
|
||
}
|
||
readonly toolbar: {
|
||
addLink(spec: { id: string; label: LocalizedText; href: string }): () => void
|
||
addButton(spec: { id: string; label: LocalizedText; onClick(): void }): () => void
|
||
}
|
||
readonly lang: {
|
||
get(): GameLang
|
||
subscribe(fn: (lang: GameLang) => void): () => void
|
||
}
|
||
readonly log: PluginLogger
|
||
notifyStatusChanged(): void
|
||
}
|
||
|
||
export interface PluginPageContext {
|
||
readonly pluginId: string
|
||
getLang(): GameLang
|
||
setLang(lang: GameLang): void
|
||
subscribeLang(fn: (lang: GameLang) => void): () => void
|
||
isEnabled(): boolean
|
||
subscribeEnabled(fn: (enabled: boolean) => void): () => void
|
||
getSetting(key: string): boolean | string
|
||
setSetting(key: string, value: boolean | string): void
|
||
subscribeSettings(fn: (key: string) => void): () => void
|
||
destroy(): void
|
||
}
|
||
|
||
export function validatePluginDefinition(plugin: D2WebPlugin): void
|
||
export function validatePluginRegistry(plugins: readonly D2WebPlugin[]): void
|
||
export function bootPluginPage(plugin: D2WebPlugin): PluginPageContext
|
||
```
|
||
|
||
### 宿主生命周期与故障隔离保证(`PluginHost`)
|
||
- **启动期快速失败校验(`validatePluginRegistry`)**:校验每个插件的 kebab-case `id` 唯一性、非空双语 `name`/`description`、唯一设置项 `key`、`select` 默认值合法性以及相对路径 `link.href`(拒绝 `http://`、`https://`、`//`、`javascript:` 或 `/` 开头路径)。
|
||
- **串行化热插拔(`setEnabled(id, enabled)`)**:同一插件的启停切换按序串行收敛;`deactivate` 时等待 `await instance.deactivate()`(例如 `kill-stats` 会先执行 `await tracker.flushNow()` 落盘待写入击杀增量),随后宿主自动清理该插件注册的全部工具栏按钮/链接(`#d2-toolbar-plugin-slot`)及设置/语言订阅。
|
||
- **插件级异常隔离**:`activate`、`deactivate`、动作回调或订阅回调抛错/reject 时,宿主通过 `console.error` 记录堆栈、将该插件状态标记为 `level: 'error'` 并在工具栏 `#d2-toolbar-plugins-btn` 显示 `⚠`,绝不中断主游戏循环或其他插件。
|
||
- **独立页面引导(`bootPluginPage(plugin)`)**:供 `plugins/<id>/page-main.ts` 调用,无需直接导入 `src/client/i18n/lang.ts` 或 `src/client/settings/settings-store.ts`,自动解析语言(URL `?lang=` > `localStorage['d2web.settings.v1']` > `<html lang>`)并通过 `storage` 事件实时同步语言与 `localStorage['d2web.plugins.v1']` 中的插件启停/设置状态。
|
||
|
||
---
|
||
|
||
## 4. 存储键值与跨标签页通信规范
|
||
|
||
| 存储介质 / 通道 | 键名 / 标识 | 所属模块 | 用途与容错规范 |
|
||
| :--- | :--- | :--- | :--- |
|
||
| `localStorage` | `'d2web.plugins.v1'` | `PluginConfigStore` (`src/client/plugin-host/plugin-config-store.ts`) | 持久化各插件 `{ version: 1, plugins: { [id]: { enabled, settings } } }`。检测到损坏 JSON 或非法字段时:`console.error` 报警、在插件菜单展示 `#d2-plugin-menu-alert`(`role="alert"`)并在 `#d2-toolbar-plugins-btn` 显示 `⚠`,内存回退至插件声明默认值,但**绝不静默覆盖原始损坏的 `localStorage` 条目**,直至用户显式修改配置或点击 `#d2-plugin-menu-reset`(全部恢复默认)。未识别的插件 ID 原样保留。 |
|
||
| `IndexedDB` | `'d2web-kill-stats'` (v1) | `IndexedDbKillStatsRepository` (`plugins/kill-stats/kill-stats-store.ts`) | 持久化怪物击杀统计数据,包含 `meta`(`replicaId` UUIDv4)、`characters`(按 `charKey` 索引的角色档案)与 `tallies`(按 `[replicaId, charKey, kind, difficulty, subject]` 主键存储的 G-counter 分片计数)三个 Object Store。导入/导出 JSON 格式标识为 `format: 'd2web-kill-stats'`, `version: 1`(注:不再使用任何类似 `'d2web.killStats.v1'` 的 `localStorage` 键)。 |
|
||
| `BroadcastChannel` | `'d2web-kill-stats'` | `KillTracker` & `KillStatsPage` (`plugins/kill-stats/`) | 每次 `KillTracker` 批量落盘或 `KillStatsPage` 导入/删除/清空成功后广播 `{ type: 'updated', at }`,触发已打开的 `/plugins/kill-stats/index.html` 标签页自动防抖刷新。 |
|
||
|
||
---
|
||
|
||
## 5. 如何新增插件(Authoring Checklist)
|
||
|
||
1. **创建插件目录与实现**:在 `plugins/<plugin-id>/index.ts` 中导出实现 `D2WebPlugin` 接口的插件对象(`id` 必须与目录名 `<plugin-id>` 完全一致)。若需独立页面,创建 `plugins/<plugin-id>/index.html` 与 `plugins/<plugin-id>/page-main.ts` 并使用 `bootPluginPage(plugin)`。
|
||
2. **注册到 `plugins/index.ts`**:在 `BUILTIN_PLUGINS` 数组中注册该插件(`vite.config.ts` 会自动发现 `plugins/<plugin-id>/index.html`)。
|
||
3. **遵循 1.13c Ground Truth 与 Fail-Fast 原则**:严禁添加宽松兜底默认值(如缺失角色职业/等级/难度标记时不得伪造默认值),所有依赖只允许指向 `./*`、`src/common/**`、`src/netproto/index.ts` 与 `src/client/plugin-host/api.ts`。
|
||
4. **编写测试**:在 `tests/plugins/<plugin-id>/` 下添加单元测试,并确保 `tests/plugins/registry.test.ts` 与 `tests/arch/boundaries.test.ts`(Rules 1–10)全部通过。
|
||
|
||
---
|
||
|
||
## 6. 已迁移插件与后续插件迁移 Gitea Issues(#714–#717)
|
||
|
||
| 插件目录 | 状态 | 追踪链接 | 说明 |
|
||
| :--- | :---: | :--- | :--- |
|
||
| [`plugins/kill-stats/`](./kill-stats) | ✅ 已迁移 | 本轮已落地 | 怪物击杀统计插件与独立统计页面(`/plugins/kill-stats/index.html`),按 `(host, realm, account, charName, hardcore)` 追踪普通/噩梦/地狱难度下的怪物、首领与超级精英击杀数。 |
|
||
| `plugins/automap-reveal` | 📋 待迁移 | [Issue #714](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/714) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/714`) | 将小地图全图揭示增强功能(Automap Reveal)从核心客户端解耦迁移至 `plugins/automap-reveal`。 |
|
||
| `plugins/lighting-presets` | 📋 待迁移 | [Issue #715](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/715) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/715`) | 将分幕环境光照预设增强功能(Lighting Presets)从核心客户端解耦迁移至 `plugins/lighting-presets`。 |
|
||
| `plugins/extended-viewports` | 📋 待迁移 | [Issue #716](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/716) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/716`) | 将非原版扩展分辨率视口预设(Extended Viewports,如宽屏/高分辨率档位)解耦迁移至 `plugins/extended-viewports`。 |
|
||
| `plugins/packet-inspector` | 📋 待迁移 | [Issue #717](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/717) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/717`) | 将实时网络封包检查器与录制回放面板(Packet Inspector)解耦迁移至 `plugins/packet-inspector`。 |
|