diablo2-web/plugins/README.md

197 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `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`。 |