diablo2-web/plugins
troytt 72d413fa93 fix(plugins): remediate Iteration 2 plugin system docs, runtime edge-cases, E2E checks, and unit tests
- Align plugins/README.md, src/client/README.md, and README.md with actual plugin architecture, file inventories, and test paths
- Rethrow initial repository read failures in StatsPage.init() and expose ready: false on window.__d2StatsApp in bootStatsApp()
- Remove duplicate PLUGIN_CONFIG_STORAGE_KEY storage listener branch in src/client/plugin-host/api.ts
- Serialize cross-tab reload reconciliation onto record.queue and unconditionally clear record.error in resetAllToDefaults()
- Align localized strings in plugins/kill-stats/index.ts with plugin contract
- Extend tools/verify-plugin-menu-browser.ts with Steps 4-7 (corrupt JSON alert banner, raw storage preservation, and reset recovery)
- Add comprehensive unit tests in plugin-config-store.test.ts, plugin-menu.test.ts, plugin-host.test.ts, kill-stats-view.test.ts, and registry.test.ts

TAG=agy
CONV=2cf150d5-78df-48ac-be0c-42ef1694a004
2026-10-03 10:08:55 +00:00
..
kill-stats fix(plugins): remediate Iteration 2 plugin system docs, runtime edge-cases, E2E checks, and unit tests 2026-10-03 10:08:55 +00:00
README.md fix(plugins): remediate Iteration 2 plugin system docs, runtime edge-cases, E2E checks, and unit tests 2026-10-03 10:08:55 +00:00
index.ts feat(plugins): implement top-level plugin architecture, toolbar plugin modal menu, and kill-stats plugin migration 2026-10-03 08:13:18 +00:00

README.md

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,零白名单例外)强制守卫:

  1. 组合根(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')的字符串字面量。
  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. 目录结构

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-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/ ✅ 已迁移 本轮已落地 怪物击杀统计插件与独立统计页面(/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。