diablo2-web/plugins
troytt fdde06b9cb feat(plugins): migrate automap-reveal (#714), lighting-presets (#715), extended-viewports (#716) and remove packet-inspector (#717) 2026-10-03 12:46:14 +00:00
..
automap-reveal feat(plugins): migrate automap-reveal (#714), lighting-presets (#715), extended-viewports (#716) and remove packet-inspector (#717) 2026-10-03 12:46:14 +00:00
extended-viewports feat(plugins): migrate automap-reveal (#714), lighting-presets (#715), extended-viewports (#716) and remove packet-inspector (#717) 2026-10-03 12:46:14 +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
lighting-presets feat(plugins): migrate automap-reveal (#714), lighting-presets (#715), extended-viewports (#716) and remove packet-inspector (#717) 2026-10-03 12:46:14 +00:00
README.md feat(plugins): migrate automap-reveal (#714), lighting-presets (#715), extended-viewports (#716) and remove packet-inspector (#717) 2026-10-03 12:46:14 +00:00
index.ts feat(plugins): migrate automap-reveal (#714), lighting-presets (#715), extended-viewports (#716) and remove packet-inspector (#717) 2026-10-03 12:46:14 +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          # 统计页面中英双语词典
├── automap-reveal/            # 内置插件:小地图全图揭示 (id: 'automap-reveal', Issue #714)
│   └── index.ts               # 管理 automapReveal ('off' | 'level' | 'act') 与工具栏下拉快捷框 (#d2-toolbar-automap-select),禁用时强制回退 1.13c 'off'
├── lighting-presets/          # 内置插件:环境光照预设 (id: 'lighting-presets', Issue #715)
│   └── index.ts               # 管理 lightingPreset (7 档光照预设) 与工具栏下拉快捷框 (#d2-toolbar-lighting-select),禁用时强制回退 1.13c 'auto'
└── extended-viewports/        # 内置插件:扩展分辨率视口 (id: 'extended-viewports', Issue #716)
    └── index.ts               # 解锁 1024x768 / 1068x600 / 1280x720 扩展视口,禁用时仅开放 1.13c 原版 640x480 / 800x600 并自动收敛至 800x600

3. 插件接口契约(src/client/plugin-host/api.ts)

src/client/plugin-host/api.ts 是 plugins/** 唯一允许导入的 src/client/ 模块,导出以下核心类型与函数:

export type {
  AutomapRevealMode,
  GameLang,
  LightingPresetId,
  ViewportResolutionId,
}

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 PluginClientCapabilities {
  getAutomapReveal(): AutomapRevealMode
  setAutomapReveal(mode: AutomapRevealMode): void
  subscribeAutomapReveal(fn: (mode: AutomapRevealMode) => void): () => void
  getLightingPreset(): LightingPresetId
  setLightingPreset(preset: LightingPresetId): void
  subscribeLightingPreset(fn: (preset: LightingPresetId) => void): () => void
  getViewport(): ViewportResolutionId
  setViewport(viewport: ViewportResolutionId): void
  getAllowedViewports(): readonly ViewportResolutionId[]
  setAllowedViewports(viewports: readonly ViewportResolutionId[]): void
}

export interface PluginContext {
  readonly pluginId: string
  readonly session: PluginSessionView
  readonly client: PluginClientCapabilities
  readonly settings: {
    get(key: string): boolean | string
    set(key: string, value: boolean | string): void
    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
    addSelect(spec: {
      id: string
      domId?: string
      ariaLabel: LocalizedText
      options: readonly { value: string; label: LocalizedText }[]
      getValue(): string
      onChange(value: string): 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: 或 / 开头路径)。
  • 串行化热插拔与 1.13c 原版回退(setEnabled(id, enabled)):同一插件的启停切换按序串行收敛;deactivate 时等待 await instance.deactivate()(例如 kill-stats 会先执行 await tracker.flushNow() 落盘待写入击杀增量),自动清理该插件注册的全部工具栏控件(#d2-toolbar-plugin-slot)及订阅,并自动将失去插件声明支撑的客户端能力回退至 1.13c 原版状态(automapReveal -> 'off'、lightingPreset -> 'auto'、allowedViewports -> ['640x480', '800x600'])。
  • 插件级异常隔离: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 小地图全图揭示增强插件(off / level / act),可选在顶部工具栏显示 #d2-toolbar-automap-select 快捷下拉框;禁用时强制恢复 1.13c 原版战争迷雾(off)。
plugins/lighting-presets/ ✅ 已落地 Issue #715 环境光照预设增强插件(auto / noon / dusk / night / torch / cold / fullbright),可选在顶部工具栏显示 #d2-toolbar-lighting-select 快捷下拉框;禁用时强制恢复 1.13c 原版关卡环境光(auto)。
plugins/extended-viewports/ ✅ 已落地 Issue #716 扩展分辨率视口插件,启用时在工具栏分辨率下拉框中解锁 1024x768、1068x600、1280x720;禁用时仅保留 1.13c 原版 640x480 与 800x600,并将扩展分辨率自动收敛回 800x600。
移除封包检查器 (PacketInspector) ✅ 已移除 Issue #717 按精简要求彻底移除 src/client/inspector/packet-inspector.ts 与工具栏检查器按钮(保留 OnlineSession 的 .d2cap 离线录制重放能力)。