14 KiB
14 KiB
plugins/ — 可拔插客户端扩展插件架构
plugins/ 目录承载 Diablo II: Lord of Destruction (v1.13c) Web Port 的所有非 1.13c 原版增强插件。所有插件均位于核心 src/ 之外,仅通过公共宿主契约(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(Rules 1–10,零白名单例外)强制守卫:
- 组合根(Composition Root —
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')的字符串字面量。
- 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']。
- 仓库根目录唯一的 HTML 入口文件为
- 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')的导入或字符串字面量。
- 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. 目录结构
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/ 模块,导出以下核心类型与函数:
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-caseid唯一性、非空双语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)
- 创建插件目录与实现:在
plugins/<plugin-id>/index.ts中导出实现D2WebPlugin接口的插件对象(id必须与目录名<plugin-id>完全一致)。若需独立页面,创建plugins/<plugin-id>/index.html与plugins/<plugin-id>/page-main.ts并使用bootPluginPage(plugin)。 - 注册到
plugins/index.ts:在BUILTIN_PLUGINS数组中注册该插件(vite.config.ts会自动发现plugins/<plugin-id>/index.html)。 - 遵循 1.13c Ground Truth 与 Fail-Fast 原则:严禁添加宽松兜底默认值(如缺失角色职业/等级/难度标记时不得伪造默认值),所有依赖只允许指向
./*、src/common/**、src/netproto/index.ts与src/client/plugin-host/api.ts。 - 编写测试:在
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/ |
✅ 已迁移 | 本轮已落地 | 怪物击杀统计插件与独立统计页面(/plugins/kill-stats/index.html),按 (host, realm, account, charName, hardcore) 追踪普通/噩梦/地狱难度下的怪物、首领与超级精英击杀数。 |
plugins/automap-reveal |
📋 待迁移 | Issue #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) |
将分幕环境光照预设增强功能(Lighting Presets)从核心客户端解耦迁移至 plugins/lighting-presets。 |
plugins/extended-viewports |
📋 待迁移 | Issue #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) |
将实时网络封包检查器与录制回放面板(Packet Inspector)解耦迁移至 plugins/packet-inspector。 |