feat(plugins): implement top-level plugin architecture, toolbar plugin modal menu, and kill-stats plugin migration

This commit is contained in:
troytt 2026-10-03 08:13:18 +00:00
parent db1b69f440
commit ff76655d4d
47 changed files with 4888 additions and 289 deletions

View File

@ -7,7 +7,7 @@
- **1:1 原生网络协议兼容**:完整实现 BNCS(战网登录/聊天,端口 `6112`)、MCP(Realm 角色管理/建房加房,端口 `6113`)与 D2GS(Huffman 压缩帧、167 个 S2C 操作码、C2S 指令及物品比特流,端口 `4000`),浏览器客户端可通过轻量 TCP-to-WebSocket 桥直接连入原版 C++ **PvPGN + D2GS (1.13c)** 战网服务器。 - **1:1 原生网络协议兼容**:完整实现 BNCS(战网登录/聊天,端口 `6112`)、MCP(Realm 角色管理/建房加房,端口 `6113`)与 D2GS(Huffman 压缩帧、167 个 S2C 操作码、C2S 指令及物品比特流,端口 `4000`),浏览器客户端可通过轻量 TCP-to-WebSocket 桥直接连入原版 C++ **PvPGN + D2GS (1.13c)** 战网服务器。
- **种子驱动的确定性 DRLG 地图重建**:1:1 复刻 `D2Common.dll` 64 位进位乘法随机数 (`0x6FDAA9E0`) 与三大 DRLG 拓扑算法(Preset 预设、Maze 地牢迷宫、Outdoors 野外生成),客户端仅凭 D2GS 下发的 32 位 `mapSeed` 即可在浏览器端还原与 C++ 服务端逐格一致的瓦片地图与碰撞网格。 - **种子驱动的确定性 DRLG 地图重建**:1:1 复刻 `D2Common.dll` 64 位进位乘法随机数 (`0x6FDAA9E0`) 与三大 DRLG 拓扑算法(Preset 预设、Maze 地牢迷宫、Outdoors 野外生成),客户端仅凭 D2GS 下发的 32 位 `mapSeed` 即可在浏览器端还原与 C++ 服务端逐格一致的瓦片地图与碰撞网格。
- **WebGL2 高性能等距渲染与离线资产管线**:通过 Node.js 离线烘焙管线 (`src/baker`) 将原版 MPQ 中的 DT1/DS1 瓦片、DC6/DCC 角色与怪物动画、COF 复合图层、PL2 调色板与 TBL 字符串表预编译为静态图集与二进制包,配合 Service Worker 增量缓存与 WebGL2 合批渲染实现 60fps 流畅体验。 - **WebGL2 高性能等距渲染与离线资产管线**:通过 Node.js 离线烘焙管线 (`src/baker`) 将原版 MPQ 中的 DT1/DS1 瓦片、DC6/DCC 角色与怪物动画、COF 复合图层、PL2 调色板与 TBL 字符串表预编译为静态图集与二进制包,配合 Service Worker 增量缓存与 WebGL2 合批渲染实现 60fps 流畅体验。
- **严格的模块化架构隔离**:全仓按原版 DLL 职责划分为五大核心代码模块与独立工具集,由多套独立 `tsconfig.*.json` 与架构边界测试(`tests/arch/boundaries.test.ts`)强制保障零跨层污染。 - **严格的模块化架构隔离**:全仓按原版 DLL 职责划分为五大核心代码模块、顶层可拔插扩展目录(`plugins/`)与独立工具集,由多套独立 `tsconfig.*.json` 与架构边界测试(`tests/arch/boundaries.test.ts`)强制保障零跨层污染。
--- ---
@ -19,7 +19,8 @@
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| [`src/common`](./src/common) | `D2Common.dll` `Storm.dll` `D2Lang.dll` | 纯 TypeScript (`ES2022`),零 DOM / 零 Node.js 依赖 | MPQ 与原生二进制格式解析器 (DC6/DCC/DT1/DS1/COF/PL2/TBL)、`D2DataRegistry` 数据表中心、1.13c DRLG 随机地图生成算法、64 位 `D2Rng` 与烘焙校验契约 (`pack-contract`) | [src/common/README.md](./src/common/README.md) | | [`src/common`](./src/common) | `D2Common.dll` `Storm.dll` `D2Lang.dll` | 纯 TypeScript (`ES2022`),零 DOM / 零 Node.js 依赖 | MPQ 与原生二进制格式解析器 (DC6/DCC/DT1/DS1/COF/PL2/TBL)、`D2DataRegistry` 数据表中心、1.13c DRLG 随机地图生成算法、64 位 `D2Rng` 与烘焙校验契约 (`pack-contract`) | [src/common/README.md](./src/common/README.md) |
| [`src/netproto`](./src/netproto) | `D2Net.dll` `Bnclient.dll` `D2MCPClient.dll` `Fog.dll` | 纯协议栈模块,零外部模块依赖,唯一入口 `src/netproto/index.ts` | BNCS (`6112`) / MCP (`6113`) / D2GS (`4000`) 二进制协议编解码、XSHA-1 与 CheckRevision 认证、Fog.dll Huffman 解压、167 个 S2C 操作码解析、物品比特流与 WebSocket/TCP 传输层抽象 | [src/netproto/README.md](./src/netproto/README.md) | | [`src/netproto`](./src/netproto) | `D2Net.dll` `Bnclient.dll` `D2MCPClient.dll` `Fog.dll` | 纯协议栈模块,零外部模块依赖,唯一入口 `src/netproto/index.ts` | BNCS (`6112`) / MCP (`6113`) / D2GS (`4000`) 二进制协议编解码、XSHA-1 与 CheckRevision 认证、Fog.dll Huffman 解压、167 个 S2C 操作码解析、物品比特流与 WebSocket/TCP 传输层抽象 | [src/netproto/README.md](./src/netproto/README.md) |
| [`src/client`](./src/client) | `D2Client.dll` `D2Gfx.dll` `D2Win.dll` `D2Launch.dll` | 纯浏览器客户端 (`DOM` + `WebGL2`),零 Node / 零 `server` / 零 `baker` 依赖 | 双页面入口 `play.html`(游戏主入口)与 `stats.html`(独立怪物击杀统计页)、战网登录/选人/大厅前端界面、`OnlineSession` 与 `ClientWorld` 状态镜像、实时怪物击杀追踪与 IndexedDB G-counter 统计 (`stats/`)、种子驱动的实时 DRLG 地图重建、移动预测与内插、WebGL2 渲染器、全套经典 HUD 面板与抓包检查器 | [src/client/README.md](./src/client/README.md) | | [`src/client`](./src/client) | `D2Client.dll` `D2Gfx.dll` `D2Win.dll` `D2Launch.dll` | 纯浏览器客户端 (`DOM` + `WebGL2`),零 Node / 零 `server` / 零 `baker` / 零 `plugins` 依赖 | 游戏主入口 `play.html`、战网登录/选人/大厅前端界面、`OnlineSession` 与 `ClientWorld` 状态镜像、非侵入式插件宿主 (`plugin-host/`)、种子驱动的实时 DRLG 地图重建、移动预测与内插、WebGL2 渲染器、全套经典 HUD 面板与抓包检查器 | [src/client/README.md](./src/client/README.md) |
| [`plugins/`](./plugins) | 客户端外挂扩展生态 | 浏览器扩展层 (`DOM`),仅依赖 `src/common`、`src/netproto` 与 `src/client/plugin-host/api.ts`,插件间严格物理隔离 | 独立于核心客户端的可拔插辅助扩展(如 `plugins/kill-stats/` 实时怪物击杀统计与独立页面 `plugins/kill-stats/index.html`),支持通过顶部工具栏 **Plugins** 菜单动态启停与快捷按钮配置 | [plugins/README.md](./plugins/README.md) |
| [`src/server`](./src/server) | `D2Game.dll` | 纯确定性模拟 (`ES2022` + `WebWorker`),禁 DOM / 禁 Node / 禁非确定性随机与时钟 | 25fps (40ms/tick) 权威游戏循环、1.13c 战斗伤害与抗性管线、7 职业 221 个技能执行器、投射物与光环引擎、怪物 AI 与房间流式激活、TC 掉落管线与 `.d2s` 存档读写 | [src/server/README.md](./src/server/README.md) | | [`src/server`](./src/server) | `D2Game.dll` | 纯确定性模拟 (`ES2022` + `WebWorker`),禁 DOM / 禁 Node / 禁非确定性随机与时钟 | 25fps (40ms/tick) 权威游戏循环、1.13c 战斗伤害与抗性管线、7 职业 221 个技能执行器、投射物与光环引擎、怪物 AI 与房间流式激活、TC 掉落管线与 `.d2s` 存档读写 | [src/server/README.md](./src/server/README.md) |
| [`src/baker`](./src/baker) | 离线资源构建管线 | 纯 Node.js CLI 环境 (`types: ["node"]`),禁 DOM / 禁 `client` / 禁 `server` | 从原版 1.13c MPQ 归档中提取并烘焙瓦片图集 (`tiles`)、角色/怪物/NPC 实体 (`entities`)、投射物 (`missiles`)、覆盖层 (`overlays`)、HUD/前端 UI、动画表 (`animdata`) 与 SHA-256 资产清单 (`asset-manifest.json`) | [src/baker/README.md](./src/baker/README.md) | | [`src/baker`](./src/baker) | 离线资源构建管线 | 纯 Node.js CLI 环境 (`types: ["node"]`),禁 DOM / 禁 `client` / 禁 `server` | 从原版 1.13c MPQ 归档中提取并烘焙瓦片图集 (`tiles`)、角色/怪物/NPC 实体 (`entities`)、投射物 (`missiles`)、覆盖层 (`overlays`)、HUD/前端 UI、动画表 (`animdata`) 与 SHA-256 资产清单 (`asset-manifest.json`) | [src/baker/README.md](./src/baker/README.md) |
| [`tools/`](./tools) | 开发者与 QA 工具集 | Node.js / Playwright CLI | 无头协议测试机器人 (`d2-bot.ts`)、`.d2cap` 抓包离线重放器 (`netproto-replay.ts`)、`.d2s` 存档全解锁工具 (`d2s-unlock.ts`)、全谱系 136 关卡浏览器审计 (`audit-levels-browser.ts`) 与 DRLG 差分对比工具 | [tools/README.md](./tools/README.md) | | [`tools/`](./tools) | 开发者与 QA 工具集 | Node.js / Playwright CLI | 无头协议测试机器人 (`d2-bot.ts`)、`.d2cap` 抓包离线重放器 (`netproto-replay.ts`)、`.d2s` 存档全解锁工具 (`d2s-unlock.ts`)、全谱系 136 关卡浏览器审计 (`audit-levels-browser.ts`) 与 DRLG 差分对比工具 | [tools/README.md](./tools/README.md) |
@ -37,8 +38,14 @@
┌─────────────┐ ┌──────────────┐ ┌─────────────────────────┐ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────────┐
│ src/baker │ │ src/server │◄──┤ src/client │ │ src/baker │ │ src/server │◄──┤ src/client │
│ (Node CLI) │ │ (D2Game Sim) │ │ (Browser WebGL2 Client) │ │ (Node CLI) │ │ (D2Game Sim) │ │ (Browser WebGL2 Client) │
└─────────────┘ └──────────────┘ └─────────────────────────┘ └─────────────┘ └──────────────┘ └────────────┬────────────┘
*(注:src/client 与 src/server 之间完全解耦,仅各自依赖 src/common 与 src/netproto)* ▲
│ (plugin-host/api.ts only)
┌────────────┴────────────┐
│ plugins/ │
│ (Isolated Extensions) │
└─────────────────────────┘
*(注:src/client 与 src/server 之间完全解耦;plugins/* 仅依赖 plugin-host/api.ts、src/common 与 src/netproto)*
``` ```
--- ---

View File

@ -158,12 +158,22 @@
cursor: pointer; cursor: pointer;
} }
.d2-toolbar__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.d2-toolbar__btn--active { .d2-toolbar__btn--active {
background: linear-gradient(180deg, #524020 0%, #2e2310 100%); background: linear-gradient(180deg, #524020 0%, #2e2310 100%);
border-color: #d4b46a; border-color: #d4b46a;
color: #fff5d6; color: #fff5d6;
} }
.d2-toolbar__btn--warn {
border-color: #d4882a;
color: #ffb84d;
}
.d2-toolbar__diag { .d2-toolbar__diag {
position: absolute; position: absolute;
top: 32px; top: 32px;
@ -446,6 +456,10 @@
</div> </div>
<div id="d2-inspector-container"></div> <div id="d2-inspector-container"></div>
</div> </div>
<script type="module" src="/src/client/main.ts"></script> <script type="module">
import { startPlayApp } from '/src/client/main.ts'
import { BUILTIN_PLUGINS } from '/plugins/index.ts'
void startPlayApp({ plugins: BUILTIN_PLUGINS })
</script>
</body> </body>
</html> </html>

99
plugins/README.md Normal file
View File

@ -0,0 +1,99 @@
# `plugins/` — 可拔插客户端扩展插件架构
`plugins/` 目录承载 **Diablo II: Lord of Destruction (v1.13c) Web Port** 的所有非侵入式客户端辅助扩展插件。所有插件均与核心客户端(`src/client`)严格解耦,仅通过只读插件宿主契约([`src/client/plugin-host/api.ts`](../src/client/plugin-host/api.ts))订阅 `ServerEvent` 与会话状态快照,支持用户在顶部工具栏 **🧩 Plugins(插件)** 弹窗中动态启用/禁用插件、控制工具栏快捷入口显隐,或打开插件独立配套页面。
---
## 1. 架构边界与工程约束
架构边界测试([`tests/arch/boundaries.test.ts`](../tests/arch/boundaries.test.ts))强制执行以下四条核心隔离规则:
1. **Rule 7 — 核心客户端零插件耦合**:
- 除客户端组合根 [`src/client/main.ts`](../src/client/main.ts) 导入静态插件注册表 [`plugins/index.ts`](./index.ts) 外,**`src/client/**` 下的任何文件严禁导入 `plugins/**`**。
2. **Rule 8 — 服务端与烘焙管线零插件耦合**:
- `src/common/**`、`src/netproto/**`、`src/server/**` 与 `src/baker/**` 严禁导入 `plugins/**`。
3. **Rule 9 — 插件仅限访问公共只读宿主契约**:
- `plugins/**` 仅允许导入:
- `src/common/**`(只读数据表 `D2DataRegistry`、二进制解析器等)
- `src/netproto/**`(只读协议事件与常量定义)
- `src/client/plugin-host/api.ts`(唯一允许导入的 `src/client` 模块)
- 同一插件目录 `plugins/<self>/**` 内部模块
- **严禁直接导入 `src/client/world/**`、`src/client/session/**`、`src/client/ui/**`、`src/client/render/**` 等客户端内部实现**。
4. **Rule 10 — 插件间严格物理隔离**:
- 每个插件必须自包含在 `plugins/<plugin-id>/` 子目录中,**严禁跨插件导入(`plugins/<a>/**` 不得导入 `plugins/<b>/**`)**。
- 所有插件均运行在纯浏览器环境(`tsconfig.client.json`),严禁使用任何 Node.js 内置模块。
---
## 2. 目录结构
```text
plugins/
├── README.md # 插件系统架构说明与开发指南
├── index.ts # BUILTIN_PLUGINS 静态注册表(唯一向 src/client/main.ts 暴露的入口)
└── kill-stats/ # [Built-in #714] 怪物击杀统计插件 (Kill Statistics)
├── index.ts # killStatsPlugin 描述符、生命周期挂载与工具栏动作
├── index.html # 独立统计看板页面入口 (/plugins/kill-stats/index.html)
├── page-main.ts # 独立页面启动引导 (基于 bootPluginPage 统一处理语言与启停状态)
├── stats-page.ts # 独立统计页控制器 (IndexedDB 实时监听、BroadcastChannel 同步、导入/导出)
├── kill-tracker.ts # 实时击杀追踪器 (监听 unitDeath S2C 事件并批量落盘 IndexedDB)
├── kill-stats-model.ts # G-counter CRDT 统计模型与 JSON 序列化/合并校验
├── kill-stats-store.ts # IndexedDB (d2_kill_stats_v1) 存储实现
├── kill-stats-view.ts # 独立统计页双语 DOM 渲染器
├── monster-kill-classes.ts# 基于 MonStats.txt (hcIdx / killable / align) 的可击杀怪物分类器
└── stats-i18n.ts # 插件专属中英双语词典
```
---
## 3. 插件接口契约(`src/client/plugin-host/api.ts`)
每个插件通过实现 `D2WebPlugin<TInstance>` 接口并注册到 `plugins/index.ts` 的 `BUILTIN_PLUGINS` 数组中接入宿主:
```ts
export interface D2WebPlugin<TInstance = unknown> {
readonly descriptor: PluginDescriptor;
create(ctx: PluginHostContext): PluginLifecycle<TInstance>;
}
```
- **`PluginDescriptor`**:
- `id`: 全局唯一 kebab-case 插件标识(如 `'kill-stats'`),必须与目录名 `plugins/<id>/` 一致。
- `version`: 语义化版本号(如 `'1.0.0'`)。
- `name` / `description`: `{ zh: string; en: string }` 双语名称与简介(在工具栏 **Plugins** 模态框中展示)。
- `defaultEnabled`: 默认是否启用。
- `capabilities`: 声明插件能力标签(`'event-subscriber' | 'standalone-page' | 'toolbar-shortcut' | 'storage-indexeddb'`)。
- `standalonePagePath?`: 若包含独立页面,声明相对路径 `'plugins/<id>/index.html'`(Vite 构建会自动扫描 `plugins/*/index.html` 并打包为多页入口 `plugin-<id>`)。
- `toolbarAction?`: 工具栏快捷按钮与插件配置弹窗中的“打开页面”动作元数据。
- **`PluginHostContext`**:
- `getLang()` / `setLang(lang)`: 读取或切换全局双语设置(`'zh' | 'en'`)。
- `getDataRegistry()`: 获取已加载的 `D2DataRegistry`(若尚未加载完成则返回 `null`)。
- `getSessionView()`: 获取只读会话视图 `PluginSessionView`(当前战网网关、账号名、角色名、角色职业/等级与存活怪物快照 `getMonsterSnapshot(unitId)`)。
- `onServerEvent(listener)`: 订阅 D2GS `ServerEvent` 流。宿主会自动为每个事件调用包裹 `try/catch` 故障隔离:单个插件抛错不会中断游戏主循环或其他插件。
- **`bootPluginPage(options)`**:
- 独立插件页面(`plugins/<id>/page-main.ts`)专用的标准引导辅助函数。自动读取 `PluginConfigStore` 的启用状态与全局语言,并监听跨标签页 `window.addEventListener('storage', ...)` 事件,实时触发 `onEnabledChange` 与 `onLangChange`。
---
## 4. 如何开发与接入新插件
1. **创建插件目录**:在 `plugins/<plugin-id>/` 下创建 `index.ts`(若需要独立 HTML 页面,创建 `plugins/<plugin-id>/index.html` 与 `page-main.ts`)。
2. **遵循只读与快速失败原则(Fail-Fast)**:
- 严禁伪造或硬编码游戏规则数据;凡涉及怪物、物品、技能或关卡元数据,必须从 `ctx.getDataRegistry()` 的 1.13c 权威数据表中读取,缺失必需参数时立即抛出明确错误。
3. **注册到 `plugins/index.ts`**:
- 将插件实例追加到 `BUILTIN_PLUGINS` 只读数组中。
- `vite.config.ts` 会自动扫描 `plugins/*/index.html`,无需手工修改 Rollup `input` 列表。
4. **编写单元测试与边界校验**:
- 在 `tests/plugins/<plugin-id>/` 下编写单元测试。
- 运行 `npm run typecheck` 与 `npx vitest run` 验证类型安全及 `tests/arch/boundaries.test.ts` Rules 1–10 零违规。
---
## 5. 内置与规划插件路线图(Gitea Issues)
| 插件目录 / 标识 | 状态 | Gitea Issue | 功能简述 |
| :--- | :---: | :---: | :--- |
| [`plugins/kill-stats/`](./kill-stats) | ✅ 已内置 | `#714` | **怪物击杀统计 (Monster Kill Statistics)**:按战网网关/账号/角色隔离追踪全部可击杀怪物(普通/精英/暗金/首领),基于 IndexedDB G-counter CRDT 支持多标签页实时同步与 JSON 导入合并。 |
| `plugins/loot-filter/` | 📋 规划中 | `#715` | **掉落物品过滤与高价值掉落提醒 (Loot Filter & Drop Notifier)**:订阅地面物品掉落事件,支持自定义过滤规则、底材孔数/无形高亮与高号符文/暗金掉落提示音。 |
| `plugins/runeword-calc/` | 📋 规划中 | `#716` | **符文之语与赫拉迪克方块配方查询器 (Runeword & Cube Recipe Calculator)**:基于 `Runes.txt` 与 `CubeMain.txt` 结合当前角色背包/储物箱符文快照,实时计算可合成的符文之语与升级打孔配方。 |
| `plugins/area-guide/` | 📋 规划中 | `#717` | **85 级场景 (TC85) 与怪物抗性/免疫速查面板 (Area Level & Monster Immunity Guide)**:根据当前角色所在关卡 `areaId` 与难度,实时展示来自 `Levels.txt` / `MonStats.txt` 的场景等级、四系免疫分布与 Boss 掉落参考。 |

8
plugins/index.ts Normal file
View File

@ -0,0 +1,8 @@
/**
* Built-in plugins registry (`plugins/index.ts`).
*/
import type { D2WebPlugin } from '../src/client/plugin-host/api.ts'
import { killStatsPlugin } from './kill-stats/index.ts'
export const BUILTIN_PLUGINS: readonly D2WebPlugin[] = [killStatsPlugin]

View File

@ -218,6 +218,16 @@
border-color: var(--d2-gold); border-color: var(--d2-gold);
color: var(--d2-gold-bright); color: var(--d2-gold-bright);
} }
.d2-stats__banner--warn {
display: block;
border-color: rgba(215, 168, 78, 0.58);
background: rgba(58, 41, 14, 0.85);
color: #f0d699;
margin-bottom: 10px;
}
.d2-stats__banner--warn[hidden] {
display: none !important;
}
.d2-stats__summary-grid { .d2-stats__summary-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(130px, 1fr)); grid-template-columns: repeat(4, minmax(130px, 1fr));
@ -428,6 +438,6 @@
</head> </head>
<body> <body>
<div id="d2-stats-app"></div> <div id="d2-stats-app"></div>
<script type="module" src="/src/client/stats/stats-main.ts"></script> <script type="module" src="/plugins/kill-stats/page-main.ts"></script>
</body> </body>
</html> </html>

169
plugins/kill-stats/index.ts Normal file
View File

@ -0,0 +1,169 @@
/**
* Kill Statistics Plugin (`plugins/kill-stats/index.ts`).
*/
import type {
D2WebPlugin,
PluginContext,
PluginInstance,
PluginStatus,
} from '../../src/client/plugin-host/api.ts'
import {
IndexedDbKillStatsRepository,
type KillStatsRepository,
} from './kill-stats-store.ts'
import { KillTracker, type KillTrackerOptions } from './kill-tracker.ts'
export interface KillStatsPluginInstance extends PluginInstance {
readonly tracker: KillTracker
readonly repository: KillStatsRepository
flushNow(): Promise<void>
}
export interface CreateKillStatsPluginOptions {
readonly repository?: KillStatsRepository | undefined
readonly trackerOptions?: Omit<KillTrackerOptions, 'session' | 'repository'> | undefined
}
export function createKillStatsPlugin(
options: CreateKillStatsPluginOptions = {},
): D2WebPlugin {
return {
id: 'kill-stats',
name: {
zh: '怪物击杀统计',
en: 'Kill Statistics',
},
description: {
zh: '记录本浏览器内各账号与角色在普通/噩梦/地狱难度下的怪物、首领与超级精英击杀数。',
en: 'Tracks per-account and per-character monster, boss, and SuperUnique kill counts across Normal, Nightmare, and Hell.',
},
defaultEnabled: true,
settings: [
{
kind: 'boolean',
key: 'showToolbarShortcut',
label: {
zh: '在顶部工具栏显示「击杀统计」快捷入口',
en: 'Show "Kill Stats" shortcut in top toolbar',
},
default: true,
},
],
actions: [
{
kind: 'link',
id: 'open-page',
label: {
zh: '打开统计页面',
en: 'Open Statistics Page',
},
href: 'plugins/kill-stats/index.html',
},
],
activate(ctx: PluginContext): KillStatsPluginInstance {
const repository =
options.repository ?? new IndexedDbKillStatsRepository()
const tracker = new KillTracker({
session: ctx.session,
repository,
log: ctx.log,
...options.trackerOptions,
onStatusChange: () => {
options.trackerOptions?.onStatusChange?.()
ctx.notifyStatusChanged()
},
})
let removeToolbarLink: (() => void) | null = null
const syncToolbarShortcut = (): void => {
const show = ctx.settings.get('showToolbarShortcut') === true
if (show && !removeToolbarLink) {
removeToolbarLink = ctx.toolbar.addLink({
id: 'open',
label: {
zh: '击杀统计',
en: 'Kill Stats',
},
href: 'plugins/kill-stats/index.html',
})
} else if (!show && removeToolbarLink) {
removeToolbarLink()
removeToolbarLink = null
}
}
syncToolbarShortcut()
const unsubSettings = ctx.settings.subscribe((key) => {
if (key === 'showToolbarShortcut') {
syncToolbarShortcut()
}
})
const getStatus = (): PluginStatus => {
const st = tracker.getStatus()
if (st.lastError) {
return {
level: 'error',
text: {
zh: `错误:${st.lastError}`,
en: `Error: ${st.lastError}`,
},
}
}
if (ctx.session.phase === 'ingame' && st.difficulty === null) {
return {
level: 'warn',
text: {
zh: '等待下一局(本局未收到难度标记)',
en: 'Waiting for next game (difficulty flags not received in current game)',
},
}
}
if (st.lastSkipReason) {
return {
level: 'warn',
text: {
zh: `等待数据:${st.lastSkipReason}`,
en: `Waiting: ${st.lastSkipReason}`,
},
}
}
if (ctx.session.phase === 'ingame') {
return {
level: 'ok',
text: {
zh: '记录中',
en: 'Recording',
},
}
}
return {
level: 'ok',
text: {
zh: '就绪(未在游戏中)',
en: 'Ready (not in game)',
},
}
}
return {
tracker,
repository,
flushNow: () => tracker.flushNow(),
getStatus,
async deactivate(): Promise<void> {
unsubSettings()
if (removeToolbarLink) {
removeToolbarLink()
removeToolbarLink = null
}
await tracker.flushNow()
tracker.destroy()
},
}
},
}
}
export const killStatsPlugin: D2WebPlugin = createKillStatsPlugin()

View File

@ -3,11 +3,11 @@
* validation for per-account, per-character monster kill statistics. * validation for per-account, per-character monster kill statistics.
*/ */
import type { CharClassId } from '../../src/netproto/index.ts'
import { import {
isCountableBossClass, isCountableBossClass,
isCountableKillClass, isCountableKillClass,
} from '../../common/units/monster-kill-classes.ts' } from './monster-kill-classes.ts'
import type { CharClassId } from '../../netproto/index.ts'
export const KILL_STATS_FORMAT = 'd2web-kill-stats' as const export const KILL_STATS_FORMAT = 'd2web-kill-stats' as const
export const KILL_STATS_VERSION = 1 as const export const KILL_STATS_VERSION = 1 as const

View File

@ -10,18 +10,18 @@
* - Monster Type × Difficulty table with column sorting and bilingual name/ID search * - Monster Type × Difficulty table with column sorting and bilingual name/ID search
*/ */
import { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts' import type { GameLang } from '../../src/client/plugin-host/api.ts'
import { resolveTblKey } from '../../src/common/data/canonical-tbl-strings.ts'
import {
getMonsterKindByClassId,
getSuperUniqueByIndex,
} from '../../src/common/units/monsters-data.ts'
import { CHAR_CLASS_NAMES, type CharClassId } from '../../src/netproto/index.ts'
import { import {
MONSTATS_MAX_HCIDX, MONSTATS_MAX_HCIDX,
isCountableBossClass, isCountableBossClass,
isCountableKillClass, isCountableKillClass,
} from '../../common/units/monster-kill-classes.ts' } from './monster-kill-classes.ts'
import {
getMonsterKindByClassId,
getSuperUniqueByIndex,
} from '../../common/units/monsters-data.ts'
import { CHAR_CLASS_NAMES, type CharClassId } from '../../netproto/index.ts'
import type { GameLang } from '../i18n/lang.ts'
import { import {
SUPERUNIQUE_COUNT, SUPERUNIQUE_COUNT,
type KillCharacterRecord, type KillCharacterRecord,

View File

@ -7,14 +7,16 @@
* `KillStatsRepository`, and notifies open `stats.html` tabs via `BroadcastChannel`. * `KillStatsRepository`, and notifies open `stats.html` tabs via `BroadcastChannel`.
*/ */
import { isCountableKillClass } from '../../common/units/monster-kill-classes.ts' import type {
import { getMonsterKindByClassId } from '../../common/units/monsters-data.ts' PluginLogger,
PluginSessionView,
} from '../../src/client/plugin-host/api.ts'
import { getMonsterKindByClassId } from '../../src/common/units/monsters-data.ts'
import { import {
unitTypeFromId, unitTypeFromId,
type CharClassId, type CharClassId,
type ServerEvent, type ServerEvent,
} from '../../netproto/index.ts' } from '../../src/netproto/index.ts'
import type { OnlineSession } from '../session/online-session.ts'
import { import {
KILL_STATS_CHANNEL_NAME, KILL_STATS_CHANNEL_NAME,
SUPERUNIQUE_COUNT, SUPERUNIQUE_COUNT,
@ -30,6 +32,7 @@ import {
IndexedDbKillStatsRepository, IndexedDbKillStatsRepository,
type KillStatsRepository, type KillStatsRepository,
} from './kill-stats-store.ts' } from './kill-stats-store.ts'
import { isCountableKillClass } from './monster-kill-classes.ts'
export interface KillTrackerScheduler { export interface KillTrackerScheduler {
setTimeout(cb: () => void, delayMs: number): unknown setTimeout(cb: () => void, delayMs: number): unknown
@ -53,18 +56,27 @@ export interface KillTrackerWindowLike {
readonly document?: KillTrackerDocumentLike | undefined readonly document?: KillTrackerDocumentLike | undefined
} }
export type SessionIdentityResolution =
| { readonly ok: true; readonly profile: KillCharacterProfile }
| { readonly ok: false; readonly reason: string }
export interface KillTrackerStatus { export interface KillTrackerStatus {
readonly pendingKills: number readonly pendingKills: number
readonly pendingDeltasCount: number readonly pendingDeltasCount: number
readonly lastFlushAt: number | null readonly lastFlushAt: number | null
readonly lastError: string | null readonly lastError: string | null
readonly lastSkipReason: string | null
readonly difficulty: KillDifficulty | null readonly difficulty: KillDifficulty | null
} }
export interface KillTrackerOptions { export interface KillTrackerOptions {
readonly session: OnlineSession readonly session: PluginSessionView
readonly repository?: KillStatsRepository | undefined readonly repository?: KillStatsRepository | undefined
readonly resolveIdentity?: ((session: OnlineSession) => KillCharacterProfile | null) | undefined readonly resolveIdentity?:
| ((session: PluginSessionView) => KillCharacterProfile | SessionIdentityResolution | null)
| undefined
readonly log?: PluginLogger | undefined
readonly onStatusChange?: (() => void) | undefined
readonly now?: (() => number) | undefined readonly now?: (() => number) | undefined
readonly scheduler?: KillTrackerScheduler | undefined readonly scheduler?: KillTrackerScheduler | undefined
readonly channelFactory?: ((name: string) => BroadcastChannelLike | null) | undefined readonly channelFactory?: ((name: string) => BroadcastChannelLike | null) | undefined
@ -87,13 +99,16 @@ interface PendingCharacterBatch {
/** /**
* Resolves the active 4-tuple character identity `(host, realm, account, charName)` and * Resolves the active 4-tuple character identity `(host, realm, account, charName)` and
* character profile metadata from `OnlineSession`. Returns `null` if any of the 4 identity * character profile metadata from `PluginSessionView`.
* components is missing or blank. *
* Fail-fast invariant: NEVER default `charClass` to `0`, `level` to `1`, or
* `hardcore`/`expansion`/`ladder` to guessed values. Requires `hasGameFlags === true`,
* valid integer `charClass` in `0..6`, and finite integer `level >= 1`.
*/ */
export function resolveSessionIdentity( export function resolveSessionIdentity(
session: OnlineSession, session: PluginSessionView,
hasGameFlags = true, hasGameFlags = true,
): KillCharacterProfile | null { ): SessionIdentityResolution {
const host = (session.loginHost ?? '').trim() const host = (session.loginHost ?? '').trim()
const snap = session.getSnapshot() const snap = session.getSnapshot()
const realm = (snap.selectedRealm?.title ?? '').trim() const realm = (snap.selectedRealm?.title ?? '').trim()
@ -101,19 +116,28 @@ export function resolveSessionIdentity(
const charName = (snap.selectedChar ?? '').trim() const charName = (snap.selectedChar ?? '').trim()
if (!host || !realm || !account || !charName) { if (!host || !realm || !account || !charName) {
return null return {
ok: false,
reason: 'incomplete character identity (host, realm, account, charName)',
}
}
if (!hasGameFlags) {
return {
ok: false,
reason: 'GameFlags (0x01) difficulty has not been received in this game',
}
} }
const charKey = computeCharKey(host, realm, account, charName)
const lowerName = charName.toLowerCase() const lowerName = charName.toLowerCase()
const matchedChar = snap.characters.find( const matchedChar = snap.characters.find(
(c) => c.name.trim().toLowerCase() === lowerName, (c) => c.name.trim().toLowerCase() === lowerName,
) )
const selfClass = session.world.self.charClass const selfClass = session.world.self.charClass
let charClass: CharClassId = 0 let charClass: CharClassId | null = null
if ( if (
selfClass !== null && typeof selfClass === 'number' &&
Number.isInteger(selfClass) && Number.isInteger(selfClass) &&
selfClass >= 0 && selfClass >= 0 &&
selfClass <= 6 selfClass <= 6
@ -121,31 +145,60 @@ export function resolveSessionIdentity(
charClass = selfClass as CharClassId charClass = selfClass as CharClassId
} else if ( } else if (
matchedChar && matchedChar &&
typeof matchedChar.charClass === 'number' &&
Number.isInteger(matchedChar.charClass) && Number.isInteger(matchedChar.charClass) &&
matchedChar.charClass >= 0 && matchedChar.charClass >= 0 &&
matchedChar.charClass <= 6 matchedChar.charClass <= 6
) { ) {
charClass = matchedChar.charClass as CharClassId charClass = matchedChar.charClass as CharClassId
} }
if (charClass === null) {
return {
ok: false,
reason: 'missing or invalid character class (expected integer 0..6)',
}
}
const selfLevel = session.world.self.level const selfLevel = session.world.self.level
const rawLevel = let rawLevel: number | null = null
Number.isFinite(selfLevel) && selfLevel >= 1 if (
? selfLevel typeof selfLevel === 'number' &&
: (matchedChar?.level ?? 1) Number.isFinite(selfLevel) &&
const level = Math.min(99, Math.max(1, Math.trunc(rawLevel))) Math.trunc(selfLevel) >= 1
) {
rawLevel = Math.trunc(selfLevel)
} else if (
matchedChar &&
typeof matchedChar.level === 'number' &&
Number.isFinite(matchedChar.level) &&
Math.trunc(matchedChar.level) >= 1
) {
rawLevel = Math.trunc(matchedChar.level)
}
if (rawLevel === null) {
return {
ok: false,
reason: 'missing or invalid character level (expected finite integer >= 1)',
}
}
const level = Math.min(99, rawLevel)
const hardcore = hasGameFlags if (
? Boolean(session.world.hardcore) typeof session.world.hardcore !== 'boolean' ||
: Boolean(matchedChar?.hardcore ?? false) typeof session.world.expansion !== 'boolean' ||
const expansion = hasGameFlags typeof session.world.ladder !== 'boolean'
? Boolean(session.world.expansion) ) {
: Boolean(matchedChar?.expansion ?? true) return {
const ladder = hasGameFlags ok: false,
? Boolean(session.world.ladder) reason: 'missing authoritative world mode flags (hardcore, expansion, ladder)',
: Boolean(matchedChar?.ladder ?? false) }
}
const charKey = computeCharKey(host, realm, account, charName)
return { return {
ok: true,
profile: {
charKey, charKey,
host, host,
realm, realm,
@ -153,9 +206,10 @@ export function resolveSessionIdentity(
charName, charName,
charClass, charClass,
level, level,
hardcore, hardcore: session.world.hardcore,
expansion, expansion: session.world.expansion,
ladder, ladder: session.world.ladder,
},
} }
} }
@ -164,6 +218,12 @@ const DEFAULT_SCHEDULER: KillTrackerScheduler = {
clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>), clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),
} }
const DEFAULT_LOGGER: PluginLogger = {
info: (msg, ...args) => console.info(`[KillTracker] ${msg}`, ...args),
warn: (msg, ...args) => console.warn(`[KillTracker] ${msg}`, ...args),
error: (msg, ...args) => console.error(`[KillTracker] ${msg}`, ...args),
}
function defaultChannelFactory(name: string): BroadcastChannelLike | null { function defaultChannelFactory(name: string): BroadcastChannelLike | null {
if (typeof globalThis.BroadcastChannel !== 'function') return null if (typeof globalThis.BroadcastChannel !== 'function') return null
try { try {
@ -175,8 +235,12 @@ function defaultChannelFactory(name: string): BroadcastChannelLike | null {
export class KillTracker { export class KillTracker {
readonly repository: KillStatsRepository readonly repository: KillStatsRepository
private readonly session: OnlineSession private readonly session: PluginSessionView
private readonly resolveIdentityFn: (session: OnlineSession) => KillCharacterProfile | null private readonly resolveIdentityFn: (
session: PluginSessionView,
) => KillCharacterProfile | SessionIdentityResolution | null
private readonly log: PluginLogger
private readonly onStatusChange: (() => void) | undefined
private readonly now: () => number private readonly now: () => number
private readonly scheduler: KillTrackerScheduler private readonly scheduler: KillTrackerScheduler
private readonly channel: BroadcastChannelLike | null private readonly channel: BroadcastChannelLike | null
@ -189,6 +253,7 @@ export class KillTracker {
private readonly owned = new Set<number>() private readonly owned = new Set<number>()
private warnedMissingGameFlags = false private warnedMissingGameFlags = false
private warnedMissingIdentity = false private warnedMissingIdentity = false
private lastSkipReason: string | null = null
private lastPhase: string private lastPhase: string
private readonly pendingByChar = new Map<string, PendingCharacterBatch>() private readonly pendingByChar = new Map<string, PendingCharacterBatch>()
@ -211,6 +276,8 @@ export class KillTracker {
this.resolveIdentityFn = this.resolveIdentityFn =
options.resolveIdentity ?? options.resolveIdentity ??
((s) => resolveSessionIdentity(s, this.difficulty !== null)) ((s) => resolveSessionIdentity(s, this.difficulty !== null))
this.log = options.log ?? DEFAULT_LOGGER
this.onStatusChange = options.onStatusChange
this.scheduler = options.scheduler ?? DEFAULT_SCHEDULER this.scheduler = options.scheduler ?? DEFAULT_SCHEDULER
const createChannel = options.channelFactory ?? defaultChannelFactory const createChannel = options.channelFactory ?? defaultChannelFactory
this.channel = createChannel(KILL_STATS_CHANNEL_NAME) this.channel = createChannel(KILL_STATS_CHANNEL_NAME)
@ -281,6 +348,7 @@ export class KillTracker {
pendingDeltasCount, pendingDeltasCount,
lastFlushAt: this.lastFlushAt, lastFlushAt: this.lastFlushAt,
lastError: this.lastError, lastError: this.lastError,
lastSkipReason: this.lastSkipReason,
difficulty: this.difficulty, difficulty: this.difficulty,
} }
} }
@ -304,7 +372,8 @@ export class KillTracker {
private recordInternalError(prefix: string, err: unknown): void { private recordInternalError(prefix: string, err: unknown): void {
const detail = err instanceof Error ? err.message : String(err) const detail = err instanceof Error ? err.message : String(err)
this.lastError = `${prefix}: ${detail}` this.lastError = `${prefix}: ${detail}`
console.error(`[KillTracker] ${prefix}:`, err) this.log.error(`${prefix}:`, err)
this.onStatusChange?.()
} }
private resetPerGameState(): void { private resetPerGameState(): void {
@ -313,6 +382,7 @@ export class KillTracker {
this.owned.clear() this.owned.clear()
this.warnedMissingGameFlags = false this.warnedMissingGameFlags = false
this.warnedMissingIdentity = false this.warnedMissingIdentity = false
this.lastSkipReason = null
} }
private handlePhaseChange(nextPhase: string): void { private handlePhaseChange(nextPhase: string): void {
@ -321,8 +391,10 @@ export class KillTracker {
if (prevPhase !== 'ingame' && nextPhase === 'ingame') { if (prevPhase !== 'ingame' && nextPhase === 'ingame') {
this.resetPerGameState() this.resetPerGameState()
this.onStatusChange?.()
} else if (prevPhase === 'ingame' && nextPhase !== 'ingame') { } else if (prevPhase === 'ingame' && nextPhase !== 'ingame') {
this.resetPerGameState() this.resetPerGameState()
this.onStatusChange?.()
void this.flushNow() void this.flushNow()
} }
} }
@ -338,6 +410,7 @@ export class KillTracker {
if (ev.difficulty === 0 || ev.difficulty === 1 || ev.difficulty === 2) { if (ev.difficulty === 0 || ev.difficulty === 1 || ev.difficulty === 2) {
this.difficulty = ev.difficulty this.difficulty = ev.difficulty
} }
this.onStatusChange?.()
return return
} }
@ -449,35 +522,64 @@ export class KillTracker {
return true return true
} }
private evaluateIdentity(): SessionIdentityResolution {
const raw = this.resolveIdentityFn(this.session)
if (!raw) {
return {
ok: false,
reason: 'incomplete character identity (host, realm, account, charName)',
}
}
if ('ok' in raw) {
return raw
}
if (
!raw.host.trim() ||
!raw.realm.trim() ||
!raw.account.trim() ||
!raw.charName.trim()
) {
return {
ok: false,
reason: 'incomplete character identity (host, realm, account, charName)',
}
}
return { ok: true, profile: raw }
}
private recordKill(unit: TrackedMonsterUnit): void { private recordKill(unit: TrackedMonsterUnit): void {
if (this.difficulty === null) { if (this.difficulty === null) {
const reason = 'GameFlags (0x01) difficulty has not been received in this game'
const changed = this.lastSkipReason !== reason
this.lastSkipReason = reason
if (!this.warnedMissingGameFlags) { if (!this.warnedMissingGameFlags) {
this.warnedMissingGameFlags = true this.warnedMissingGameFlags = true
const msg = this.log.warn(`Skipping monster kill count: ${reason}`)
'Skipping monster kill count: GameFlags (0x01) difficulty has not been received in this game' }
this.lastError = msg if (changed) {
console.warn(`[KillTracker] ${msg}`) this.onStatusChange?.()
} }
return return
} }
const profile = this.resolveIdentityFn(this.session) const identityRes = this.evaluateIdentity()
if ( if (!identityRes.ok) {
!profile || const changed = this.lastSkipReason !== identityRes.reason
!profile.host.trim() || this.lastSkipReason = identityRes.reason
!profile.realm.trim() ||
!profile.account.trim() ||
!profile.charName.trim()
) {
if (!this.warnedMissingIdentity) { if (!this.warnedMissingIdentity) {
this.warnedMissingIdentity = true this.warnedMissingIdentity = true
const msg = this.log.warn(`Skipping monster kill count: ${identityRes.reason}`)
'Skipping monster kill count: incomplete character identity (host, realm, account, charName)' }
this.lastError = msg if (changed) {
console.warn(`[KillTracker] ${msg}`) this.onStatusChange?.()
} }
return return
} }
const profile = identityRes.profile
if (this.lastSkipReason !== null) {
this.lastSkipReason = null
this.onStatusChange?.()
}
if (!isCountableKillClass(unit.classId)) { if (!isCountableKillClass(unit.classId)) {
return return
@ -511,7 +613,8 @@ export class KillTracker {
} else { } else {
const msg = `Out-of-range superUniqueId ${String(unit.superUniqueId)} for monster classId ${unit.classId}` const msg = `Out-of-range superUniqueId ${String(unit.superUniqueId)} for monster classId ${unit.classId}`
this.lastError = msg this.lastError = msg
console.error(`[KillTracker] ${msg}`) this.log.error(msg)
this.onStatusChange?.()
} }
} else if (kind.boss === true) { } else if (kind.boss === true) {
this.enqueueDelta(profile, 'boss', difficulty, unit.classId, isSolo, at) this.enqueueDelta(profile, 'boss', difficulty, unit.classId, isSolo, at)
@ -666,7 +769,8 @@ export class KillTracker {
const msg = const msg =
failedError instanceof Error ? failedError.message : String(failedError) failedError instanceof Error ? failedError.message : String(failedError)
this.lastError = msg this.lastError = msg
console.error('[KillTracker] Failed to flush kill stats batch:', failedError) this.log.error('Failed to flush kill stats batch:', failedError)
this.onStatusChange?.()
const backoffMs = Math.min( const backoffMs = Math.min(
this.maxBackoffMs, this.maxBackoffMs,
this.flushDelayMs * Math.pow(2, this.retryAttempts - 1), this.flushDelayMs * Math.pow(2, this.retryAttempts - 1),
@ -674,6 +778,9 @@ export class KillTracker {
this.scheduleFlush(backoffMs) this.scheduleFlush(backoffMs)
} else { } else {
this.retryAttempts = 0 this.retryAttempts = 0
if (anySucceeded) {
this.onStatusChange?.()
}
if (this.pendingByChar.size > 0 && this.flushTimer === null) { if (this.pendingByChar.size > 0 && this.flushTimer === null) {
this.scheduleFlush(this.flushDelayMs) this.scheduleFlush(this.flushDelayMs)
} }

View File

@ -7,7 +7,7 @@
* - `neverCount !== 0` (77 rows: bone prison, windows, trapped souls, barricade doors/walls, familiars) * - `neverCount !== 0` (77 rows: bone prison, windows, trapped souls, barricade doors/walls, familiars)
*/ */
import { getMonsterKindByClassId } from './monsters-data.ts' import { getMonsterKindByClassId } from '../../src/common/units/monsters-data.ts'
export const MONSTATS_MAX_HCIDX = 733 export const MONSTATS_MAX_HCIDX = 733

View File

@ -0,0 +1,75 @@
/**
* Browser entry point for `plugins/kill-stats/index.html`.
*/
import { bootPluginPage } from '../../src/client/plugin-host/api.ts'
import { killStatsPlugin } from './index.ts'
import type { KillStatsRepository } from './kill-stats-store.ts'
import type { KillStatsViewModel } from './kill-stats-view.ts'
import { tStats } from './stats-i18n.ts'
import { KillStatsPage } from './stats-page.ts'
export interface StatsAppController {
readonly ready: boolean
readonly page: KillStatsPage
readonly repository: KillStatsRepository
refresh(): Promise<void>
getViewModel(): KillStatsViewModel | null
getLastExportJson(): string | null
}
declare global {
interface Window {
__d2StatsApp?: StatsAppController
}
}
export async function bootStatsApp(): Promise<void> {
const root =
document.getElementById('d2-stats-app') ??
(() => {
const el = document.createElement('div')
el.id = 'd2-stats-app'
document.body.appendChild(el)
return el
})()
let page: KillStatsPage | null = null
const pageContext = bootPluginPage(killStatsPlugin)
try {
page = new KillStatsPage({
root,
pageContext,
})
await page.init()
const controller: StatsAppController = {
ready: true,
page,
repository: page.repository,
refresh: () => page!.refresh(),
getViewModel: () => page!.getViewModel(),
getLastExportJson: () => page!.getLastExportJson(),
}
window.__d2StatsApp = controller
} catch (err) {
console.error('[kill-stats] Failed to initialize KillStatsPage:', err)
const detail = err instanceof Error ? err.message : String(err)
const message = `${tStats('stats.error.idbUnavailable', pageContext.getLang())} (${detail})`
if (page) {
page.showError(message)
} else {
const banner = document.createElement('div')
banner.id = 'd2-stats-error-banner'
banner.className = 'd2-stats__banner d2-stats__banner--error'
banner.setAttribute('role', 'alert')
banner.textContent = message
root.appendChild(banner)
}
}
}
void bootStatsApp()

View File

@ -2,7 +2,7 @@
* Bilingual (`zh` / `en`) UI strings for `stats.html`. * Bilingual (`zh` / `en`) UI strings for `stats.html`.
*/ */
import type { GameLang } from '../i18n/lang.ts' import type { GameLang } from '../../src/client/plugin-host/api.ts'
export type StatsI18nKey = export type StatsI18nKey =
| 'stats.page.title' | 'stats.page.title'
@ -54,10 +54,12 @@ export type StatsI18nKey =
| 'stats.emptyState.body' | 'stats.emptyState.body'
| 'stats.footnote.attribution' | 'stats.footnote.attribution'
| 'stats.import.successSummary' | 'stats.import.successSummary'
| 'stats.notice.pluginDisabled'
| 'stats.error.idbUnavailable' | 'stats.error.idbUnavailable'
| 'stats.error.idbBlocked' | 'stats.error.idbBlocked'
| 'stats.error.idbVersionChange' | 'stats.error.idbVersionChange'
| 'stats.error.importFailedPrefix' | 'stats.error.importFailedPrefix'
| 'stats.error.bootFailedPrefix'
export const STATS_I18N: Readonly< export const STATS_I18N: Readonly<
Record<StatsI18nKey, { readonly zh: string; readonly en: string }> Record<StatsI18nKey, { readonly zh: string; readonly en: string }>
@ -258,6 +260,10 @@ export const STATS_I18N: Readonly<
zh: '导入合并完成', zh: '导入合并完成',
en: 'Import merge complete', en: 'Import merge complete',
}, },
'stats.notice.pluginDisabled': {
zh: '击杀统计插件当前已禁用 — 仅展示历史已保存数据,当前游戏内击杀不会被记录。可在游戏顶部工具栏「插件」弹窗中重新启用。',
en: 'Kill Statistics plugin is currently disabled — showing saved historical data only; new in-game kills are not being recorded. Re-enable it from the Toolbar Plugins modal.',
},
'stats.error.idbUnavailable': { 'stats.error.idbUnavailable': {
zh: 'IndexedDB 在当前浏览器环境下不可用,无法读取或保存击杀统计。', zh: 'IndexedDB 在当前浏览器环境下不可用,无法读取或保存击杀统计。',
en: 'IndexedDB is unavailable in this browser environment; cannot load or save kill statistics.', en: 'IndexedDB is unavailable in this browser environment; cannot load or save kill statistics.',
@ -274,6 +280,10 @@ export const STATS_I18N: Readonly<
zh: 'JSON 导入校验失败:', zh: 'JSON 导入校验失败:',
en: 'JSON import validation failed: ', en: 'JSON import validation failed: ',
}, },
'stats.error.bootFailedPrefix': {
zh: '击杀统计页面启动失败:',
en: 'Failed to initialize Kill Statistics page: ',
},
} }
export function tStats(key: StatsI18nKey, lang: GameLang): string { export function tStats(key: StatsI18nKey, lang: GameLang): string {

View File

@ -1,15 +1,19 @@
/** /**
* DOM controller and interactive UI for `stats.html`. * DOM controller and interactive UI for `plugins/kill-stats/index.html`.
* *
* Security & Fidelity Invariants: * Security & Fidelity Invariants:
* - All user/server/file strings are rendered exclusively via `textContent` (never `innerHTML`). * - All user/server/file strings are rendered exclusively via `textContent` (never `innerHTML`).
* - Synchronizes language (`zh` / `en`) with `getGlobalSettingsStore()` (`d2web.settings.v1`). * - Synchronizes language (`zh` / `en`) and plugin enabled state via `bootPluginPage(killStatsPlugin)`.
* - Subscribes to `BroadcastChannel('d2web-kill-stats')` with 300ms debounced auto-refresh. * - Subscribes to `BroadcastChannel('d2web-kill-stats')` with 300ms debounced auto-refresh.
* - Displays `role="alert"` error banners on IndexedDB errors or `KillStatsImportError` violations. * - Displays `role="alert"` error banners on IndexedDB errors or `KillStatsImportError` violations.
*/ */
import { getGameLang, setGameLang, type GameLang } from '../i18n/lang.ts' import {
import { getGlobalSettingsStore, type SettingsStore } from '../settings/settings-store.ts' bootPluginPage,
type GameLang,
type PluginPageContext,
} from '../../src/client/plugin-host/api.ts'
import { killStatsPlugin } from './index.ts'
import { import {
KILL_STATS_CHANNEL_NAME, KILL_STATS_CHANNEL_NAME,
KillStatsImportError, KillStatsImportError,
@ -34,7 +38,7 @@ import { tStats } from './stats-i18n.ts'
export interface KillStatsPageOptions { export interface KillStatsPageOptions {
readonly root: HTMLElement readonly root: HTMLElement
readonly repository?: KillStatsRepository | undefined readonly repository?: KillStatsRepository | undefined
readonly settingsStore?: SettingsStore | undefined readonly pageContext?: PluginPageContext | undefined
readonly confirmFn?: ((message: string) => boolean) | undefined readonly confirmFn?: ((message: string) => boolean) | undefined
readonly refreshDebounceMs?: number | undefined readonly refreshDebounceMs?: number | undefined
} }
@ -47,6 +51,7 @@ interface BuiltDomRefs {
readonly allAccountsLabelEl: HTMLElement readonly allAccountsLabelEl: HTMLElement
readonly allAccountsCountEl: HTMLElement readonly allAccountsCountEl: HTMLElement
readonly accountTreeEl: HTMLElement readonly accountTreeEl: HTMLElement
readonly disabledNoticeEl: HTMLElement
readonly errorBannerEl: HTMLElement readonly errorBannerEl: HTMLElement
readonly infoBannerEl: HTMLElement readonly infoBannerEl: HTMLElement
readonly cardNormalLabelEl: HTMLElement readonly cardNormalLabelEl: HTMLElement
@ -117,7 +122,7 @@ export class KillStatsPage {
readonly repository: KillStatsRepository readonly repository: KillStatsRepository
private readonly root: HTMLElement private readonly root: HTMLElement
private readonly doc: Document private readonly doc: Document
private readonly settingsStore: SettingsStore private readonly pageContext: PluginPageContext
private readonly confirmFn: (message: string) => boolean private readonly confirmFn: (message: string) => boolean
private readonly refreshDebounceMs: number private readonly refreshDebounceMs: number
private readonly dom: BuiltDomRefs private readonly dom: BuiltDomRefs
@ -143,14 +148,14 @@ export class KillStatsPage {
private channel: BroadcastChannel | null = null private channel: BroadcastChannel | null = null
private refreshTimer: ReturnType<typeof setTimeout> | null = null private refreshTimer: ReturnType<typeof setTimeout> | null = null
private unsubSettings: (() => void) | null = null private unsubLang: (() => void) | null = null
private unsubEnabled: (() => void) | null = null
constructor(options: KillStatsPageOptions) { constructor(options: KillStatsPageOptions) {
this.root = options.root this.root = options.root
this.doc = options.root.ownerDocument this.doc = options.root.ownerDocument
this.settingsStore = options.settingsStore ?? getGlobalSettingsStore() this.pageContext = options.pageContext ?? bootPluginPage(killStatsPlugin)
this.lang = this.settingsStore.get().lang ?? getGameLang() this.lang = this.pageContext.getLang()
setGameLang(this.lang)
this.confirmFn = this.confirmFn =
options.confirmFn ?? options.confirmFn ??
((msg: string) => ((msg: string) =>
@ -176,13 +181,15 @@ export class KillStatsPage {
async init(): Promise<void> { async init(): Promise<void> {
this.setupBroadcastChannel() this.setupBroadcastChannel()
this.unsubSettings = this.settingsStore.subscribe((next) => { this.unsubLang = this.pageContext.subscribeLang((nextLang) => {
if (next.lang !== this.lang) { if (nextLang !== this.lang) {
this.lang = next.lang this.lang = nextLang
setGameLang(this.lang)
this.render() this.render()
} }
}) })
this.unsubEnabled = this.pageContext.subscribeEnabled(() => {
this.render()
})
await this.refresh() await this.refresh()
} }
@ -191,8 +198,11 @@ export class KillStatsPage {
clearTimeout(this.refreshTimer) clearTimeout(this.refreshTimer)
this.refreshTimer = null this.refreshTimer = null
} }
this.unsubSettings?.() this.unsubLang?.()
this.unsubSettings = null this.unsubLang = null
this.unsubEnabled?.()
this.unsubEnabled = null
this.pageContext.destroy()
try { try {
this.channel?.close() this.channel?.close()
} catch { } catch {
@ -420,8 +430,7 @@ export class KillStatsPage {
toggleLanguage(): GameLang { toggleLanguage(): GameLang {
const nextLang: GameLang = this.lang === 'en' ? 'zh' : 'en' const nextLang: GameLang = this.lang === 'en' ? 'zh' : 'en'
this.lang = nextLang this.lang = nextLang
setGameLang(nextLang) this.pageContext.setLang(nextLang)
this.settingsStore.set({ lang: nextLang })
this.render() this.render()
return nextLang return nextLang
} }
@ -519,6 +528,10 @@ export class KillStatsPage {
} }
this.doc.title = tStats('stats.page.title', lang) this.doc.title = tStats('stats.page.title', lang)
const isPluginEnabled = this.pageContext.isEnabled()
this.dom.disabledNoticeEl.textContent = tStats('stats.notice.pluginDisabled', lang)
this.dom.disabledNoticeEl.hidden = isPluginEnabled
this.dom.pageTitleEl.textContent = tStats('stats.page.title', lang) this.dom.pageTitleEl.textContent = tStats('stats.page.title', lang)
this.dom.backToGameLink.textContent = tStats('stats.action.backToGame', lang) this.dom.backToGameLink.textContent = tStats('stats.action.backToGame', lang)
this.dom.sidebarTitleEl.textContent = tStats('stats.sidebar.title', lang) this.dom.sidebarTitleEl.textContent = tStats('stats.sidebar.title', lang)
@ -894,7 +907,7 @@ export class KillStatsPage {
const backToGameLink = this.doc.createElement('a') const backToGameLink = this.doc.createElement('a')
backToGameLink.id = 'd2-stats-back-link' backToGameLink.id = 'd2-stats-back-link'
backToGameLink.className = 'd2-stats__btn d2-stats__btn--link' backToGameLink.className = 'd2-stats__btn d2-stats__btn--link'
backToGameLink.href = 'play.html' backToGameLink.href = '../../play.html'
sidebarHeader.append(pageTitleEl, backToGameLink) sidebarHeader.append(pageTitleEl, backToGameLink)
@ -923,6 +936,12 @@ export class KillStatsPage {
main.id = 'd2-stats-main' main.id = 'd2-stats-main'
main.className = 'd2-stats__main' main.className = 'd2-stats__main'
const disabledNoticeEl = this.doc.createElement('div')
disabledNoticeEl.id = 'd2-stats-disabled-notice'
disabledNoticeEl.className = 'd2-stats__banner d2-stats__banner--warn'
disabledNoticeEl.setAttribute('role', 'status')
disabledNoticeEl.hidden = true
const errorBannerEl = this.doc.createElement('div') const errorBannerEl = this.doc.createElement('div')
errorBannerEl.id = 'd2-stats-error-banner' errorBannerEl.id = 'd2-stats-error-banner'
errorBannerEl.className = 'd2-stats__banner d2-stats__banner--error' errorBannerEl.className = 'd2-stats__banner d2-stats__banner--error'
@ -1180,6 +1199,7 @@ export class KillStatsPage {
footnoteEl.className = 'd2-stats__footnote' footnoteEl.className = 'd2-stats__footnote'
main.append( main.append(
disabledNoticeEl,
errorBannerEl, errorBannerEl,
infoBannerEl, infoBannerEl,
summaryGrid, summaryGrid,
@ -1200,6 +1220,7 @@ export class KillStatsPage {
allAccountsLabelEl, allAccountsLabelEl,
allAccountsCountEl, allAccountsCountEl,
accountTreeEl, accountTreeEl,
disabledNoticeEl,
errorBannerEl, errorBannerEl,
infoBannerEl, infoBannerEl,
cardNormalLabelEl: normalCard.label, cardNormalLabelEl: normalCard.label,

View File

@ -6,9 +6,10 @@
## 1. 模块职责与架构边界 ## 1. 模块职责与架构边界
- **双 HTML 页面入口**: - **主页面入口与非侵入式插件宿主**:
- [`play.html`](../../play.html) $\rightarrow$ [`src/client/main.ts`](./main.ts)(在线游戏主客户端)。 - [`play.html`](../../play.html) $\rightarrow$ [`src/client/main.ts`](./main.ts)(在线游戏主客户端)。
- [`stats.html`](../../stats.html) $\rightarrow$ [`src/client/stats/stats-main.ts`](./stats/stats-main.ts)(本地 IndexedDB 按账号/角色隔离的怪物击杀统计页,支持跨标签页 BroadcastChannel 实时刷新与 G-counter JSON 导出/导入合并)。 - 可选客户端扩展插件统一解耦至顶层 [`plugins/`](../../plugins) 目录(例如怪物击杀统计插件 [`plugins/kill-stats/`](../../plugins/kill-stats) 及其独立页面 [`plugins/kill-stats/index.html`](../../plugins/kill-stats/index.html))。
- `src/client` 通过 [`src/client/plugin-host/`](./plugin-host) 向插件提供只读会话视图(`PluginSessionView`)、生命周期与语言切换钩子以及顶部工具栏插件配置菜单(`PluginMenuModal`),除组根 [`src/client/main.ts`](./main.ts) 读取注册表 [`plugins/index.ts`](../../plugins/index.ts) 外,**`src/client/**` 严禁反向依赖任何 `plugins/*` 代码**(由 `tests/arch/boundaries.test.ts` Rule 7–10 强制守卫)。
- **TypeScript 工程约束(`tsconfig.client.json`)**: - **TypeScript 工程约束(`tsconfig.client.json`)**:
- `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。 - `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。
- **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。 - **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。
@ -20,18 +21,18 @@
```text ```text
src/client/ src/client/
├── main.ts # 客户端主入口:组装 WebGL2 画布、工具栏、前端战网状态机、OnlineSession 与 60fps 渲染循环 ├── main.ts # 客户端主入口:组装 WebGL2 画布、工具栏、前端战网状态机、OnlineSession、PluginHost 与 60fps 渲染循环
├── session/ # OnlineSession 会话协调器与 C2S 发包速率限制器 (OutboundRateLimiter) ├── session/ # OnlineSession 会话协调器与 C2S 发包速率限制器 (OutboundRateLimiter)
├── world/ # ClientWorld:由 ServerEvent 驱动的客户端权威状态镜像 (玩家/怪物/NPC/地面物品/投射物/传送门) ├── world/ # ClientWorld:由 ServerEvent 驱动的客户端权威状态镜像 (玩家/怪物/NPC/地面物品/投射物/传送门)
├── map/ # 种子驱动的客户端 DRLG 地图服务 (MapService, LevelView, TileAtlas GPU 瓦片加载) ├── map/ # 种子驱动的客户端 DRLG 地图服务 (MapService, LevelView, TileAtlas GPU 瓦片加载)
├── motion/ # 本地玩家移动预测 (LocalMovementPredictor)、远端实体插值器与 A* 寻路 (pathfind) ├── motion/ # 本地玩家移动预测 (LocalMovementPredictor)、远端实体插值器与 A* 寻路 (pathfind)
├── render/ # WebGL2 渲染管线 (SpriteRenderer, TileAtlasGpuCache, EntityArtLoader, 投射物/覆盖层/地面物品渲染) ├── render/ # WebGL2 渲染管线 (SpriteRenderer, TileAtlasGpuCache, EntityArtLoader, 投射物/覆盖层/地面物品渲染)
├── scene/ # 场景摄像机、屏幕/世界等距坐标转换与分幕环境光照预设 (lighting-presets) ├── scene/ # 场景摄像机、屏幕/世界等距坐标转换与分幕环境光照预设 (lighting-presets)
├── ui/ # 游戏内 HUD 渲染与交互 (HudManager, 底部状态栏, 小地图 Automap, 7 大面板, D2 点阵字体渲染) ├── ui/ # 游戏内 HUD 渲染与交互 (HudManager, 底部状态栏, 小地图 Automap, 7 大面板, D2 点阵字体渲染, DOM 浮层焦点守卫)
├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑) ├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑)
├── frontend/ # 战网前置界面 (FrontendRenderer:Gateway 选择、账号登录/注册、8 角色选择、建房/加房大厅) ├── frontend/ # 战网前置界面 (FrontendRenderer:Gateway 选择、账号登录/注册、8 角色选择、建房/加房大厅)
├── toolbar/ # 顶部 32px 窄条工具栏 (FPS/Ping、网络抓包开关、击杀统计入口、设置弹窗、全屏与音量控制) ├── toolbar/ # 顶部 32px 窄条工具栏 (FPS/Ping、网络抓包开关、插件快捷按钮插槽、Plugins 配置菜单入口、设置弹窗、全屏与音量控制)
├── stats/ # 按账号/角色隔离的怪物击杀统计 (KillTracker、IndexedDB G-counter 存储与导入导出、stats.html 视图) ├── plugin-host/ # 非侵入式插件宿主与配置中心 (PluginHost, PluginConfigStore, PluginMenuModal 与只读契约 api.ts)
├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理 WS Bridge 地址与显示偏好) ├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理 WS Bridge 地址与显示偏好)
├── inspector/ # 实时网络封包检查器 (PacketInspector) 与 .d2cap 录制/回放面板 ├── inspector/ # 实时网络封包检查器 (PacketInspector) 与 .d2cap 录制/回放面板
├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存 ├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存

View File

@ -108,7 +108,11 @@ export function detectLangFromUrl(urlStr?: string): TblLang {
return detectInitialLang() === 'en' ? 'ENG' : 'CHI' return detectInitialLang() === 'en' ? 'ENG' : 'CHI'
} }
export function detectInitialLang(): GameLang { export interface DetectLangOptions {
readonly preferStoredSetting?: boolean | undefined
}
export function detectInitialLang(options?: DetectLangOptions): GameLang {
if (typeof window !== 'undefined' && window.location) { if (typeof window !== 'undefined' && window.location) {
const params = new URLSearchParams(window.location.search || '') const params = new URLSearchParams(window.location.search || '')
const q = params.get('lang')?.trim().toLowerCase() const q = params.get('lang')?.trim().toLowerCase()
@ -121,10 +125,9 @@ export function detectInitialLang(): GameLang {
return fromPath return fromPath
} }
if ( if (
options?.preferStoredSetting === true ||
path === 'play.html' || path === 'play.html' ||
path.endsWith('/play.html') || path.endsWith('/play.html')
path === 'stats.html' ||
path.endsWith('/stats.html')
) { ) {
try { try {
const raw = window.localStorage?.getItem('d2web.settings.v1') const raw = window.localStorage?.getItem('d2web.settings.v1')
@ -223,8 +226,8 @@ export function setTblLang(lang: TblLang | GameLang | null): void {
setGameLang(lang) setGameLang(lang)
} }
export function initLangFromBrowser(): TblLang { export function initLangFromBrowser(options?: DetectLangOptions): TblLang {
overrideLang = detectInitialLang() overrideLang = detectInitialLang(options)
notifyListeners() notifyListeners()
return getTblLang() return getTblLang()
} }
@ -279,7 +282,7 @@ export type ToolbarI18nKey =
| 'toolbar.automap.act' | 'toolbar.automap.act'
| 'toolbar.viewport.label' | 'toolbar.viewport.label'
| 'toolbar.inspector.toggle' | 'toolbar.inspector.toggle'
| 'toolbar.killStats.open' | 'toolbar.plugins.open'
| 'toolbar.cache.clear' | 'toolbar.cache.clear'
| 'toolbar.cache.cleared' | 'toolbar.cache.cleared'
| 'toolbar.diag.quotaExceeded' | 'toolbar.diag.quotaExceeded'
@ -308,7 +311,7 @@ export const TOOLBAR_I18N: Readonly<Record<ToolbarI18nKey, { readonly zh: string
'toolbar.automap.act': { zh: '全幕', en: 'Act' }, 'toolbar.automap.act': { zh: '全幕', en: 'Act' },
'toolbar.viewport.label': { zh: '分辨率', en: 'Viewport' }, 'toolbar.viewport.label': { zh: '分辨率', en: 'Viewport' },
'toolbar.inspector.toggle': { zh: '抓包面板', en: 'Packets' }, 'toolbar.inspector.toggle': { zh: '抓包面板', en: 'Packets' },
'toolbar.killStats.open': { zh: '击杀统计', en: 'Kill Stats' }, 'toolbar.plugins.open': { zh: '插件', en: 'Plugins' },
'toolbar.cache.clear': { zh: '清除缓存', en: 'Clear Cache' }, 'toolbar.cache.clear': { zh: '清除缓存', en: 'Clear Cache' },
'toolbar.cache.cleared': { zh: '已清缓存', en: 'Cleared' }, 'toolbar.cache.cleared': { zh: '已清缓存', en: 'Cleared' },
'toolbar.diag.quotaExceeded': { 'toolbar.diag.quotaExceeded': {

View File

@ -51,14 +51,17 @@ import {
BootProgressTracker, BootProgressTracker,
type BootProgressSnapshot, type BootProgressSnapshot,
} from './ui/boot-progress.ts' } from './ui/boot-progress.ts'
import { isModalDomOverlayOpen } from './ui/dom-overlay.ts'
import { HudManager } from './ui/hud-manager.ts' import { HudManager } from './ui/hud-manager.ts'
import { Minimap } from './ui/minimap.ts' import { Minimap } from './ui/minimap.ts'
import { CommandMapper } from './input/command-mapper.ts' import { CommandMapper } from './input/command-mapper.ts'
import { PacketInspector } from './inspector/packet-inspector.ts' import { PacketInspector } from './inspector/packet-inspector.ts'
import { MapService } from './map/map-service.ts' import { MapService } from './map/map-service.ts'
import type { D2WebPlugin } from './plugin-host/api.ts'
import { PluginHost } from './plugin-host/plugin-host.ts'
import { PluginMenu } from './plugin-host/plugin-menu.ts'
import { OnlineSession, SessionLostError } from './session/online-session.ts' import { OnlineSession, SessionLostError } from './session/online-session.ts'
import { getGlobalSettingsStore, type SettingsStore } from './settings/settings-store.ts' import { getGlobalSettingsStore, type SettingsStore } from './settings/settings-store.ts'
import { KillTracker } from './stats/kill-tracker.ts'
import { import {
getAssetCacheStatus, getAssetCacheStatus,
registerAssetCache, registerAssetCache,
@ -93,7 +96,8 @@ export interface PlayAppController {
readonly settings: SettingsStore readonly settings: SettingsStore
readonly settingsStore: SettingsStore readonly settingsStore: SettingsStore
readonly session: OnlineSession readonly session: OnlineSession
readonly killStats: KillTracker readonly plugins: PluginHost
readonly pluginMenu: PluginMenu
readonly toolbar: Toolbar readonly toolbar: Toolbar
readonly inspector: PacketInspector readonly inspector: PacketInspector
readonly hudManager: HudManager readonly hudManager: HudManager
@ -125,7 +129,9 @@ declare global {
} }
} }
async function bootPlayApp(): Promise<PlayAppController> { export async function startPlayApp(options?: {
readonly plugins?: readonly D2WebPlugin[]
}): Promise<PlayAppController> {
window.__d2SkipAutoBoot = true window.__d2SkipAutoBoot = true
const toolbarContainer = document.querySelector<HTMLElement>('#d2-toolbar-container') const toolbarContainer = document.querySelector<HTMLElement>('#d2-toolbar-container')
@ -149,6 +155,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
setLang(initialSettings.lang) setLang(initialSettings.lang)
let sessionRef: OnlineSession | null = null let sessionRef: OnlineSession | null = null
let pluginMenuRef: PluginMenu | null = null
const syncStageViewportFit = ( const syncStageViewportFit = (
profileOrId: ViewportProfile | typeof initialSettings.viewport = settingsStore.get().viewport, profileOrId: ViewportProfile | typeof initialSettings.viewport = settingsStore.get().viewport,
@ -185,6 +192,9 @@ async function bootPlayApp(): Promise<PlayAppController> {
onServerHostReconnect: (newHost) => { onServerHostReconnect: (newHost) => {
void sessionRef?.disconnect(`Reconnecting to ${newHost}`) void sessionRef?.disconnect(`Reconnecting to ${newHost}`)
}, },
onPluginsToggle: () => {
pluginMenuRef?.toggle()
},
}, },
}) })
@ -384,7 +394,16 @@ async function bootPlayApp(): Promise<PlayAppController> {
}, },
}) })
sessionRef = session sessionRef = session
const killStats = new KillTracker({ session }) const plugins = new PluginHost({
plugins: options?.plugins ?? [],
session,
toolbar,
})
const pluginMenu = new PluginMenu({
host: plugins,
toolbar,
})
pluginMenuRef = pluginMenu
// Mount PacketInspector inside #d2-inspector-container if available // Mount PacketInspector inside #d2-inspector-container if available
const inspector = session.packetInspector const inspector = session.packetInspector
@ -902,6 +921,9 @@ async function bootPlayApp(): Promise<PlayAppController> {
window.addEventListener( window.addEventListener(
'keydown', 'keydown',
(e) => { (e) => {
if (isModalDomOverlayOpen()) {
return
}
const activeEl = document.activeElement const activeEl = document.activeElement
if ( if (
activeEl && activeEl &&
@ -927,6 +949,9 @@ async function bootPlayApp(): Promise<PlayAppController> {
window.addEventListener( window.addEventListener(
'keyup', 'keyup',
(e) => { (e) => {
if (isModalDomOverlayOpen()) {
return
}
if (inGameView) { if (inGameView) {
session.commandMapper.handleKeyUp(e) session.commandMapper.handleKeyUp(e)
} }
@ -1011,6 +1036,8 @@ async function bootPlayApp(): Promise<PlayAppController> {
renderFrame(performance.now()) renderFrame(performance.now())
requestAnimationFrame(loop) requestAnimationFrame(loop)
await plugins.whenStarted()
const controller: PlayAppController = { const controller: PlayAppController = {
ready: true, ready: true,
bootProgress: bootProgress.getSnapshot(), bootProgress: bootProgress.getSnapshot(),
@ -1018,7 +1045,8 @@ async function bootPlayApp(): Promise<PlayAppController> {
settings: settingsStore, settings: settingsStore,
settingsStore, settingsStore,
session, session,
killStats, plugins,
pluginMenu,
toolbar, toolbar,
inspector, inspector,
hudManager: session.hudManager, hudManager: session.hudManager,
@ -1058,11 +1086,3 @@ async function bootPlayApp(): Promise<PlayAppController> {
window.__d2Play = controller window.__d2Play = controller
return controller return controller
} }
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
bootPlayApp().catch((err) => {
;(window as unknown as { __d2BootError?: string }).__d2BootError =
err instanceof Error ? `${err.message}\n${err.stack ?? ''}` : String(err)
console.error('[play.html] Boot error:', err)
})
}

View File

@ -0,0 +1,443 @@
/**
* Public Plugin API Contract (`src/client/plugin-host/api.ts`).
*
* Architectural Rule 9: This is the SOLE `src/client/` module that `plugins/**` may import.
* It defines the plugin descriptor contract, runtime context, status reporting, fail-fast
* registry validation, and the standalone plugin page bootstrapper (`bootPluginPage`).
*/
import {
getGameLang,
initLangFromBrowser,
onLangChange,
setLang,
type GameLang,
} from '../i18n/lang.ts'
import type { OnlineSession } from '../session/online-session.ts'
import { getGlobalSettingsStore } from '../settings/settings-store.ts'
import {
PLUGIN_CONFIG_STORAGE_KEY,
PluginConfigStore,
} from './plugin-config-store.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
}
| {
readonly kind: 'button'
readonly id: string
readonly label: LocalizedText
run(ctx: PluginContext): void
}
export interface D2WebPlugin {
readonly id: string
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 PluginLogger {
info(msg: string, ...args: unknown[]): void
warn(msg: string, ...args: unknown[]): void
error(msg: string, ...args: unknown[]): void
}
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
}
const KEBAB_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
export function resolveLocalizedText(text: LocalizedText, lang: GameLang): string {
return lang === 'en' ? text.en : text.zh
}
export function isValidLocalizedText(value: unknown): value is LocalizedText {
if (!value || typeof value !== 'object') return false
const candidate = value as { zh?: unknown; en?: unknown }
return (
typeof candidate.zh === 'string' &&
candidate.zh.trim().length > 0 &&
typeof candidate.en === 'string' &&
candidate.en.trim().length > 0
)
}
/**
* Only relative paths inside the application are permitted for plugin link actions
* and toolbar links. External schemes (`http:`, `https:`, `javascript:`, `data:`),
* protocol-relative URLs (`//`), and root-absolute paths (`/`) are rejected.
*/
export function isRelativePluginHref(href: string): boolean {
if (typeof href !== 'string') return false
const trimmed = href.trim()
if (trimmed.length === 0) return false
if (trimmed.startsWith('/') || trimmed.startsWith('//') || trimmed.startsWith('\\')) {
return false
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) {
return false
}
return true
}
export function isValidPluginSettingValue(
spec: PluginSettingSpec,
value: unknown,
): value is boolean | string {
if (spec.kind === 'boolean') {
return typeof value === 'boolean'
}
if (spec.kind === 'select') {
return (
typeof value === 'string' &&
spec.options.some((opt) => opt.value === value)
)
}
return false
}
export function validatePluginDefinition(plugin: D2WebPlugin): void {
if (!plugin || typeof plugin !== 'object') {
throw new Error('[PluginHost] Invalid plugin definition: expected object')
}
if (typeof plugin.id !== 'string' || !KEBAB_ID_RE.test(plugin.id)) {
throw new Error(
`[PluginHost] Invalid plugin id "${String(plugin.id)}": must be non-empty kebab-case`,
)
}
if (!isValidLocalizedText(plugin.name)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" must provide non-empty localized name (zh & en)`,
)
}
if (!isValidLocalizedText(plugin.description)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" must provide non-empty localized description (zh & en)`,
)
}
if (typeof plugin.defaultEnabled !== 'boolean') {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" defaultEnabled must be a boolean`,
)
}
if (typeof plugin.activate !== 'function') {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" must implement activate(ctx)`,
)
}
if (plugin.settings !== undefined) {
if (!Array.isArray(plugin.settings)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" settings must be an array`,
)
}
const seenKeys = new Set<string>()
for (const spec of plugin.settings) {
if (!spec || typeof spec !== 'object') {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" has an invalid setting descriptor`,
)
}
if (typeof spec.key !== 'string' || spec.key.trim().length === 0) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" has a setting with an empty key`,
)
}
if (seenKeys.has(spec.key)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" has duplicate setting key "${spec.key}"`,
)
}
seenKeys.add(spec.key)
if (!isValidLocalizedText(spec.label)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" setting "${spec.key}" must have non-empty localized label`,
)
}
if (spec.kind === 'boolean') {
if (typeof spec.default !== 'boolean') {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" boolean setting "${spec.key}" must have boolean default`,
)
}
} else if (spec.kind === 'select') {
if (!Array.isArray(spec.options) || spec.options.length === 0) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" select setting "${spec.key}" must have non-empty options`,
)
}
const optionValues = new Set<string>()
for (const opt of spec.options) {
if (!opt || typeof opt.value !== 'string' || opt.value.trim().length === 0) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" select setting "${spec.key}" has an option with empty value`,
)
}
if (optionValues.has(opt.value)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" select setting "${spec.key}" has duplicate option "${opt.value}"`,
)
}
optionValues.add(opt.value)
if (!isValidLocalizedText(opt.label)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" select setting "${spec.key}" option "${opt.value}" must have localized label`,
)
}
}
if (typeof spec.default !== 'string' || !optionValues.has(spec.default)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" select setting "${spec.key}" default "${String(spec.default)}" is not in options`,
)
}
} else {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" setting "${(spec as { key?: string }).key ?? ''}" has unsupported kind`,
)
}
}
}
if (plugin.actions !== undefined) {
if (!Array.isArray(plugin.actions)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" actions must be an array`,
)
}
const seenIds = new Set<string>()
for (const action of plugin.actions) {
if (!action || typeof action !== 'object') {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" has an invalid action descriptor`,
)
}
if (typeof action.id !== 'string' || !KEBAB_ID_RE.test(action.id)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" action id "${String(action.id)}" must be non-empty kebab-case`,
)
}
if (seenIds.has(action.id)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" has duplicate action id "${action.id}"`,
)
}
seenIds.add(action.id)
if (!isValidLocalizedText(action.label)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" action "${action.id}" must have non-empty localized label`,
)
}
if (action.kind === 'link') {
if (!isRelativePluginHref(action.href)) {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" link action "${action.id}" href "${String(action.href)}" must be a relative path`,
)
}
} else if (action.kind === 'button') {
if (typeof action.run !== 'function') {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" button action "${action.id}" must provide a run(ctx) function`,
)
}
} else {
throw new Error(
`[PluginHost] Plugin "${plugin.id}" action "${(action as { id?: string }).id ?? ''}" has unsupported kind`,
)
}
}
}
}
export function validatePluginRegistry(plugins: readonly D2WebPlugin[]): void {
if (!Array.isArray(plugins)) {
throw new Error('[PluginHost] Plugin registry must be an array')
}
const seenIds = new Set<string>()
for (const plugin of plugins) {
validatePluginDefinition(plugin)
if (seenIds.has(plugin.id)) {
throw new Error(`[PluginHost] Duplicate plugin id "${plugin.id}" in registry`)
}
seenIds.add(plugin.id)
}
}
/**
* Bootstrap context for a standalone plugin page (e.g. `/plugins/<id>/index.html`).
* - Resolves language using URL `?lang=` > stored `d2web.settings.v1` > `<html lang>`
* - Synchronizes global language and plugin enabled/settings state across tabs via `storage` events
*/
export function bootPluginPage(plugin: D2WebPlugin): PluginPageContext {
validatePluginDefinition(plugin)
initLangFromBrowser({ preferStoredSetting: true })
const settingsStore = getGlobalSettingsStore()
const currentLang = getGameLang()
if (settingsStore.get().lang !== currentLang) {
settingsStore.set({ lang: currentLang })
}
const configStore = new PluginConfigStore([plugin])
configStore.attachStorageListener()
const enabledListeners = new Set<(enabled: boolean) => void>()
const settingListeners = new Set<(key: string) => void>()
const unsubConfig = configStore.subscribe((change) => {
if (change.pluginId !== null && change.pluginId !== plugin.id) return
if (change.kind === 'enabled' || change.kind === 'reload' || change.kind === 'reset') {
const enabled = configStore.isEnabled(plugin.id)
for (const fn of [...enabledListeners]) {
fn(enabled)
}
}
if (change.kind === 'setting' && change.settingKey) {
for (const fn of [...settingListeners]) {
fn(change.settingKey)
}
} else if (change.kind === 'reload' || change.kind === 'reset') {
for (const spec of plugin.settings ?? []) {
for (const fn of [...settingListeners]) {
fn(spec.key)
}
}
}
})
const onWindowStorage = (ev: StorageEvent): void => {
if (ev.key === 'd2web.settings.v1' || ev.key === null) {
const reloaded = settingsStore.reload()
if (getGameLang() !== reloaded.lang) {
setLang(reloaded.lang)
}
} else if (ev.key === PLUGIN_CONFIG_STORAGE_KEY) {
configStore.reloadFromStorage(true)
}
}
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('storage', onWindowStorage)
}
return {
pluginId: plugin.id,
getLang(): GameLang {
return getGameLang()
},
setLang(lang: GameLang): void {
settingsStore.set({ lang })
setLang(lang)
},
subscribeLang(fn: (lang: GameLang) => void): () => void {
return onLangChange(fn)
},
isEnabled(): boolean {
return configStore.isEnabled(plugin.id)
},
subscribeEnabled(fn: (enabled: boolean) => void): () => void {
enabledListeners.add(fn)
return () => {
enabledListeners.delete(fn)
}
},
getSetting(key: string): boolean | string {
return configStore.getSetting(plugin.id, key)
},
setSetting(key: string, value: boolean | string): void {
configStore.setSetting(plugin.id, key, value)
},
subscribeSettings(fn: (key: string) => void): () => void {
settingListeners.add(fn)
return () => {
settingListeners.delete(fn)
}
},
destroy(): void {
unsubConfig()
configStore.detachStorageListener()
if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
window.removeEventListener('storage', onWindowStorage)
}
enabledListeners.clear()
settingListeners.clear()
},
}
}

View File

@ -0,0 +1,384 @@
/**
* Persistent Plugin Configuration Store (`d2web.plugins.v1`).
*
* Schema:
* `{ version: 1, plugins: { [id]: { enabled: boolean, settings: Record<string, boolean | string> } } }`
*
* Fail-fast & anti-silent-overwrite guarantees:
* - Missing plugins or setting keys use the plugin's declared defaults.
* - Unknown plugin IDs in storage are preserved untouched when structurally valid.
* - On corrupt storage (invalid JSON, wrong `version`, non-boolean `enabled`, non-object
* `settings`, invalid setting type or out-of-range `select` option):
* 1. Logs via `console.error`.
* 2. Marks the store as having a corruption error (`hasCorruptionError()`, `getCorruptionMessage()`).
* 3. Falls back to declared defaults in memory for invalid fields.
* 4. NEVER overwrites the raw corrupt `localStorage` entry until the user explicitly
* changes a setting/toggle or calls `resetAllToDefaults()`.
*/
import {
isValidPluginSettingValue,
validatePluginRegistry,
type D2WebPlugin,
type PluginSettingSpec,
} from './api.ts'
export const PLUGIN_CONFIG_STORAGE_KEY = 'd2web.plugins.v1'
export interface StoredPluginEntry {
readonly enabled: boolean
readonly settings: Record<string, boolean | string>
}
export interface StoredPluginsPayloadV1 {
readonly version: 1
readonly plugins: Record<string, StoredPluginEntry>
}
export interface PluginConfigChangeEvent {
readonly kind: 'enabled' | 'setting' | 'reset' | 'reload'
readonly pluginId: string | null
readonly settingKey?: string | undefined
}
export type PluginConfigListener = (event: PluginConfigChangeEvent) => void
export interface PluginConfigStoreOptions {
readonly storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null | undefined
}
interface MutablePluginState {
enabled: boolean
settings: Record<string, boolean | string>
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function resolveDefaultStorage(): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null {
try {
if (typeof window !== 'undefined' && window.localStorage) {
return window.localStorage
}
if (typeof localStorage !== 'undefined') {
return localStorage
}
} catch {
// Ignore sandboxed storage access errors
}
return null
}
export class PluginConfigStore {
private readonly pluginsById = new Map<string, D2WebPlugin>()
private readonly settingSpecsByPlugin = new Map<string, Map<string, PluginSettingSpec>>()
private readonly states = new Map<string, MutablePluginState>()
private readonly unknownPlugins = new Map<string, StoredPluginEntry>()
private readonly listeners = new Set<PluginConfigListener>()
private readonly storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null
private corruptionMessage: string | null = null
private storageListenerAttached = false
private readonly handleWindowStorage = (ev: StorageEvent): void => {
if (ev.key === PLUGIN_CONFIG_STORAGE_KEY || ev.key === null) {
this.reloadFromStorage(true)
}
}
constructor(plugins: readonly D2WebPlugin[], options?: PluginConfigStoreOptions) {
validatePluginRegistry(plugins)
this.storage = options?.storage !== undefined ? options.storage : resolveDefaultStorage()
for (const plugin of plugins) {
this.pluginsById.set(plugin.id, plugin)
const specMap = new Map<string, PluginSettingSpec>()
for (const spec of plugin.settings ?? []) {
specMap.set(spec.key, spec)
}
this.settingSpecsByPlugin.set(plugin.id, specMap)
}
this.reloadFromStorage(false)
}
hasCorruptionError(): boolean {
return this.corruptionMessage !== null
}
getCorruptionMessage(): string | null {
return this.corruptionMessage
}
isEnabled(pluginId: string): boolean {
const state = this.requirePluginState(pluginId)
return state.enabled
}
setEnabled(pluginId: string, enabled: boolean): void {
const state = this.requirePluginState(pluginId)
if (typeof enabled !== 'boolean') {
throw new Error(`[PluginConfigStore] enabled value for "${pluginId}" must be boolean`)
}
state.enabled = enabled
this.corruptionMessage = null
this.persistToStorage()
this.notify({ kind: 'enabled', pluginId })
}
getSetting<T extends boolean | string = boolean | string>(
pluginId: string,
key: string,
): T {
const state = this.requirePluginState(pluginId)
const specMap = this.settingSpecsByPlugin.get(pluginId)
if (!specMap || !specMap.has(key)) {
throw new Error(
`[PluginConfigStore] Unknown setting key "${key}" for plugin "${pluginId}"`,
)
}
return state.settings[key] as T
}
getAllSettings(pluginId: string): Readonly<Record<string, boolean | string>> {
const state = this.requirePluginState(pluginId)
return { ...state.settings }
}
setSetting(pluginId: string, key: string, value: boolean | string): void {
const state = this.requirePluginState(pluginId)
const spec = this.settingSpecsByPlugin.get(pluginId)?.get(key)
if (!spec) {
throw new Error(
`[PluginConfigStore] Unknown setting key "${key}" for plugin "${pluginId}"`,
)
}
if (!isValidPluginSettingValue(spec, value)) {
throw new Error(
`[PluginConfigStore] Invalid value ${JSON.stringify(value)} for setting "${pluginId}.${key}"`,
)
}
state.settings[key] = value
this.corruptionMessage = null
this.persistToStorage()
this.notify({ kind: 'setting', pluginId, settingKey: key })
}
resetAllToDefaults(): void {
this.initializeDefaultStates()
this.corruptionMessage = null
this.persistToStorage()
this.notify({ kind: 'reset', pluginId: null })
}
reloadFromStorage(notifyListeners = true): void {
this.initializeDefaultStates()
this.unknownPlugins.clear()
if (!this.storage) {
this.corruptionMessage = null
if (notifyListeners) {
this.notify({ kind: 'reload', pluginId: null })
}
return
}
let raw: string | null = null
try {
raw = this.storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)
} catch (err) {
this.recordCorruption(
`Failed to read ${PLUGIN_CONFIG_STORAGE_KEY}: ${err instanceof Error ? err.message : String(err)}`,
)
if (notifyListeners) {
this.notify({ kind: 'reload', pluginId: null })
}
return
}
if (raw === null) {
this.corruptionMessage = null
if (notifyListeners) {
this.notify({ kind: 'reload', pluginId: null })
}
return
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (err) {
this.recordCorruption(
`Corrupt JSON in ${PLUGIN_CONFIG_STORAGE_KEY}: ${err instanceof Error ? err.message : String(err)}`,
)
if (notifyListeners) {
this.notify({ kind: 'reload', pluginId: null })
}
return
}
if (!isPlainObject(parsed) || parsed.version !== 1 || !isPlainObject(parsed.plugins)) {
this.recordCorruption(
`Invalid schema in ${PLUGIN_CONFIG_STORAGE_KEY}: expected { version: 1, plugins: { ... } }`,
)
if (notifyListeners) {
this.notify({ kind: 'reload', pluginId: null })
}
return
}
let detectedIssue: string | null = null
for (const [pluginId, rawEntry] of Object.entries(parsed.plugins)) {
const registered = this.pluginsById.get(pluginId)
if (!registered) {
if (this.isValidRawUnknownEntry(rawEntry)) {
this.unknownPlugins.set(pluginId, {
enabled: rawEntry.enabled,
settings: { ...rawEntry.settings },
})
} else {
detectedIssue ??= `Corrupt plugin entry "${pluginId}" in ${PLUGIN_CONFIG_STORAGE_KEY}`
}
continue
}
if (!isPlainObject(rawEntry)) {
detectedIssue ??= `Corrupt entry for plugin "${pluginId}" in ${PLUGIN_CONFIG_STORAGE_KEY}: expected object`
continue
}
const state = this.states.get(pluginId)!
if (typeof rawEntry.enabled === 'boolean') {
state.enabled = rawEntry.enabled
} else {
detectedIssue ??= `Corrupt "enabled" field for plugin "${pluginId}" in ${PLUGIN_CONFIG_STORAGE_KEY}: expected boolean`
}
if (rawEntry.settings !== undefined) {
if (!isPlainObject(rawEntry.settings)) {
detectedIssue ??= `Corrupt "settings" field for plugin "${pluginId}" in ${PLUGIN_CONFIG_STORAGE_KEY}: expected object`
} else {
const specMap = this.settingSpecsByPlugin.get(pluginId)!
for (const [key, rawVal] of Object.entries(rawEntry.settings)) {
const spec = specMap.get(key)
if (!spec) {
if (typeof rawVal !== 'boolean' && typeof rawVal !== 'string') {
detectedIssue ??= `Corrupt setting "${pluginId}.${key}" in ${PLUGIN_CONFIG_STORAGE_KEY}`
}
continue
}
if (isValidPluginSettingValue(spec, rawVal)) {
state.settings[key] = rawVal
} else {
detectedIssue ??= `Invalid setting value for "${pluginId}.${key}" in ${PLUGIN_CONFIG_STORAGE_KEY}: ${JSON.stringify(rawVal)}`
}
}
}
}
}
if (detectedIssue !== null) {
this.recordCorruption(detectedIssue)
} else {
this.corruptionMessage = null
}
if (notifyListeners) {
this.notify({ kind: 'reload', pluginId: null })
}
}
attachStorageListener(): void {
if (this.storageListenerAttached) return
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('storage', this.handleWindowStorage)
this.storageListenerAttached = true
}
}
detachStorageListener(): void {
if (!this.storageListenerAttached) return
if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
window.removeEventListener('storage', this.handleWindowStorage)
}
this.storageListenerAttached = false
}
subscribe(listener: PluginConfigListener): () => void {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
private initializeDefaultStates(): void {
this.states.clear()
for (const [pluginId, plugin] of this.pluginsById.entries()) {
const defaults: Record<string, boolean | string> = {}
for (const spec of plugin.settings ?? []) {
defaults[spec.key] = spec.default
}
this.states.set(pluginId, {
enabled: plugin.defaultEnabled,
settings: defaults,
})
}
}
private isValidRawUnknownEntry(rawEntry: unknown): rawEntry is StoredPluginEntry {
if (!isPlainObject(rawEntry)) return false
if (typeof rawEntry.enabled !== 'boolean') return false
if (!isPlainObject(rawEntry.settings)) return false
for (const val of Object.values(rawEntry.settings)) {
if (typeof val !== 'boolean' && typeof val !== 'string') {
return false
}
}
return true
}
private recordCorruption(message: string): void {
this.corruptionMessage = message
console.error(`[PluginConfigStore] ${message}`)
}
private persistToStorage(): void {
if (!this.storage) return
const pluginsPayload: Record<string, StoredPluginEntry> = {}
for (const [unknownId, entry] of this.unknownPlugins.entries()) {
pluginsPayload[unknownId] = {
enabled: entry.enabled,
settings: { ...entry.settings },
}
}
for (const [pluginId, state] of this.states.entries()) {
pluginsPayload[pluginId] = {
enabled: state.enabled,
settings: { ...state.settings },
}
}
const payload: StoredPluginsPayloadV1 = {
version: 1,
plugins: pluginsPayload,
}
try {
this.storage.setItem(PLUGIN_CONFIG_STORAGE_KEY, JSON.stringify(payload))
} catch (err) {
console.error('[PluginConfigStore] Failed to persist plugin configuration:', err)
}
}
private requirePluginState(pluginId: string): MutablePluginState {
const state = this.states.get(pluginId)
if (!state) {
throw new Error(`[PluginConfigStore] Unknown plugin id "${pluginId}"`)
}
return state
}
private notify(event: PluginConfigChangeEvent): void {
for (const listener of [...this.listeners]) {
listener(event)
}
}
}

View File

@ -0,0 +1,664 @@
/**
* Generic Hot-Swappable Plugin Host (`src/client/plugin-host/plugin-host.ts`).
*
* Responsibilities:
* - Fail-fast startup validation via `validatePluginRegistry`.
* - Per-plugin serialized `setEnabled(id, enabled)` lifecycle queue so rapid toggles
* converge deterministically to the final state.
* - Automatic teardown of toolbar contributions (`addLink` / `addButton`) and
* setting/language subscriptions registered via `PluginContext` on deactivate.
* - Runtime error isolation in `activate`, `deactivate`, setting callbacks, action
* runners, and `ctx.log.error` (`console.error` + plugin status set to `level: 'error'`
* + toolbar plugin button displays `⚠`).
*/
import { getGameLang, onLangChange, type GameLang } from '../i18n/lang.ts'
import {
isRelativePluginHref,
isValidLocalizedText,
validatePluginRegistry,
type D2WebPlugin,
type LocalizedText,
type PluginContext,
type PluginInstance,
type PluginLogger,
type PluginSessionView,
type PluginStatus,
} from './api.ts'
import {
PluginConfigStore,
type PluginConfigStoreOptions,
} from './plugin-config-store.ts'
export type ToolbarContributionSpec =
| {
readonly domId: string
readonly kind: 'link'
readonly label: LocalizedText
readonly href: string
}
| {
readonly domId: string
readonly kind: 'button'
readonly label: LocalizedText
readonly onClick: () => void
}
export interface PluginHostToolbarPort {
setPluginsButtonState(state: {
readonly ready: boolean
readonly hasWarning: boolean
readonly open?: boolean | undefined
}): void
addContribution(spec: ToolbarContributionSpec): () => void
}
export interface PluginDescriptorSnapshot {
readonly id: string
readonly plugin: D2WebPlugin
readonly enabled: boolean
readonly active: boolean
readonly status: PluginStatus
readonly settings: Readonly<Record<string, boolean | string>>
readonly error: string | null
}
export interface PluginHostOptions {
readonly plugins: readonly D2WebPlugin[]
readonly session: PluginSessionView
readonly toolbar?: PluginHostToolbarPort | null | undefined
readonly configStore?: PluginConfigStore | undefined
readonly storageOptions?: PluginConfigStoreOptions | undefined
readonly autoStart?: boolean | undefined
}
interface InternalPluginRecord {
readonly plugin: D2WebPlugin
instance: PluginInstance | null
ctx: PluginContext | null
active: boolean
error: string | null
queue: Promise<void>
disposers: Set<() => void>
settingSubscribers: Set<(key: string) => void>
}
const KEBAB_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
export class PluginHost {
private readonly session: PluginSessionView
private readonly toolbar: PluginHostToolbarPort | null
private readonly configStore: PluginConfigStore
private readonly records = new Map<string, InternalPluginRecord>()
private readonly listeners = new Set<() => void>()
private readonly unsubConfig: () => void
private ready = false
private destroyed = false
private startPromise: Promise<void> | null = null
constructor(options: PluginHostOptions) {
validatePluginRegistry(options.plugins)
this.session = options.session
this.toolbar = options.toolbar ?? null
this.configStore =
options.configStore ??
new PluginConfigStore(options.plugins, options.storageOptions)
this.configStore.attachStorageListener()
for (const plugin of options.plugins) {
this.records.set(plugin.id, {
plugin,
instance: null,
ctx: null,
active: false,
error: null,
queue: Promise.resolve(),
disposers: new Set(),
settingSubscribers: new Set(),
})
}
this.unsubConfig = this.configStore.subscribe((event) => {
if (this.destroyed) return
if (event.kind === 'setting' && event.pluginId && event.settingKey) {
const record = this.records.get(event.pluginId)
if (record && record.active) {
this.notifySettingSubscribers(record, event.settingKey)
}
this.syncToolbarState()
this.notifyListeners()
} else if (event.kind === 'reload') {
for (const [pluginId, record] of this.records.entries()) {
const desired = this.configStore.isEnabled(pluginId)
if (desired !== record.active) {
void this.enqueueTransition(record, desired)
} else if (record.active) {
for (const spec of record.plugin.settings ?? []) {
this.notifySettingSubscribers(record, spec.key)
}
}
}
this.syncToolbarState()
this.notifyListeners()
}
})
this.syncToolbarState()
if (options.autoStart !== false) {
this.startPromise = this.start()
}
}
start(): Promise<void> {
if (this.startPromise) {
return this.startPromise
}
const tasks: Promise<void>[] = []
for (const [pluginId, record] of this.records.entries()) {
if (this.configStore.isEnabled(pluginId)) {
tasks.push(this.enqueueTransition(record, true))
}
}
this.startPromise = Promise.all(tasks).then(() => {
this.ready = true
this.syncToolbarState()
this.notifyListeners()
})
return this.startPromise
}
whenStarted(): Promise<void> {
return this.start()
}
isReady(): boolean {
return this.ready
}
getConfigStore(): PluginConfigStore {
return this.configStore
}
hasCorruptionError(): boolean {
return this.configStore.hasCorruptionError()
}
getCorruptionMessage(): string | null {
return this.configStore.getCorruptionMessage()
}
hasErrorOrCorruption(): boolean {
if (this.configStore.hasCorruptionError()) {
return true
}
for (const record of this.records.values()) {
if (record.error !== null) {
return true
}
if (record.active && record.instance?.getStatus) {
try {
if (record.instance.getStatus().level === 'error') {
return true
}
} catch (err) {
this.recordPluginError(record, 'getStatus', err)
return true
}
}
}
return false
}
list(): readonly PluginDescriptorSnapshot[] {
const out: PluginDescriptorSnapshot[] = []
for (const [pluginId, record] of this.records.entries()) {
out.push({
id: pluginId,
plugin: record.plugin,
enabled: this.configStore.isEnabled(pluginId),
active: record.active,
status: this.getStatus(pluginId),
settings: this.configStore.getAllSettings(pluginId),
error: record.error,
})
}
return out
}
getInstance<T = PluginInstance>(id: string): T | null {
const record = this.records.get(id)
if (!record || !record.active || !record.instance) {
return null
}
return record.instance as unknown as T
}
isEnabled(id: string): boolean {
this.requireRecord(id)
return this.configStore.isEnabled(id)
}
setEnabled(id: string, enabled: boolean): Promise<void> {
const record = this.requireRecord(id)
this.configStore.setEnabled(id, enabled)
this.syncToolbarState()
this.notifyListeners()
return this.enqueueTransition(record, enabled)
}
getSetting<T extends boolean | string = boolean | string>(
id: string,
key: string,
): T {
this.requireRecord(id)
return this.configStore.getSetting<T>(id, key)
}
setSetting(id: string, key: string, value: boolean | string): void {
this.requireRecord(id)
this.configStore.setSetting(id, key, value)
}
getStatus(id: string): PluginStatus {
const record = this.requireRecord(id)
if (record.error !== null) {
return {
level: 'error',
text: {
zh: `错误: ${record.error}`,
en: `Error: ${record.error}`,
},
}
}
if (!this.configStore.isEnabled(id)) {
return {
level: 'ok',
text: {
zh: '已关闭',
en: 'Disabled',
},
}
}
if (record.active && record.instance?.getStatus) {
try {
const status = record.instance.getStatus()
if (status.level === 'error') {
const zhText = status.text.zh.startsWith('错误')
? status.text.zh
: `错误: ${status.text.zh}`
const enText = status.text.en.toLowerCase().startsWith('error')
? status.text.en
: `Error: ${status.text.en}`
return {
level: 'error',
text: { zh: zhText, en: enText },
}
}
return status
} catch (err) {
this.recordPluginError(record, 'getStatus', err)
return {
level: 'error',
text: {
zh: `错误: ${record.error}`,
en: `Error: ${record.error}`,
},
}
}
}
return {
level: 'ok',
text: {
zh: '运行中',
en: 'Active',
},
}
}
runAction(id: string, actionId: string): void {
const record = this.requireRecord(id)
const action = record.plugin.actions?.find((a) => a.id === actionId)
if (!action) {
throw new Error(`[PluginHost] Unknown action "${actionId}" on plugin "${id}"`)
}
if (action.kind === 'button') {
if (!record.active || !record.ctx) {
return
}
try {
action.run(record.ctx)
} catch (err) {
this.recordPluginError(record, `action "${actionId}"`, err)
this.syncToolbarState()
this.notifyListeners()
}
}
}
async resetAllToDefaults(): Promise<void> {
this.configStore.resetAllToDefaults()
const tasks: Promise<void>[] = []
for (const [pluginId, record] of this.records.entries()) {
const targetEnabled = this.configStore.isEnabled(pluginId)
if (!targetEnabled) {
record.error = null
}
tasks.push(this.enqueueTransition(record, targetEnabled))
if (targetEnabled && record.active) {
for (const spec of record.plugin.settings ?? []) {
this.notifySettingSubscribers(record, spec.key)
}
}
}
await Promise.all(tasks)
this.syncToolbarState()
this.notifyListeners()
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
async destroy(): Promise<void> {
if (this.destroyed) return
this.destroyed = true
this.unsubConfig()
this.configStore.detachStorageListener()
const tasks: Promise<void>[] = []
for (const record of this.records.values()) {
tasks.push(this.enqueueTransition(record, false))
}
await Promise.all(tasks)
this.listeners.clear()
}
private enqueueTransition(
record: InternalPluginRecord,
targetEnabled: boolean,
): Promise<void> {
const next = record.queue.then(async () => {
if (targetEnabled && !this.destroyed) {
await this.activateRecord(record)
} else {
await this.deactivateRecord(record)
}
})
record.queue = next.catch(() => {
// Errors are isolated inside activateRecord / deactivateRecord
})
return next
}
private async activateRecord(record: InternalPluginRecord): Promise<void> {
if (record.active) {
return
}
record.error = null
this.disposeRecordResources(record)
let contextDisposed = false
const pluginId = record.plugin.id
const logger: PluginLogger = {
info: (msg: string, ...args: unknown[]): void => {
console.info(`[plugin:${pluginId}] ${msg}`, ...args)
},
warn: (msg: string, ...args: unknown[]): void => {
console.warn(`[plugin:${pluginId}] ${msg}`, ...args)
},
error: (msg: string, ...args: unknown[]): void => {
console.error(`[plugin:${pluginId}] ${msg}`, ...args)
record.error = msg
this.syncToolbarState()
this.notifyListeners()
},
}
const ctx: PluginContext = {
pluginId,
session: this.session,
settings: {
get: (key: string): boolean | string => {
return this.configStore.getSetting(pluginId, key)
},
subscribe: (fn: (key: string) => void): (() => void) => {
if (contextDisposed) {
return () => {}
}
record.settingSubscribers.add(fn)
const remove = (): void => {
record.settingSubscribers.delete(fn)
record.disposers.delete(remove)
}
record.disposers.add(remove)
return remove
},
},
toolbar: {
addLink: (spec: { id: string; label: LocalizedText; href: string }): (() => void) => {
if (!spec || typeof spec.id !== 'string' || !KEBAB_ID_RE.test(spec.id)) {
throw new Error(
`[PluginHost] Toolbar link id "${String(spec?.id)}" on plugin "${pluginId}" must be kebab-case`,
)
}
if (!isValidLocalizedText(spec.label)) {
throw new Error(
`[PluginHost] Toolbar link "${spec.id}" on plugin "${pluginId}" must have localized label`,
)
}
if (!isRelativePluginHref(spec.href)) {
throw new Error(
`[PluginHost] Toolbar link "${spec.id}" on plugin "${pluginId}" href "${String(spec.href)}" must be relative`,
)
}
if (contextDisposed) {
return () => {}
}
const domId = `d2-toolbar-plugin-${pluginId}-${spec.id}`
const removeDom =
this.toolbar?.addContribution({
domId,
kind: 'link',
label: spec.label,
href: spec.href,
}) ?? (() => {})
let removed = false
const dispose = (): void => {
if (removed) return
removed = true
record.disposers.delete(dispose)
removeDom()
}
record.disposers.add(dispose)
return dispose
},
addButton: (spec: {
id: string
label: LocalizedText
onClick(): void
}): (() => void) => {
if (!spec || typeof spec.id !== 'string' || !KEBAB_ID_RE.test(spec.id)) {
throw new Error(
`[PluginHost] Toolbar button id "${String(spec?.id)}" on plugin "${pluginId}" must be kebab-case`,
)
}
if (!isValidLocalizedText(spec.label)) {
throw new Error(
`[PluginHost] Toolbar button "${spec.id}" on plugin "${pluginId}" must have localized label`,
)
}
if (typeof spec.onClick !== 'function') {
throw new Error(
`[PluginHost] Toolbar button "${spec.id}" on plugin "${pluginId}" must provide onClick`,
)
}
if (contextDisposed) {
return () => {}
}
const domId = `d2-toolbar-plugin-${pluginId}-${spec.id}`
const removeDom =
this.toolbar?.addContribution({
domId,
kind: 'button',
label: spec.label,
onClick: () => {
try {
spec.onClick()
} catch (err) {
this.recordPluginError(record, `toolbar button "${spec.id}"`, err)
this.syncToolbarState()
this.notifyListeners()
}
},
}) ?? (() => {})
let removed = false
const dispose = (): void => {
if (removed) return
removed = true
record.disposers.delete(dispose)
removeDom()
}
record.disposers.add(dispose)
return dispose
},
},
lang: {
get: (): GameLang => getGameLang(),
subscribe: (fn: (lang: GameLang) => void): (() => void) => {
if (contextDisposed) {
return () => {}
}
const unsub = onLangChange((lang) => {
try {
fn(lang)
} catch (err) {
this.recordPluginError(record, 'lang subscriber', err)
this.syncToolbarState()
this.notifyListeners()
}
})
let removed = false
const dispose = (): void => {
if (removed) return
removed = true
record.disposers.delete(dispose)
unsub()
}
record.disposers.add(dispose)
return dispose
},
},
log: logger,
notifyStatusChanged: (): void => {
if (contextDisposed) return
this.syncToolbarState()
this.notifyListeners()
},
}
record.disposers.add(() => {
contextDisposed = true
})
try {
const instance = await record.plugin.activate(ctx)
record.ctx = ctx
record.instance = instance
record.active = true
this.syncToolbarState()
this.notifyListeners()
} catch (err) {
this.disposeRecordResources(record)
record.ctx = null
record.instance = null
record.active = false
this.recordPluginError(record, 'activate', err)
this.syncToolbarState()
this.notifyListeners()
}
}
private async deactivateRecord(record: InternalPluginRecord): Promise<void> {
const instance = record.instance
record.instance = null
record.ctx = null
record.active = false
let deactivateError: unknown = null
if (instance && typeof instance.deactivate === 'function') {
try {
await instance.deactivate()
} catch (err) {
deactivateError = err
}
}
this.disposeRecordResources(record)
if (deactivateError !== null) {
this.recordPluginError(record, 'deactivate', deactivateError)
} else {
record.error = null
}
this.syncToolbarState()
this.notifyListeners()
}
private disposeRecordResources(record: InternalPluginRecord): void {
record.settingSubscribers.clear()
for (const dispose of [...record.disposers]) {
try {
dispose()
} catch (err) {
console.error(`[plugin:${record.plugin.id}] Error during resource cleanup:`, err)
}
}
record.disposers.clear()
}
private notifySettingSubscribers(record: InternalPluginRecord, key: string): void {
for (const fn of [...record.settingSubscribers]) {
try {
fn(key)
} catch (err) {
this.recordPluginError(record, `setting subscriber (${key})`, err)
}
}
}
private recordPluginError(
record: InternalPluginRecord,
phase: string,
err: unknown,
): void {
const message = err instanceof Error ? err.message : String(err)
record.error = message
console.error(`[plugin:${record.plugin.id}] Error in ${phase}:`, err)
}
private syncToolbarState(): void {
this.toolbar?.setPluginsButtonState({
ready: this.ready,
hasWarning: this.hasErrorOrCorruption(),
})
}
private requireRecord(id: string): InternalPluginRecord {
const record = this.records.get(id)
if (!record) {
throw new Error(`[PluginHost] Unknown plugin id "${id}"`)
}
return record
}
private notifyListeners(): void {
for (const listener of [...this.listeners]) {
listener()
}
}
}

View File

@ -0,0 +1,72 @@
/**
* Bilingual (`zh` / `en`) copy for the Toolbar Plugin Configuration Modal (`PluginMenu`).
*/
import type { GameLang } from '../i18n/lang.ts'
export type PluginMenuI18nKey =
| 'pluginMenu.title'
| 'pluginMenu.subtitle'
| 'pluginMenu.close'
| 'pluginMenu.empty'
| 'pluginMenu.resetAll'
| 'pluginMenu.corruptConfig'
| 'pluginMenu.pluginErrorPrefix'
| 'pluginMenu.status.active'
| 'pluginMenu.status.disabled'
| 'pluginMenu.toggle.enable'
| 'pluginMenu.toggle.disable'
export const PLUGIN_MENU_I18N: Readonly<
Record<PluginMenuI18nKey, { readonly zh: string; readonly en: string }>
> = {
'pluginMenu.title': {
zh: '插件管理',
en: 'Plugins',
},
'pluginMenu.subtitle': {
zh: '以下均为 1.13c 原版之外的增强功能,关闭后不影响游戏本体。',
en: 'All items below are non-1.13c enhancements; disabling them leaves base gameplay untouched.',
},
'pluginMenu.close': {
zh: '关闭 (Esc)',
en: 'Close (Esc)',
},
'pluginMenu.empty': {
zh: '当前未注册任何插件。',
en: 'No plugins registered.',
},
'pluginMenu.resetAll': {
zh: '全部恢复默认',
en: 'Reset All to Defaults',
},
'pluginMenu.corruptConfig': {
zh: '插件配置存储 (d2web.plugins.v1) 损坏,已临时回退到默认设置;修改设置或点击「全部恢复默认」后将修复存储。',
en: 'Plugin configuration storage (d2web.plugins.v1) is corrupted; using default settings in memory until you change a setting or click "Reset All to Defaults".',
},
'pluginMenu.pluginErrorPrefix': {
zh: '插件运行错误:',
en: 'Plugin runtime error: ',
},
'pluginMenu.status.active': {
zh: '运行中',
en: 'Active',
},
'pluginMenu.status.disabled': {
zh: '已关闭',
en: 'Disabled',
},
'pluginMenu.toggle.enable': {
zh: '已开启',
en: 'Enabled',
},
'pluginMenu.toggle.disable': {
zh: '已关闭',
en: 'Disabled',
},
}
export function tPluginMenu(key: PluginMenuI18nKey, lang: GameLang): string {
const entry = PLUGIN_MENU_I18N[key]
return lang === 'en' ? entry.en : entry.zh
}

View File

@ -0,0 +1,174 @@
/**
* Pure View-Model Builder for the Toolbar Plugin Configuration Modal (`PluginMenu`).
*
* Projects `PluginHost` state and `GameLang` into a deterministic, DOM-free view model
* for unit testing and DOM rendering.
*/
import {
resolveLocalizedText,
type GameLang,
type PluginStatus,
} from './api.ts'
import type { PluginHost } from './plugin-host.ts'
import { tPluginMenu } from './plugin-menu-i18n.ts'
export type PluginMenuSettingControlViewModel =
| {
readonly kind: 'boolean'
readonly key: string
readonly label: string
readonly value: boolean
}
| {
readonly kind: 'select'
readonly key: string
readonly label: string
readonly value: string
readonly options: readonly {
readonly value: string
readonly label: string
}[]
}
export type PluginMenuActionViewModel =
| {
readonly kind: 'link'
readonly id: string
readonly label: string
readonly href: string
}
| {
readonly kind: 'button'
readonly id: string
readonly label: string
}
export interface PluginMenuCardViewModel {
readonly id: string
readonly name: string
readonly description: string
readonly enabled: boolean
readonly toggleLabel: string
readonly statusLevel: PluginStatus['level'] | 'disabled'
readonly statusText: string
readonly settings: readonly PluginMenuSettingControlViewModel[]
readonly actions: readonly PluginMenuActionViewModel[]
}
export interface PluginMenuViewModel {
readonly lang: GameLang
readonly title: string
readonly subtitle: string
readonly closeLabel: string
readonly resetAllLabel: string
readonly emptyText: string
readonly alertText: string | null
readonly cards: readonly PluginMenuCardViewModel[]
}
export function buildPluginMenuViewModel(
host: PluginHost,
lang: GameLang,
): PluginMenuViewModel {
const snapshots = host.list()
const alertMessages: string[] = []
if (host.hasCorruptionError()) {
const detail = host.getCorruptionMessage()
alertMessages.push(
detail
? `${tPluginMenu('pluginMenu.corruptConfig', lang)} (${detail})`
: tPluginMenu('pluginMenu.corruptConfig', lang),
)
}
const cards: PluginMenuCardViewModel[] = snapshots.map((snap) => {
const plugin = snap.plugin
const name = resolveLocalizedText(plugin.name, lang)
const description = resolveLocalizedText(plugin.description, lang)
let statusLevel: PluginMenuCardViewModel['statusLevel']
let statusText: string
if (snap.status.level === 'error') {
statusLevel = 'error'
statusText = resolveLocalizedText(snap.status.text, lang)
alertMessages.push(
`${tPluginMenu('pluginMenu.pluginErrorPrefix', lang)}${name} — ${statusText}`,
)
} else if (!snap.enabled) {
statusLevel = 'disabled'
statusText = tPluginMenu('pluginMenu.status.disabled', lang)
} else {
statusLevel = snap.status.level
statusText = resolveLocalizedText(snap.status.text, lang)
}
const settings: PluginMenuSettingControlViewModel[] = snap.enabled
? (plugin.settings ?? []).map((spec) => {
if (spec.kind === 'boolean') {
return {
kind: 'boolean',
key: spec.key,
label: resolveLocalizedText(spec.label, lang),
value: Boolean(snap.settings[spec.key]),
}
}
return {
kind: 'select',
key: spec.key,
label: resolveLocalizedText(spec.label, lang),
value: String(snap.settings[spec.key] ?? spec.default),
options: spec.options.map((opt) => ({
value: opt.value,
label: resolveLocalizedText(opt.label, lang),
})),
}
})
: []
const actions: PluginMenuActionViewModel[] = snap.enabled
? (plugin.actions ?? []).map((action) => {
if (action.kind === 'link') {
return {
kind: 'link',
id: action.id,
label: resolveLocalizedText(action.label, lang),
href: action.href,
}
}
return {
kind: 'button',
id: action.id,
label: resolveLocalizedText(action.label, lang),
}
})
: []
return {
id: plugin.id,
name,
description,
enabled: snap.enabled,
toggleLabel: snap.enabled
? tPluginMenu('pluginMenu.toggle.enable', lang)
: tPluginMenu('pluginMenu.toggle.disable', lang),
statusLevel,
statusText,
settings,
actions,
}
})
return {
lang,
title: tPluginMenu('pluginMenu.title', lang),
subtitle: tPluginMenu('pluginMenu.subtitle', lang),
closeLabel: tPluginMenu('pluginMenu.close', lang),
resetAllLabel: tPluginMenu('pluginMenu.resetAll', lang),
emptyText: tPluginMenu('pluginMenu.empty', lang),
alertText: alertMessages.length > 0 ? alertMessages.join(' | ') : null,
cards,
}
}

View File

@ -0,0 +1,617 @@
/**
* DOM Modal Configuration Menu for Plugins (`src/client/plugin-host/plugin-menu.ts`).
*
* Mounts a centered modal overlay on `document.body` (`[data-d2-modal-overlay="plugins"]`)
* with Diablo II dark theme, gold borders, `D2Exocet` / `D2Formal436` typography,
* isolated scrollable plugin card list (`overflow-y: auto`), and strict capture-phase
* keyboard isolation so `Escape` and hotkeys never leak into the game canvas or `HudManager`.
*/
import { getGameLang, onLangChange, type GameLang } from '../i18n/lang.ts'
import type { PluginHost, PluginHostToolbarPort } from './plugin-host.ts'
import { buildPluginMenuViewModel } from './plugin-menu-model.ts'
export interface PluginMenuOptions {
readonly host: PluginHost
readonly toolbar?: PluginHostToolbarPort | null | undefined
readonly container?: HTMLElement | null | undefined
}
export class PluginMenu {
private readonly host: PluginHost
private readonly toolbar: PluginHostToolbarPort | null
private readonly container: HTMLElement | null
private readonly unsubHost: () => void
private readonly unsubLang: () => void
private overlayEl: HTMLElement | null = null
private dialogEl: HTMLElement | null = null
private titleEl: HTMLElement | null = null
private subtitleEl: HTMLElement | null = null
private closeBtnEl: HTMLButtonElement | null = null
private alertEl: HTMLElement | null = null
private listEl: HTMLElement | null = null
private resetBtnEl: HTMLButtonElement | null = null
private openState = false
private readonly handleWindowKeyDown = (e: KeyboardEvent): void => {
if (!this.openState) return
if (e.key === 'Escape') {
e.preventDefault()
e.stopImmediatePropagation()
this.close()
return
}
e.stopImmediatePropagation()
}
private readonly handleWindowKeyUp = (e: KeyboardEvent): void => {
if (!this.openState) return
e.stopImmediatePropagation()
}
constructor(options: PluginMenuOptions) {
this.host = options.host
this.toolbar = options.toolbar ?? null
this.container =
options.container !== undefined
? options.container
: typeof document !== 'undefined'
? document.body
: null
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
this.mountDom()
}
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('keydown', this.handleWindowKeyDown, true)
window.addEventListener('keyup', this.handleWindowKeyUp, true)
}
this.unsubHost = this.host.subscribe(() => {
this.render()
this.syncToolbarOpenState()
})
this.unsubLang = onLangChange(() => {
this.render()
})
}
isOpen(): boolean {
return this.openState
}
open(): void {
if (this.openState) return
this.openState = true
if (this.overlayEl) {
this.overlayEl.hidden = false
}
this.render()
this.syncToolbarOpenState()
this.closeBtnEl?.focus()
}
close(): void {
if (!this.openState) return
this.openState = false
if (this.overlayEl) {
this.overlayEl.hidden = true
}
this.syncToolbarOpenState()
if (typeof document !== 'undefined') {
const btn = document.getElementById('d2-toolbar-plugins-btn')
if (btn && typeof btn.focus === 'function') {
btn.focus()
}
}
}
toggle(): boolean {
if (this.openState) {
this.close()
} else {
this.open()
}
return this.openState
}
getElement(): HTMLElement | null {
return this.overlayEl
}
destroy(): void {
this.unsubHost()
this.unsubLang()
if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
window.removeEventListener('keydown', this.handleWindowKeyDown, true)
window.removeEventListener('keyup', this.handleWindowKeyUp, true)
}
if (this.overlayEl && this.overlayEl.parentNode) {
this.overlayEl.parentNode.removeChild(this.overlayEl)
}
this.overlayEl = null
}
private syncToolbarOpenState(): void {
this.toolbar?.setPluginsButtonState({
ready: this.host.isReady(),
hasWarning: this.host.hasErrorOrCorruption(),
open: this.openState,
})
}
private ensureStyles(doc: Document): void {
if (!doc.head || doc.getElementById('d2-plugin-menu-styles')) return
const styleEl = doc.createElement('style')
styleEl.id = 'd2-plugin-menu-styles'
styleEl.textContent = `
.d2-plugin-menu-overlay {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
background: rgba(4, 3, 2, 0.78);
font-family: var(--font-body, "D2Formal436", "Noto Serif SC", "PingFang SC", Georgia, serif);
color: #e6d6b0;
}
.d2-plugin-menu-overlay[hidden] {
display: none !important;
}
.d2-plugin-menu {
width: 580px;
max-width: 96vw;
max-height: calc(100vh - 56px);
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #17130d 0%, #0b0906 100%);
border: 1px solid #9c7c42;
border-radius: 4px;
box-shadow: 0 12px 42px rgba(0, 0, 0, 0.95), inset 0 0 18px rgba(156, 124, 66, 0.14);
overflow: hidden;
}
.d2-plugin-menu__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px 8px;
border-bottom: 1px solid #4a3c24;
background: rgba(27, 21, 14, 0.92);
}
.d2-plugin-menu__title {
margin: 0;
font-family: var(--font-display, "D2Exocet", "Cinzel", Georgia, serif);
font-size: 16px;
font-weight: 700;
letter-spacing: 0.06em;
color: #dfc478;
}
.d2-plugin-menu__close {
height: 26px;
padding: 0 10px;
background: linear-gradient(180deg, #2b2316 0%, #17120b 100%);
border: 1px solid #6b5632;
border-radius: 2px;
color: #dfc991;
font-family: var(--font-display, "D2Exocet", Georgia, serif);
font-size: 11px;
cursor: pointer;
}
.d2-plugin-menu__close:hover {
border-color: #d4b46a;
color: #fff5d6;
}
.d2-plugin-menu__subtitle {
margin: 0;
padding: 8px 16px;
font-size: 12px;
color: #b5a178;
background: rgba(16, 13, 9, 0.9);
border-bottom: 1px solid #2e2517;
}
.d2-plugin-menu__alert {
margin: 10px 16px 0;
padding: 8px 12px;
background: rgba(58, 16, 14, 0.95);
border: 1px solid #c94a4a;
border-radius: 3px;
color: #ffd6d1;
font-size: 12px;
line-height: 1.4;
}
.d2-plugin-menu__alert[hidden] {
display: none !important;
}
.d2-plugin-menu__list {
flex: 1 1 auto;
min-height: 0;
max-height: 62vh;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.d2-plugin-card {
padding: 12px 14px;
background: rgba(22, 18, 13, 0.94);
border: 1px solid #4a3c24;
border-radius: 3px;
display: flex;
flex-direction: column;
gap: 8px;
}
.d2-plugin-card--error {
border-color: #b83b3b;
}
.d2-plugin-card__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.d2-plugin-card__title-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.d2-plugin-card__name {
font-family: var(--font-display, "D2Exocet", Georgia, serif);
font-size: 14px;
font-weight: 700;
color: #ebd396;
}
.d2-plugin-card__status {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 2px;
font-size: 11px;
border: 1px solid #4a3c24;
background: #14110d;
color: #c9b68a;
}
.d2-plugin-card__status--ok {
border-color: #2f6e46;
color: #7ee2a0;
}
.d2-plugin-card__status--warn {
border-color: #9e6b24;
color: #f5c26b;
}
.d2-plugin-card__status--error {
border-color: #b83b3b;
color: #ff9c9c;
}
.d2-plugin-card__status--disabled {
border-color: #463d30;
color: #948771;
}
.d2-plugin-card__toggle-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #dfc991;
cursor: pointer;
user-select: none;
}
.d2-plugin-card__desc {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: #b8a57e;
}
.d2-plugin-card__controls {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #2e2517;
}
.d2-plugin-card__setting {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 12px;
color: #d8c598;
}
.d2-plugin-card__actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.d2-plugin-card__action {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
padding: 0 10px;
background: linear-gradient(180deg, #2b2316 0%, #17120b 100%);
border: 1px solid #6b5632;
border-radius: 2px;
color: #e6d19a;
font-size: 11px;
text-decoration: none;
cursor: pointer;
}
.d2-plugin-card__action:hover {
border-color: #d4b46a;
color: #fff5d6;
}
.d2-plugin-menu__footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 10px 16px;
border-top: 1px solid #4a3c24;
background: rgba(20, 16, 11, 0.94);
}
`
doc.head.appendChild(styleEl)
}
private mountDom(): void {
const doc = document
this.ensureStyles(doc)
const overlay = doc.createElement('div')
overlay.id = 'd2-plugin-menu-overlay'
overlay.className = 'd2-plugin-menu-overlay'
overlay.setAttribute('data-d2-modal-overlay', 'plugins')
overlay.hidden = true
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this.close()
}
})
const dialog = doc.createElement('div')
dialog.id = 'd2-plugin-menu'
dialog.className = 'd2-plugin-menu'
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
dialog.setAttribute('aria-labelledby', 'd2-plugin-menu-title')
const header = doc.createElement('div')
header.className = 'd2-plugin-menu__header'
const title = doc.createElement('h2')
title.id = 'd2-plugin-menu-title'
title.className = 'd2-plugin-menu__title'
const closeBtn = doc.createElement('button')
closeBtn.type = 'button'
closeBtn.id = 'd2-plugin-menu-close'
closeBtn.className = 'd2-plugin-menu__close'
closeBtn.addEventListener('click', () => {
this.close()
})
header.appendChild(title)
header.appendChild(closeBtn)
const subtitle = doc.createElement('p')
subtitle.id = 'd2-plugin-menu-subtitle'
subtitle.className = 'd2-plugin-menu__subtitle'
const alertBanner = doc.createElement('div')
alertBanner.id = 'd2-plugin-menu-alert'
alertBanner.className = 'd2-plugin-menu__alert'
alertBanner.setAttribute('role', 'alert')
alertBanner.hidden = true
const list = doc.createElement('div')
list.id = 'd2-plugin-menu-list'
list.className = 'd2-plugin-menu__list'
const footer = doc.createElement('div')
footer.className = 'd2-plugin-menu__footer'
const resetBtn = doc.createElement('button')
resetBtn.type = 'button'
resetBtn.id = 'd2-plugin-menu-reset'
resetBtn.className = 'd2-plugin-card__action'
resetBtn.addEventListener('click', () => {
void this.host.resetAllToDefaults()
})
footer.appendChild(resetBtn)
dialog.appendChild(header)
dialog.appendChild(subtitle)
dialog.appendChild(alertBanner)
dialog.appendChild(list)
dialog.appendChild(footer)
overlay.appendChild(dialog)
if (this.container) {
this.container.appendChild(overlay)
}
this.overlayEl = overlay
this.dialogEl = dialog
this.titleEl = title
this.subtitleEl = subtitle
this.closeBtnEl = closeBtn
this.alertEl = alertBanner
this.listEl = list
this.resetBtnEl = resetBtn
this.render()
}
render(lang: GameLang = getGameLang()): void {
if (!this.overlayEl || !this.listEl) return
const doc = this.overlayEl.ownerDocument
const vm = buildPluginMenuViewModel(this.host, lang)
if (this.titleEl) {
this.titleEl.textContent = vm.title
}
if (this.subtitleEl) {
this.subtitleEl.textContent = vm.subtitle
}
if (this.closeBtnEl) {
this.closeBtnEl.textContent = vm.closeLabel
}
if (this.resetBtnEl) {
this.resetBtnEl.textContent = vm.resetAllLabel
}
if (this.alertEl) {
if (vm.alertText !== null) {
this.alertEl.hidden = false
this.alertEl.textContent = vm.alertText
} else {
this.alertEl.hidden = true
this.alertEl.textContent = ''
}
}
this.listEl.innerHTML = ''
if (vm.cards.length === 0) {
const emptyEl = doc.createElement('p')
emptyEl.className = 'd2-plugin-card__desc'
emptyEl.textContent = vm.emptyText
this.listEl.appendChild(emptyEl)
return
}
for (const card of vm.cards) {
const cardEl = doc.createElement('div')
cardEl.className =
card.statusLevel === 'error'
? 'd2-plugin-card d2-plugin-card--error'
: 'd2-plugin-card'
cardEl.setAttribute('data-plugin-id', card.id)
const topRow = doc.createElement('div')
topRow.className = 'd2-plugin-card__top'
const titleGroup = doc.createElement('div')
titleGroup.className = 'd2-plugin-card__title-group'
const nameEl = doc.createElement('span')
nameEl.className = 'd2-plugin-card__name'
nameEl.textContent = card.name
const statusBadge = doc.createElement('span')
statusBadge.className = `d2-plugin-card__status d2-plugin-card__status--${card.statusLevel}`
statusBadge.setAttribute('data-plugin-status', card.id)
statusBadge.textContent = card.statusText
titleGroup.appendChild(nameEl)
titleGroup.appendChild(statusBadge)
const toggleLabel = doc.createElement('label')
toggleLabel.className = 'd2-plugin-card__toggle-label'
const toggleInput = doc.createElement('input')
toggleInput.type = 'checkbox'
toggleInput.checked = card.enabled
toggleInput.setAttribute('data-plugin-toggle', card.id)
toggleInput.addEventListener('change', () => {
void this.host.setEnabled(card.id, toggleInput.checked)
})
const toggleText = doc.createElement('span')
toggleText.textContent = card.toggleLabel
toggleLabel.appendChild(toggleInput)
toggleLabel.appendChild(toggleText)
topRow.appendChild(titleGroup)
topRow.appendChild(toggleLabel)
const descEl = doc.createElement('p')
descEl.className = 'd2-plugin-card__desc'
descEl.textContent = card.description
cardEl.appendChild(topRow)
cardEl.appendChild(descEl)
if (card.enabled && (card.settings.length > 0 || card.actions.length > 0)) {
const controlsEl = doc.createElement('div')
controlsEl.className = 'd2-plugin-card__controls'
for (const setting of card.settings) {
const settingRow = doc.createElement('label')
settingRow.className = 'd2-plugin-card__setting'
const labelSpan = doc.createElement('span')
labelSpan.textContent = setting.label
settingRow.appendChild(labelSpan)
if (setting.kind === 'boolean') {
const checkbox = doc.createElement('input')
checkbox.type = 'checkbox'
checkbox.checked = setting.value
checkbox.setAttribute('data-plugin-setting', `${card.id}:${setting.key}`)
checkbox.addEventListener('change', () => {
this.host.setSetting(card.id, setting.key, checkbox.checked)
})
settingRow.appendChild(checkbox)
} else {
const select = doc.createElement('select')
select.className = 'd2-toolbar__select'
select.setAttribute('data-plugin-setting', `${card.id}:${setting.key}`)
for (const opt of setting.options) {
const optEl = doc.createElement('option')
optEl.value = opt.value
optEl.textContent = opt.label
select.appendChild(optEl)
}
select.value = setting.value
select.addEventListener('change', () => {
this.host.setSetting(card.id, setting.key, select.value)
})
settingRow.appendChild(select)
}
controlsEl.appendChild(settingRow)
}
if (card.actions.length > 0) {
const actionsRow = doc.createElement('div')
actionsRow.className = 'd2-plugin-card__actions'
for (const action of card.actions) {
if (action.kind === 'link') {
const link = doc.createElement('a')
link.className = 'd2-plugin-card__action'
link.href = action.href
link.target = '_blank'
link.rel = 'noopener'
link.setAttribute('data-plugin-action', `${card.id}:${action.id}`)
link.textContent = action.label
actionsRow.appendChild(link)
} else {
const btn = doc.createElement('button')
btn.type = 'button'
btn.className = 'd2-plugin-card__action'
btn.setAttribute('data-plugin-action', `${card.id}:${action.id}`)
btn.textContent = action.label
btn.addEventListener('click', () => {
this.host.runAction(card.id, action.id)
})
actionsRow.appendChild(btn)
}
}
controlsEl.appendChild(actionsRow)
}
cardEl.appendChild(controlsEl)
}
this.listEl.appendChild(cardEl)
}
}
}

View File

@ -1,57 +0,0 @@
/**
* Browser entry point for `stats.html`.
*/
import { initLangFromBrowser } from '../i18n/lang.ts'
import { getGlobalSettingsStore } from '../settings/settings-store.ts'
import type { KillStatsRepository } from './kill-stats-store.ts'
import type { KillStatsViewModel } from './kill-stats-view.ts'
import { KillStatsPage } from './stats-page.ts'
export interface StatsAppController {
readonly ready: boolean
readonly page: KillStatsPage
readonly repository: KillStatsRepository
refresh(): Promise<void>
getViewModel(): KillStatsViewModel | null
getLastExportJson(): string | null
}
declare global {
interface Window {
__d2StatsApp?: StatsAppController
}
}
async function bootStatsApp(): Promise<void> {
initLangFromBrowser()
const settingsStore = getGlobalSettingsStore()
const root =
document.getElementById('d2-stats-app') ??
(() => {
const el = document.createElement('div')
el.id = 'd2-stats-app'
document.body.appendChild(el)
return el
})()
const page = new KillStatsPage({
root,
settingsStore,
})
await page.init()
const controller: StatsAppController = {
ready: true,
page,
repository: page.repository,
refresh: () => page.refresh(),
getViewModel: () => page.getViewModel(),
getLastExportJson: () => page.getLastExportJson(),
}
window.__d2StatsApp = controller
}
void bootStatsApp()

View File

@ -5,6 +5,8 @@ import {
tToolbar, tToolbar,
type GameLang, type GameLang,
} from '../i18n/lang.ts' } from '../i18n/lang.ts'
import { resolveLocalizedText } from '../plugin-host/api.ts'
import type { ToolbarContributionSpec } from '../plugin-host/plugin-host.ts'
import { import {
CLIENT_LIGHTING_PRESET_ORDER, CLIENT_LIGHTING_PRESET_ORDER,
LIGHTING_PRESETS, LIGHTING_PRESETS,
@ -60,6 +62,7 @@ export interface ToolbarCallbacks {
readonly onAutomapRevealChange?: ((mode: AutomapRevealMode) => void) | undefined readonly onAutomapRevealChange?: ((mode: AutomapRevealMode) => void) | undefined
readonly onViewportChange?: ((profile: ViewportProfile) => void) | undefined readonly onViewportChange?: ((profile: ViewportProfile) => void) | undefined
readonly onInspectorToggle?: ((open: boolean) => void) | undefined readonly onInspectorToggle?: ((open: boolean) => void) | undefined
readonly onPluginsToggle?: (() => void) | undefined
readonly onClearCache?: (() => void | Promise<void>) | undefined readonly onClearCache?: (() => void | Promise<void>) | undefined
} }
@ -83,8 +86,8 @@ export interface ToolbarSnapshot {
} }
/** /**
* Persistent 32px Top DOM Toolbar (`#d2-toolbar`) with 5 primary controls: * Persistent 32px Top DOM Toolbar (`#d2-toolbar`) with primary controls:
* 1. Connection Status + RTT (`ms`) + Diagnostics (5-per-IP quota / WSS TLS error) + Inspector toggle * 1. Connection Status + RTT (`ms`) + Diagnostics (5-per-IP quota / WSS TLS error) + Inspector toggle + Plugins modal button (`#d2-toolbar-plugins-btn`) + Dynamic Plugin Contribution Slot (`#d2-toolbar-plugin-slot`)
* 2. Live Language Toggle (`中` / `EN`) * 2. Live Language Toggle (`中` / `EN`)
* 3. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`) * 3. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`)
* 4. Automap Reveal Selector (`off | level | act`) * 4. Automap Reveal Selector (`off | level | act`)
@ -104,6 +107,13 @@ export class Toolbar {
private serverInputValid = true private serverInputValid = true
private cacheUsageBytes = 0 private cacheUsageBytes = 0
private cacheClearedFlash = false private cacheClearedFlash = false
private pluginsReady = false
private pluginsHasWarning = false
private pluginsMenuOpen = false
private readonly contributions = new Map<
string,
{ spec: ToolbarContributionSpec; el: HTMLElement | null }
>()
private unsubscribeStore: (() => void) | null = null private unsubscribeStore: (() => void) | null = null
private unsubscribeLang: (() => void) | null = null private unsubscribeLang: (() => void) | null = null
@ -121,7 +131,8 @@ export class Toolbar {
private viewportLabelEl: HTMLElement | null = null private viewportLabelEl: HTMLElement | null = null
private viewportSelectEl: HTMLSelectElement | null = null private viewportSelectEl: HTMLSelectElement | null = null
private inspectorBtnEl: HTMLButtonElement | null = null private inspectorBtnEl: HTMLButtonElement | null = null
private killStatsLinkEl: HTMLAnchorElement | null = null private pluginsBtnEl: HTMLButtonElement | null = null
private pluginSlotEl: HTMLElement | null = null
private cacheClearBtnEl: HTMLButtonElement | null = null private cacheClearBtnEl: HTMLButtonElement | null = null
private diagBannerEl: HTMLElement | null = null private diagBannerEl: HTMLElement | null = null
@ -332,6 +343,67 @@ export class Toolbar {
return next return next
} }
setPluginsButtonState(state: {
readonly ready: boolean
readonly hasWarning: boolean
readonly open?: boolean | undefined
}): void {
this.pluginsReady = state.ready
this.pluginsHasWarning = state.hasWarning
if (state.open !== undefined) {
this.pluginsMenuOpen = state.open
}
this.syncDom()
}
addContribution(spec: ToolbarContributionSpec): () => void {
const existing = this.contributions.get(spec.domId)
if (existing) {
existing.el?.remove()
this.contributions.delete(spec.domId)
}
let el: HTMLElement | null = null
if (typeof document !== 'undefined' && this.pluginSlotEl) {
const lang = this.store.get().lang || getGameLang()
const label = resolveLocalizedText(spec.label, lang)
if (spec.kind === 'link') {
const link = document.createElement('a')
link.id = spec.domId
link.className = 'd2-toolbar__btn'
link.href = spec.href
link.target = '_blank'
link.rel = 'noopener'
link.textContent = label
link.title = label
el = link
} else {
const btn = document.createElement('button')
btn.type = 'button'
btn.id = spec.domId
btn.className = 'd2-toolbar__btn'
btn.textContent = label
btn.title = label
btn.addEventListener('click', () => {
spec.onClick()
})
el = btn
}
this.pluginSlotEl.appendChild(el)
}
const entry = { spec, el }
this.contributions.set(spec.domId, entry)
return () => {
const current = this.contributions.get(spec.domId)
if (current === entry) {
current.el?.remove()
this.contributions.delete(spec.domId)
}
}
}
setAssetCacheUsageBytes(bytes: number): void { setAssetCacheUsageBytes(bytes: number): void {
this.cacheUsageBytes = Number.isFinite(bytes) && bytes > 0 ? bytes : 0 this.cacheUsageBytes = Number.isFinite(bytes) && bytes > 0 ? bytes : 0
this.syncDom() this.syncDom()
@ -388,6 +460,7 @@ export class Toolbar {
this.unsubscribeStore = null this.unsubscribeStore = null
this.unsubscribeLang?.() this.unsubscribeLang?.()
this.unsubscribeLang = null this.unsubscribeLang = null
this.contributions.clear()
if (this.rootEl && this.rootEl.parentNode) { if (this.rootEl && this.rootEl.parentNode) {
this.rootEl.parentNode.removeChild(this.rootEl) this.rootEl.parentNode.removeChild(this.rootEl)
} }
@ -505,11 +578,19 @@ a.d2-toolbar__btn {
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
.d2-toolbar__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.d2-toolbar__btn--active { .d2-toolbar__btn--active {
background: linear-gradient(180deg, #524020 0%, #2e2310 100%); background: linear-gradient(180deg, #524020 0%, #2e2310 100%);
border-color: #d4b46a; border-color: #d4b46a;
color: #fff5d6; color: #fff5d6;
} }
.d2-toolbar__btn--warn {
border-color: #d4882a;
color: #ffb84d;
}
.d2-toolbar__diag { .d2-toolbar__diag {
position: absolute; position: absolute;
top: 32px; top: 32px;
@ -541,7 +622,7 @@ a.d2-toolbar__btn {
root.id = 'd2-toolbar' root.id = 'd2-toolbar'
root.className = 'd2-toolbar' root.className = 'd2-toolbar'
// Group 1: Connection Status + RTT + Packet Inspector button // Group 1: Connection Status + RTT + Packet Inspector button + Plugins button + Plugin Contribution Slot
const statusGroup = doc.createElement('div') const statusGroup = doc.createElement('div')
statusGroup.className = 'd2-toolbar__group' statusGroup.className = 'd2-toolbar__group'
@ -567,16 +648,24 @@ a.d2-toolbar__btn {
this.toggleInspector() this.toggleInspector()
}) })
const killStatsLink = doc.createElement('a') const pluginsBtn = doc.createElement('button')
killStatsLink.id = 'd2-toolbar-killstats' pluginsBtn.type = 'button'
killStatsLink.className = 'd2-toolbar__btn' pluginsBtn.id = 'd2-toolbar-plugins-btn'
killStatsLink.href = 'stats.html' pluginsBtn.className = 'd2-toolbar__btn'
killStatsLink.target = '_blank' pluginsBtn.disabled = !this.pluginsReady
killStatsLink.rel = 'noopener' pluginsBtn.addEventListener('click', () => {
if (!this.pluginsReady) return
this.callbacks.onPluginsToggle?.()
})
const pluginSlot = doc.createElement('div')
pluginSlot.id = 'd2-toolbar-plugin-slot'
pluginSlot.className = 'd2-toolbar__group'
statusGroup.appendChild(statusBadge) statusGroup.appendChild(statusBadge)
statusGroup.appendChild(inspectorBtn) statusGroup.appendChild(inspectorBtn)
statusGroup.appendChild(killStatsLink) statusGroup.appendChild(pluginsBtn)
statusGroup.appendChild(pluginSlot)
// Group 2: Language Toggle (中 / EN) // Group 2: Language Toggle (中 / EN)
const langGroup = doc.createElement('div') const langGroup = doc.createElement('div')
@ -688,7 +777,8 @@ a.d2-toolbar__btn {
this.viewportLabelEl = viewportLabel this.viewportLabelEl = viewportLabel
this.viewportSelectEl = viewportSelect this.viewportSelectEl = viewportSelect
this.inspectorBtnEl = inspectorBtn this.inspectorBtnEl = inspectorBtn
this.killStatsLinkEl = killStatsLink this.pluginsBtnEl = pluginsBtn
this.pluginSlotEl = pluginSlot
this.cacheClearBtnEl = cacheClearBtn this.cacheClearBtnEl = cacheClearBtn
this.diagBannerEl = diagBanner this.diagBannerEl = diagBanner
@ -715,8 +805,24 @@ a.d2-toolbar__btn {
? 'd2-toolbar__btn d2-toolbar__btn--active' ? 'd2-toolbar__btn d2-toolbar__btn--active'
: 'd2-toolbar__btn' : 'd2-toolbar__btn'
} }
if (this.killStatsLinkEl) { if (this.pluginsBtnEl) {
this.killStatsLinkEl.textContent = tToolbar('toolbar.killStats.open', lang) const baseLabel = tToolbar('toolbar.plugins.open', lang)
this.pluginsBtnEl.disabled = !this.pluginsReady
this.pluginsBtnEl.textContent = this.pluginsHasWarning ? `${baseLabel} ⚠` : baseLabel
this.pluginsBtnEl.title = baseLabel
this.pluginsBtnEl.setAttribute('aria-expanded', this.pluginsMenuOpen ? 'true' : 'false')
this.pluginsBtnEl.setAttribute('aria-haspopup', 'dialog')
let cls = 'd2-toolbar__btn'
if (this.pluginsMenuOpen) cls += ' d2-toolbar__btn--active'
if (this.pluginsHasWarning) cls += ' d2-toolbar__btn--warn'
this.pluginsBtnEl.className = cls
}
for (const entry of this.contributions.values()) {
if (entry.el) {
const label = resolveLocalizedText(entry.spec.label, lang)
entry.el.textContent = label
entry.el.title = label
}
} }
if (this.langBtnZhEl) { if (this.langBtnZhEl) {
this.langBtnZhEl.className = this.langBtnZhEl.className =

View File

@ -0,0 +1,15 @@
/**
* Modal DOM overlay detection for keyboard and input isolation (`[data-d2-modal-overlay]`).
*
* When any `[data-d2-modal-overlay]:not([hidden])` element is present in the document,
* game canvas keyboard listeners (`src/client/main.ts`) must ignore key events so modal
* interactions (such as `Escape` or hotkeys) never leak into `FrontendRenderer`,
* `CommandMapper`, or `HudManager`.
*/
export function isModalDomOverlayOpen(doc: Document | null = typeof document !== 'undefined' ? document : null): boolean {
if (!doc || typeof doc.querySelector !== 'function') {
return false
}
return doc.querySelector('[data-d2-modal-overlay]:not([hidden])') !== null
}

View File

@ -65,6 +65,7 @@ import {
type CursorAnimation, type CursorAnimation,
type CursorState, type CursorState,
} from './cursor.ts' } from './cursor.ts'
import { isModalDomOverlayOpen } from './dom-overlay.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts' import type { CharacterClassCode } from '../../common/units/classes.ts'
import { import {
detectInitialLang, detectInitialLang,
@ -1549,6 +1550,9 @@ export class HudManager {
} }
handleKeyDown(e: KeyboardEvent): void { handleKeyDown(e: KeyboardEvent): void {
if (isModalDomOverlayOpen()) {
return
}
if ( if (
typeof HTMLInputElement !== 'undefined' && typeof HTMLInputElement !== 'undefined' &&
(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement)
@ -1676,6 +1680,7 @@ export class HudManager {
window.addEventListener('keydown', (e) => this.handleKeyDown(e)) window.addEventListener('keydown', (e) => this.handleKeyDown(e))
window.addEventListener('keyup', (e) => { window.addEventListener('keyup', (e) => {
if (isModalDomOverlayOpen()) return
if (e.key === 'Alt') { if (e.key === 'Alt') {
e.preventDefault() e.preventDefault()
this.showGroundLabels = false this.showGroundLabels = false

View File

@ -3,10 +3,12 @@ import { dirname, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import * as ts from 'typescript' import * as ts from 'typescript'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import viteConfig from '../../vite.config.ts'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT_DIR = resolve(__dirname, '../..') const ROOT_DIR = resolve(__dirname, '../..')
const SRC_DIR = resolve(ROOT_DIR, 'src') const SRC_DIR = resolve(ROOT_DIR, 'src')
const PLUGINS_DIR = resolve(ROOT_DIR, 'plugins')
const TOOLS_DIR = resolve(ROOT_DIR, 'tools') const TOOLS_DIR = resolve(ROOT_DIR, 'tools')
const CANONICAL_MODULES = ['baker', 'client', 'common', 'netproto', 'server'] as const const CANONICAL_MODULES = ['baker', 'client', 'common', 'netproto', 'server'] as const
@ -40,6 +42,33 @@ function collectTsFiles(dir: string): string[] {
return results return results
} }
function collectAllFiles(dir: string): string[] {
const results: string[] = []
let entries: string[] = []
try {
entries = readdirSync(dir)
} catch {
return results
}
for (const name of entries) {
const full = resolve(dir, name)
const st = statSync(full)
if (st.isDirectory()) {
results.push(...collectAllFiles(full))
} else {
results.push(full)
}
}
return results
}
function listPluginIds(): string[] {
if (!existsSync(PLUGINS_DIR)) return []
return readdirSync(PLUGINS_DIR)
.filter((name) => statSync(resolve(PLUGINS_DIR, name)).isDirectory())
.sort()
}
function stripComments(source: string): string { function stripComments(source: string): string {
return source return source
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
@ -61,7 +90,40 @@ function extractImportSpecifiers(source: string): string[] {
return specs return specs
} }
describe('Architectural Boundaries & 5-Module Isolation (R3, F9 — Zero Whitelist Exceptions)', () => { function checkNoDynamicImportBypasses(files: readonly string[]): string[] {
const dynamicViolations: string[] = []
for (const file of files) {
const relFile = relative(ROOT_DIR, file).replace(/\\/g, '/')
const text = readFileSync(file, 'utf8')
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
const visit = (node: ts.Node) => {
if (
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword
) {
const arg = node.arguments[0]
if (!arg || !ts.isStringLiteral(arg)) {
dynamicViolations.push(`${relFile}: non-literal dynamic import(${arg?.getText(sf) ?? ''})`)
}
}
if (
ts.isNewExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'Function'
) {
const callText = node.getText(sf)
if (callText.includes('import(')) {
dynamicViolations.push(`${relFile}: new Function dynamic import bypass (${callText})`)
}
}
ts.forEachChild(node, visit)
}
visit(sf)
}
return dynamicViolations
}
describe('Architectural Boundaries & 5-Module + Plugin Isolation (Rules 1–10 — Zero Whitelist Exceptions)', () => {
it('Rule 1: src/ contains ONLY the 5 canonical module directories (common, netproto, server, client, baker)', () => { it('Rule 1: src/ contains ONLY the 5 canonical module directories (common, netproto, server, client, baker)', () => {
const entries = readdirSync(SRC_DIR).sort() const entries = readdirSync(SRC_DIR).sort()
expect(entries).toEqual([...CANONICAL_MODULES].sort()) expect(entries).toEqual([...CANONICAL_MODULES].sort())
@ -178,32 +240,37 @@ describe('Architectural Boundaries & 5-Module Isolation (R3, F9 — Zero Whiteli
expect(violations).toEqual([]) expect(violations).toEqual([])
}) })
it('Rule 7: root HTML entry points are exactly {play.html, stats.html} in vite.config.ts and all 5 legacy HTML files are deleted', () => { it('Rule 7: root HTML entry file is solely play.html, play.html composes /src/client/main.ts and /plugins/index.ts with D2Exocet/D2Formal436, and all 5 legacy HTML files remain deleted', () => {
const rootHtmlFiles = readdirSync(ROOT_DIR) const rootHtmlFiles = readdirSync(ROOT_DIR)
.filter((f) => f.endsWith('.html')) .filter((f) => f.endsWith('.html'))
.sort() .sort()
expect(rootHtmlFiles).toEqual(['play.html', 'stats.html']) expect(rootHtmlFiles).toEqual(['play.html'])
expect(existsSync(resolve(ROOT_DIR, 'stats.html'))).toBe(false)
expect(existsSync(resolve(ROOT_DIR, 'play.html'))).toBe(true) expect(existsSync(resolve(ROOT_DIR, 'play.html'))).toBe(true)
const playHtml = readFileSync(resolve(ROOT_DIR, 'play.html'), 'utf8') const playHtml = readFileSync(resolve(ROOT_DIR, 'play.html'), 'utf8')
expect(playHtml).toContain('/src/client/main.ts') expect(playHtml).toContain('/src/client/main.ts')
expect(playHtml).toContain('/plugins/index.ts')
expect(playHtml).toContain('D2Exocet') expect(playHtml).toContain('D2Exocet')
expect(playHtml).toContain('D2Formal436') expect(playHtml).toContain('D2Formal436')
expect(existsSync(resolve(ROOT_DIR, 'stats.html'))).toBe(true) const moduleScriptBlocks = [...playHtml.matchAll(/<script\b[^>]*type=["']module["'][^>]*>([\s\S]*?)<\/script>/gi)]
const statsHtml = readFileSync(resolve(ROOT_DIR, 'stats.html'), 'utf8') .map((m) => m[1] ?? '')
expect(statsHtml).toContain('/src/client/stats/stats-main.ts') const inlineImports = moduleScriptBlocks.flatMap((block) => extractImportSpecifiers(block))
expect(statsHtml).toContain('D2Exocet') expect(inlineImports).toEqual(['/src/client/main.ts', '/plugins/index.ts'])
expect(statsHtml).toContain('D2Formal436')
const viteCfg = readFileSync(resolve(ROOT_DIR, 'vite.config.ts'), 'utf8') const viteCfg = readFileSync(resolve(ROOT_DIR, 'vite.config.ts'), 'utf8')
expect(viteCfg).toContain('play.html') expect(viteCfg).toContain('play.html')
expect(viteCfg).toContain('stats.html') expect(viteCfg).not.toContain('stats.html')
for (const legacyHtml of LEGACY_HTML_FILES) { for (const legacyHtml of LEGACY_HTML_FILES) {
expect(existsSync(resolve(ROOT_DIR, legacyHtml))).toBe(false) expect(existsSync(resolve(ROOT_DIR, legacyHtml))).toBe(false)
if (legacyHtml === 'index.html') {
expect(viteCfg).not.toMatch(/resolve\(\s*__dirname\s*,\s*['"]index\.html['"]\)/)
} else {
expect(viteCfg).not.toContain(legacyHtml) expect(viteCfg).not.toContain(legacyHtml)
} }
}
}) })
it('Rule 8: src/** contains zero .test.ts/.spec.ts files, zero non-literal dynamic import(...) calls, and zero new Function("...import(...") bypasses', () => { it('Rule 8: src/** contains zero .test.ts/.spec.ts files, zero non-literal dynamic import(...) calls, and zero new Function("...import(...") bypasses', () => {
@ -213,36 +280,193 @@ describe('Architectural Boundaries & 5-Module Isolation (R3, F9 — Zero Whiteli
.filter((f) => f.endsWith('.test.ts') || f.endsWith('.spec.ts') || f.includes('__tests__')) .filter((f) => f.endsWith('.test.ts') || f.endsWith('.spec.ts') || f.includes('__tests__'))
expect(testFilesInSrc).toEqual([]) expect(testFilesInSrc).toEqual([])
const dynamicViolations: string[] = [] expect(checkNoDynamicImportBypasses(allSrcFiles)).toEqual([])
for (const file of allSrcFiles) { })
const relFile = relative(ROOT_DIR, file)
it('Rule 9: plugins/ isolation & core agnosticism (strict import whitelist, zero node:*/tests/dynamic-import in plugins/**, zero plugins/ or kill-stats references in src/**)', () => {
const pluginIds = listPluginIds()
expect(pluginIds.length).toBeGreaterThan(0)
expect(pluginIds).toContain('kill-stats')
const pluginsIndexFile = resolve(PLUGINS_DIR, 'index.ts')
expect(existsSync(pluginsIndexFile)).toBe(true)
const importViolations: string[] = []
// 1. plugins/index.ts may only import ./<id>/index.ts and src/client/plugin-host/api.ts
const allowedIndexTargets = new Set<string>([
'src/client/plugin-host/api.ts',
...pluginIds.map((id) => `plugins/${id}/index.ts`),
])
for (const spec of extractImportSpecifiers(readFileSync(pluginsIndexFile, 'utf8'))) {
if (!spec.startsWith('.')) {
importViolations.push(`plugins/index.ts: non-relative import '${spec}'`)
continue
}
const targetAbs = resolve(PLUGINS_DIR, spec)
const targetRel = relative(ROOT_DIR, targetAbs).replace(/\\/g, '/')
const resolvedRel = targetRel.endsWith('.ts') ? targetRel : `${targetRel}.ts`
if (!allowedIndexTargets.has(resolvedRel)) {
importViolations.push(
`plugins/index.ts: forbidden import '${spec}' -> '${resolvedRel}' (allowed: ./<id>/index.ts or src/client/plugin-host/api.ts)`,
)
}
}
// 2. plugins/<id>/** may only import within plugins/<id>/**, src/common/**, src/netproto/index.ts, and src/client/plugin-host/api.ts
for (const pluginId of pluginIds) {
const pluginDir = resolve(PLUGINS_DIR, pluginId)
const pluginTsFiles = collectTsFiles(pluginDir)
expect(pluginTsFiles.length, `plugins/${pluginId} must contain .ts files`).toBeGreaterThan(0)
for (const file of pluginTsFiles) {
const relFile = relative(ROOT_DIR, file).replace(/\\/g, '/')
for (const spec of extractImportSpecifiers(readFileSync(file, 'utf8'))) {
if (!spec.startsWith('.')) {
importViolations.push(`${relFile}: non-relative or package import '${spec}'`)
continue
}
const targetAbs = resolve(dirname(file), spec)
const targetRel = relative(ROOT_DIR, targetAbs).replace(/\\/g, '/')
const resolvedRel = targetRel.endsWith('.ts') ? targetRel : `${targetRel}.ts`
const isSamePlugin = resolvedRel.startsWith(`plugins/${pluginId}/`)
const isCommon = resolvedRel.startsWith('src/common/')
const isNetprotoIndex = resolvedRel === 'src/netproto/index.ts'
const isPluginHostApi = resolvedRel === 'src/client/plugin-host/api.ts'
if (!isSamePlugin && !isCommon && !isNetprotoIndex && !isPluginHostApi) {
importViolations.push(`${relFile}: forbidden import '${spec}' -> '${resolvedRel}'`)
}
}
}
}
expect(importViolations).toEqual([])
// 3. plugins/** contains zero node:* imports, zero .test.ts/.spec.ts files, and zero non-literal dynamic import(...) calls
const allPluginFiles = collectAllFiles(PLUGINS_DIR)
const testFilesInPlugins = allPluginFiles
.map((f) => relative(ROOT_DIR, f).replace(/\\/g, '/'))
.filter((f) => f.endsWith('.test.ts') || f.endsWith('.spec.ts') || f.includes('__tests__'))
expect(testFilesInPlugins).toEqual([])
const allPluginTsFiles = collectTsFiles(PLUGINS_DIR)
const nodeViolations: string[] = []
for (const file of allPluginTsFiles) {
const relFile = relative(ROOT_DIR, file).replace(/\\/g, '/')
const source = readFileSync(file, 'utf8')
for (const spec of extractImportSpecifiers(source)) {
if (
spec.startsWith('node:') ||
spec === 'fs' ||
spec === 'path' ||
spec === 'child_process' ||
spec === 'url' ||
spec === 'crypto'
) {
nodeViolations.push(`${relFile}: forbidden Node import '${spec}'`)
}
}
const stripped = stripComments(source)
if (/\bnode:(?:fs|path|url|crypto|zlib|child_process|net|os|http|https)\b/.test(stripped)) {
nodeViolations.push(`${relFile}: contains node:* string literal`)
}
}
expect(nodeViolations).toEqual([])
expect(checkNoDynamicImportBypasses(allPluginTsFiles)).toEqual([])
// 4. src/** contains zero imports or string literal references to plugins/ or 'kill-stats' (or any specific plugin ID)
const pluginIdSet = new Set(pluginIds)
const coreAgnosticismViolations: string[] = []
for (const file of collectTsFiles(SRC_DIR)) {
const relFile = relative(ROOT_DIR, file).replace(/\\/g, '/')
const text = readFileSync(file, 'utf8') const text = readFileSync(file, 'utf8')
for (const spec of extractImportSpecifiers(text)) {
if (spec.includes('plugins') || spec.includes('kill-stats')) {
coreAgnosticismViolations.push(`${relFile}: forbidden import specifier '${spec}'`)
}
}
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true) const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
const checkLiteral = (val: string) => {
if (val.includes('plugins/') || val.includes('kill-stats') || pluginIdSet.has(val)) {
coreAgnosticismViolations.push(
`${relFile}: forbidden plugin reference in string literal ${JSON.stringify(val)}`,
)
}
}
const visit = (node: ts.Node) => { const visit = (node: ts.Node) => {
if ( if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
ts.isCallExpression(node) && checkLiteral(node.text)
node.expression.kind === ts.SyntaxKind.ImportKeyword } else if (ts.isTemplateExpression(node)) {
) { checkLiteral(node.head.text)
const arg = node.arguments[0] for (const span of node.templateSpans) {
if (!arg || !ts.isStringLiteral(arg)) { checkLiteral(span.literal.text)
dynamicViolations.push(`${relFile}: non-literal dynamic import(${arg?.getText(sf) ?? ''})`)
}
}
if (
ts.isNewExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'Function'
) {
const callText = node.getText(sf)
if (callText.includes('import(')) {
dynamicViolations.push(`${relFile}: new Function dynamic import bypass (${callText})`)
} }
} }
ts.forEachChild(node, visit) ts.forEachChild(node, visit)
} }
visit(sf) visit(sf)
} }
expect(dynamicViolations).toEqual([]) expect(coreAgnosticismViolations).toEqual([])
})
it('Rule 10: every plugins/<id>/index.html declares local D2Exocet & D2Formal436 @font-face rules, contains zero http(s):// URLs, references only /plugins/<id>/... module scripts, and is auto-included in vite.config.ts build inputs', () => {
const pluginIds = listPluginIds()
const rawInput = (viteConfig.build?.rollupOptions?.input ?? {}) as Record<string, string>
expect(rawInput.play).toBe(resolve(ROOT_DIR, 'play.html'))
expect('stats' in rawInput).toBe(false)
const discoveredHtmlPages: string[] = []
for (const pluginId of pluginIds) {
const htmlPath = resolve(PLUGINS_DIR, pluginId, 'index.html')
if (!existsSync(htmlPath)) continue
discoveredHtmlPages.push(pluginId)
const relHtml = relative(ROOT_DIR, htmlPath).replace(/\\/g, '/')
const html = readFileSync(htmlPath, 'utf8')
// Local D2Exocet & D2Formal436 @font-face declarations
expect(html, `${relHtml} must declare D2Exocet @font-face`).toMatch(
/font-family:\s*['"]D2Exocet['"]/,
)
expect(html, `${relHtml} must reference /ui/fonts/D2Exocet.ttf`).toMatch(
/url\(['"]\/ui\/fonts\/D2Exocet\.ttf['"]\)/,
)
expect(html, `${relHtml} must declare D2Formal436 @font-face`).toMatch(
/font-family:\s*['"]D2Formal436['"]/,
)
expect(html, `${relHtml} must reference /ui/fonts/D2Formal436\.ttf`).toMatch(
/url\(['"]\/ui\/fonts\/D2Formal436\.ttf['"]\)/,
)
// Zero external http:// or https:// URLs
expect(html, `${relHtml} must not contain http://`).not.toContain('http://')
expect(html, `${relHtml} must not contain https://`).not.toContain('https://')
// References only /plugins/<id>/... module scripts
const scriptTags = [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
expect(scriptTags.length, `${relHtml} must contain at least one <script> tag`).toBeGreaterThan(0)
for (const [, attrs = '', body = ''] of scriptTags) {
expect(attrs, `${relHtml} script must be type="module"`).toMatch(/\btype=["']module["']/)
const srcMatch = /\bsrc=["']([^"']+)["']/.exec(attrs)
expect(srcMatch, `${relHtml} module script must have a src attribute`).not.toBeNull()
const srcAttr = srcMatch![1]!
expect(
srcAttr.startsWith(`/plugins/${pluginId}/`),
`${relHtml} script src '${srcAttr}' must start with '/plugins/${pluginId}/'`,
).toBe(true)
expect(
existsSync(resolve(ROOT_DIR, srcAttr.slice(1))),
`${relHtml} script target '${srcAttr}' must exist on disk`,
).toBe(true)
expect(body.trim(), `${relHtml} external module script must have empty body`).toBe('')
}
// Automatically included in vite.config.ts rollupOptions.input as plugin-<id>
expect(rawInput[`plugin-${pluginId}`]).toBe(htmlPath)
}
expect(discoveredHtmlPages.length).toBeGreaterThan(0)
expect(discoveredHtmlPages).toContain('kill-stats')
}) })
}) })

View File

@ -0,0 +1,386 @@
import { describe, expect, it, vi } from 'vitest'
import {
bootPluginPage,
validatePluginRegistry,
type D2WebPlugin,
type PluginContext,
type PluginSessionView,
} from '../../../src/client/plugin-host/api.ts'
import {
PLUGIN_CONFIG_STORAGE_KEY,
PluginConfigStore,
} from '../../../src/client/plugin-host/plugin-config-store.ts'
import {
PluginHost,
type PluginHostToolbarPort,
type ToolbarContributionSpec,
} from '../../../src/client/plugin-host/plugin-host.ts'
import { buildPluginMenuViewModel } from '../../../src/client/plugin-host/plugin-menu-model.ts'
import { OnlineSession } from '../../../src/client/session/online-session.ts'
import { isModalDomOverlayOpen } from '../../../src/client/ui/dom-overlay.ts'
class MemoryPluginStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
readonly data = new Map<string, string>()
setCalls = 0
getItem(key: string): string | null {
return this.data.get(key) ?? null
}
setItem(key: string, value: string): void {
this.setCalls++
this.data.set(key, value)
}
removeItem(key: string): void {
this.data.delete(key)
}
}
function makeMockSession(): PluginSessionView {
return new OnlineSession()
}
function makeMockToolbarPort(): {
toolbar: PluginHostToolbarPort
activeItems: Map<string, ToolbarContributionSpec>
buttonStates: { ready: boolean; hasWarning: boolean; open?: boolean | undefined }[]
} {
const activeItems = new Map<string, ToolbarContributionSpec>()
const buttonStates: { ready: boolean; hasWarning: boolean; open?: boolean | undefined }[] = []
const toolbar: PluginHostToolbarPort = {
setPluginsButtonState(state) {
buttonStates.push(state)
},
addContribution(spec) {
activeItems.set(spec.domId, spec)
return () => {
activeItems.delete(spec.domId)
}
},
}
return { toolbar, activeItems, buttonStates }
}
function makeValidPlugin(overrides: Partial<D2WebPlugin> = {}): D2WebPlugin {
return {
id: overrides.id ?? 'sample-plugin',
name: overrides.name ?? { zh: '示例插件', en: 'Sample Plugin' },
description: overrides.description ?? {
zh: '示例插件描述',
en: 'Sample plugin description',
},
defaultEnabled: overrides.defaultEnabled ?? true,
settings: overrides.settings ?? [
{
kind: 'boolean',
key: 'showShortcut',
label: { zh: '显示快捷方式', en: 'Show Shortcut' },
default: true,
},
{
kind: 'select',
key: 'mode',
label: { zh: '模式', en: 'Mode' },
default: 'compact',
options: [
{ value: 'compact', label: { zh: '紧凑', en: 'Compact' } },
{ value: 'full', label: { zh: '完整', en: 'Full' } },
],
},
],
actions: overrides.actions ?? [
{
kind: 'link',
id: 'open',
label: { zh: '打开页面', en: 'Open Page' },
href: 'plugins/sample-plugin/index.html',
},
],
activate:
overrides.activate ??
(() => ({
deactivate() {},
})),
}
}
describe('validatePluginRegistry (Fail-Fast Registry Validation)', () => {
it('accepts valid plugins and rejects duplicate IDs, non-kebab IDs, bad settings, and unsafe action hrefs', () => {
expect(() => validatePluginRegistry([makeValidPlugin()])).not.toThrow()
// Duplicate ID
expect(() =>
validatePluginRegistry([
makeValidPlugin({ id: 'dup-id' }),
makeValidPlugin({ id: 'dup-id' }),
]),
).toThrow(/Duplicate plugin id/i)
// Non-kebab ID
expect(() =>
validatePluginRegistry([makeValidPlugin({ id: 'Bad_Id' })]),
).toThrow(/kebab-case/i)
// Empty localized string
expect(() =>
validatePluginRegistry([
makeValidPlugin({ name: { zh: '', en: 'Sample' } }),
]),
).toThrow(/localized name/i)
// Select with empty options
expect(() =>
validatePluginRegistry([
makeValidPlugin({
settings: [
{
kind: 'select',
key: 'mode',
label: { zh: '模式', en: 'Mode' },
default: 'only',
options: [],
},
],
}),
]),
).toThrow(/non-empty options/i)
// Select default not in options
expect(() =>
validatePluginRegistry([
makeValidPlugin({
settings: [
{
kind: 'select',
key: 'mode',
label: { zh: '模式', en: 'Mode' },
default: 'missing',
options: [
{ value: 'a', label: { zh: 'A', en: 'A' } },
{ value: 'b', label: { zh: 'B', en: 'B' } },
],
},
],
}),
]),
).toThrow(/default.*not in options/i)
// External URL or leading slash in link action href
expect(() =>
validatePluginRegistry([
makeValidPlugin({
actions: [
{
kind: 'link',
id: 'open',
label: { zh: '打开', en: 'Open' },
href: 'https://evil.example.com',
},
],
}),
]),
).toThrow(/relative path/i)
expect(() =>
validatePluginRegistry([
makeValidPlugin({
actions: [
{
kind: 'link',
id: 'open',
label: { zh: '打开', en: 'Open' },
href: '/plugins/sample-plugin/index.html',
},
],
}),
]),
).toThrow(/relative path/i)
})
})
describe('PluginConfigStore (d2web.plugins.v1 Persistence & Corruption Handling)', () => {
it('flags corrupt JSON, wrong version, and invalid field types via console.error without overwriting storage until explicit user action', () => {
const plugin = makeValidPlugin()
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// 1. Corrupt JSON
const storage1 = new MemoryPluginStorage()
const rawCorrupt = '{not-valid-json'
storage1.data.set(PLUGIN_CONFIG_STORAGE_KEY, rawCorrupt)
const store1 = new PluginConfigStore([plugin], { storage: storage1 })
expect(errSpy).toHaveBeenCalled()
expect(store1.hasCorruptionError()).toBe(true)
expect(store1.getCorruptionMessage()).toContain('Corrupt JSON')
expect(store1.isEnabled('sample-plugin')).toBe(true)
expect(store1.getSetting('sample-plugin', 'mode')).toBe('compact')
// Must NOT overwrite raw corrupt localStorage entry on load
expect(storage1.setCalls).toBe(0)
expect(storage1.getItem(PLUGIN_CONFIG_STORAGE_KEY)).toBe(rawCorrupt)
// Explicit user setting change overwrites and clears corruption flag
store1.setSetting('sample-plugin', 'mode', 'full')
expect(store1.hasCorruptionError()).toBe(false)
expect(storage1.setCalls).toBe(1)
expect(store1.getSetting('sample-plugin', 'mode')).toBe('full')
// 2. Invalid field types & out-of-range select option + unknown plugin preservation
const storage2 = new MemoryPluginStorage()
storage2.data.set(
PLUGIN_CONFIG_STORAGE_KEY,
JSON.stringify({
version: 1,
plugins: {
'sample-plugin': {
enabled: 'not-a-boolean',
settings: {
showShortcut: false,
mode: 'non-existent-option',
},
},
'future-plugin': {
enabled: false,
settings: { customFlag: true },
},
},
}),
)
const store2 = new PluginConfigStore([plugin], { storage: storage2 })
expect(store2.hasCorruptionError()).toBe(true)
// Invalid enabled & mode fall back to defaults; valid showShortcut=false is kept
expect(store2.isEnabled('sample-plugin')).toBe(true)
expect(store2.getSetting('sample-plugin', 'showShortcut')).toBe(false)
expect(store2.getSetting('sample-plugin', 'mode')).toBe('compact')
expect(storage2.setCalls).toBe(0)
// Explicit toggle persists and preserves 'future-plugin'
store2.setEnabled('sample-plugin', false)
const saved = JSON.parse(storage2.getItem(PLUGIN_CONFIG_STORAGE_KEY)!) as {
version: number
plugins: Record<string, unknown>
}
expect(saved.version).toBe(1)
expect(saved.plugins['future-plugin']).toEqual({
enabled: false,
settings: { customFlag: true },
})
// resetAllToDefaults resets to defaults and preserves unknown plugins
store2.resetAllToDefaults()
expect(store2.isEnabled('sample-plugin')).toBe(true)
expect(store2.getSetting('sample-plugin', 'showShortcut')).toBe(true)
errSpy.mockRestore()
})
})
describe('PluginHost (Lifecycle, Serialized Toggles, Resource Cleanup & Error Isolation)', () => {
it('activates default-enabled plugins, auto-cleans toolbar/subscriptions on deactivate, serializes rapid toggles, and isolates errors', async () => {
const storage = new MemoryPluginStorage()
const { toolbar, activeItems } = makeMockToolbarPort()
const events: string[] = []
let resolveSlowActivate: (() => void) | null = null
let slowActivateCount = 0
let slowDeactivateCount = 0
const slowPlugin = makeValidPlugin({
id: 'slow-plugin',
defaultEnabled: true,
async activate(ctx: PluginContext) {
slowActivateCount++
events.push(`activate:${slowActivateCount}:start`)
ctx.toolbar.addLink({
id: 'open',
label: { zh: '打开', en: 'Open' },
href: 'plugins/slow-plugin/index.html',
})
if (slowActivateCount === 1) {
await new Promise<void>((resolve) => {
resolveSlowActivate = resolve
})
} else {
await Promise.resolve()
}
events.push(`activate:${slowActivateCount}:done`)
return {
async deactivate() {
slowDeactivateCount++
events.push(`deactivate:${slowDeactivateCount}`)
},
}
},
})
const faultyPlugin = makeValidPlugin({
id: 'faulty-plugin',
defaultEnabled: true,
activate() {
throw new Error('Boom during activate')
},
})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const configStore = new PluginConfigStore([slowPlugin, faultyPlugin], {
storage,
})
const pluginHost = new PluginHost({
plugins: [slowPlugin, faultyPlugin],
configStore,
session: makeMockSession(),
toolbar,
autoStart: false,
})
const startPromise = pluginHost.start()
await Promise.resolve()
// Queue rapid toggles while start() is awaiting slowPlugin.activate
const toggleOff = pluginHost.setEnabled('slow-plugin', false)
const toggleOn = pluginHost.setEnabled('slow-plugin', true)
const toggleOffFinal = pluginHost.setEnabled('slow-plugin', false)
resolveSlowActivate!()
await Promise.all([startPromise, toggleOff, toggleOn, toggleOffFinal])
expect(pluginHost.isEnabled('slow-plugin')).toBe(false)
expect(activeItems.size).toBe(0)
expect(slowDeactivateCount).toBeGreaterThanOrEqual(1)
// Faulty plugin is isolated with level === 'error'
const faultyDesc = pluginHost
.list()
.find((d) => d.plugin.id === 'faulty-plugin')
expect(faultyDesc?.status.level).toBe('error')
expect(faultyDesc?.status.text.en).toContain('Boom during activate')
expect(pluginHost.hasErrorOrCorruption()).toBe(true)
// Build menu view model in both zh and en
const vmZh = buildPluginMenuViewModel(pluginHost, 'zh')
expect(vmZh.title).toBe('插件管理')
expect(vmZh.alertText).toContain('Boom during activate')
expect(vmZh.cards).toHaveLength(2)
await pluginHost.destroy()
errSpy.mockRestore()
})
it('bootPluginPage reads language and enabled status and cleans up on destroy', () => {
const plugin = makeValidPlugin({ id: 'page-plugin', defaultEnabled: true })
const pageCtx = bootPluginPage(plugin)
expect(pageCtx.pluginId).toBe('page-plugin')
expect(pageCtx.isEnabled()).toBe(true)
pageCtx.setLang('en')
expect(pageCtx.getLang()).toBe('en')
pageCtx.setLang('zh')
expect(pageCtx.getLang()).toBe('zh')
pageCtx.destroy()
})
it('isModalDomOverlayOpen returns false in non-DOM environments', () => {
expect(isModalDomOverlayOpen(null)).toBe(false)
})
})

View File

@ -280,9 +280,9 @@ describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => {
expect(store.get().inspectorOpen).toBe(true) expect(store.get().inspectorOpen).toBe(true)
expect(inspectorSpy).toHaveBeenCalledWith(true) expect(inspectorSpy).toHaveBeenCalledWith(true)
// Kill Stats link i18n // Plugins button i18n
expect(tToolbar('toolbar.killStats.open', 'zh')).toBe('击杀统计') expect(tToolbar('toolbar.plugins.open', 'zh')).toBe('插件')
expect(tToolbar('toolbar.killStats.open', 'en')).toBe('Kill Stats') expect(tToolbar('toolbar.plugins.open', 'en')).toBe('Plugins')
toolbar.destroy() toolbar.destroy()
setLang('zh') setLang('zh')

View File

@ -610,13 +610,19 @@ describe('Tier 1 — Feature Coverage: Module Split & Single Entry Consolidation
} }
}) })
it('F8.3: configures vite.config.ts with play.html and stats.html rollup inputs and zero legacy HTML inputs', () => { it('F8.3: configures vite.config.ts with play.html and auto-discovered plugin HTML rollup inputs and zero legacy HTML inputs', () => {
const viteCfg = readRepoFile('vite.config.ts') const viteCfg = readRepoFile('vite.config.ts')
expect(viteCfg).toContain('play.html') expect(viteCfg).toContain('play.html')
expect(viteCfg).toContain('stats.html') expect(viteCfg).toContain('plugins')
expect(viteCfg).toContain('index.html')
expect(viteCfg).not.toContain('stats.html')
for (const legacyHtml of LEGACY_HTML_FILES) { for (const legacyHtml of LEGACY_HTML_FILES) {
if (legacyHtml === 'index.html') {
expect(viteCfg).not.toMatch(/resolve\(\s*__dirname\s*,\s*['"]index\.html['"]\)/)
} else {
expect(viteCfg).not.toContain(legacyHtml) expect(viteCfg).not.toContain(legacyHtml)
} }
}
}) })
it('F8.4: deletes obsolete entry glue files (src/main.ts, src/walk.ts, bnet-main.ts, act-scene.ts, net-scene.ts, src/net/)', () => { it('F8.4: deletes obsolete entry glue files (src/main.ts, src/walk.ts, bnet-main.ts, act-scene.ts, net-scene.ts, src/net/)', () => {

View File

@ -617,9 +617,14 @@ describe('Tier 2: F7 — Unified play.html Single-Entry & Legacy Entry Purge Ver
it('F7.B4: vite.config.ts contains zero references to deleted HTML entry files', () => { it('F7.B4: vite.config.ts contains zero references to deleted HTML entry files', () => {
const viteSource = stripComments(readRepoFile('vite.config.ts')) const viteSource = stripComments(readRepoFile('vite.config.ts'))
expect(viteSource).not.toContain('stats.html')
for (const legacyHtml of LEGACY_HTML_FILES) { for (const legacyHtml of LEGACY_HTML_FILES) {
if (legacyHtml === 'index.html') {
expect(viteSource).not.toMatch(/resolve\(\s*__dirname\s*,\s*['"]index\.html['"]\)/)
} else {
expect(viteSource).not.toContain(legacyHtml) expect(viteSource).not.toContain(legacyHtml)
} }
}
}) })
it('F7.B5: src/client/main.ts contains zero imports from src/server, src/baker, or legacy src/* directories', () => { it('F7.B5: src/client/main.ts contains zero imports from src/server, src/baker, or legacy src/* directories', () => {

View File

@ -199,7 +199,7 @@ describe('Tier 4: Real-World End-to-End Application Scenarios', () => {
} }
expect(dagViolations).toEqual([]) expect(dagViolations).toEqual([])
// 3. Run tsc --noEmit on all 6 tsconfigs asynchronously so worker event loop stays responsive // 3. Run tsc --noEmit on all 6 tsconfigs asynchronously
const tscBin = resolve(ROOT_DIR, 'node_modules/.bin/tsc') const tscBin = resolve(ROOT_DIR, 'node_modules/.bin/tsc')
const configs = [ const configs = [
'tsconfig.common.json', 'tsconfig.common.json',
@ -209,16 +209,20 @@ describe('Tier 4: Real-World End-to-End Application Scenarios', () => {
'tsconfig.baker.json', 'tsconfig.baker.json',
'tsconfig.json', 'tsconfig.json',
] ]
for (const cfg of configs) { const results = await Promise.all(
const { stdout } = await execFileAsync(tscBin, ['--noEmit', '-p', cfg], { configs.map((cfg) =>
execFileAsync(tscBin, ['--noEmit', '-p', cfg], {
cwd: ROOT_DIR, cwd: ROOT_DIR,
encoding: 'utf8', encoding: 'utf8',
}) }),
),
)
for (const { stdout } of results) {
expect(stdout.trim()).toBe('') expect(stdout.trim()).toBe('')
} }
}, 180_000) }, 300_000)
it('Scenario 4: Unified play.html & stats.html Vite Production Bundle Build Verification (R2)', async () => { it('Scenario 4: Unified play.html & Plugin Pages Vite Production Bundle Build Verification (R2)', async () => {
const tmpOutDir = mkdtempSync(join(tmpdir(), 'd2-vite-e2e-build-')) const tmpOutDir = mkdtempSync(join(tmpdir(), 'd2-vite-e2e-build-'))
try { try {
const viteBin = resolve(ROOT_DIR, 'node_modules/.bin/vite') const viteBin = resolve(ROOT_DIR, 'node_modules/.bin/vite')
@ -228,7 +232,8 @@ describe('Tier 4: Real-World End-to-End Application Scenarios', () => {
}) })
expect(existsSync(join(tmpOutDir, 'play.html'))).toBe(true) expect(existsSync(join(tmpOutDir, 'play.html'))).toBe(true)
expect(existsSync(join(tmpOutDir, 'stats.html'))).toBe(true) expect(existsSync(join(tmpOutDir, 'plugins/kill-stats/index.html'))).toBe(true)
expect(existsSync(join(tmpOutDir, 'stats.html'))).toBe(false)
for (const legacyHtml of LEGACY_HTML_FILES) { for (const legacyHtml of LEGACY_HTML_FILES) {
expect( expect(
existsSync(join(tmpOutDir, legacyHtml)), existsSync(join(tmpOutDir, legacyHtml)),

View File

@ -4,7 +4,7 @@
*/ */
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs' import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { import {
D2FontRenderer, D2FontRenderer,
@ -223,7 +223,16 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
}) })
it('registers local @font-face D2Exocet and D2Formal436 across HTML pages with zero external CDN links', () => { it('registers local @font-face D2Exocet and D2Formal436 across HTML pages with zero external CDN links', () => {
const htmlFiles = ['play.html', 'stats.html'] const pluginsDir = join(process.cwd(), 'plugins')
const pluginHtmlFiles = existsSync(pluginsDir)
? readdirSync(pluginsDir)
.sort()
.filter((name) => statSync(join(pluginsDir, name)).isDirectory())
.map((name) => join('plugins', name, 'index.html'))
.filter((relPath) => existsSync(join(process.cwd(), relPath)))
: []
expect(pluginHtmlFiles.length).toBeGreaterThan(0)
const htmlFiles = ['play.html', ...pluginHtmlFiles]
for (const file of htmlFiles) { for (const file of htmlFiles) {
const content = readFileSync(join(process.cwd(), file), 'utf8') const content = readFileSync(join(process.cwd(), file), 'utf8')
expect(content, `${file} must declare D2Exocet @font-face`).toMatch(/font-family:\s*['"]D2Exocet['"]/) expect(content, `${file} must declare D2Exocet @font-face`).toMatch(/font-family:\s*['"]D2Exocet['"]/)

View File

@ -19,8 +19,8 @@ import {
type KillStatsSnapshot, type KillStatsSnapshot,
type KillTallyDelta, type KillTallyDelta,
type KillTallyRecord, type KillTallyRecord,
} from '../../src/client/stats/kill-stats-model.ts' } from '../../../plugins/kill-stats/kill-stats-model.ts'
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts' import { MemoryKillStatsRepository } from './memory-repository.ts'
const REPLICA_A = '11111111-1111-4111-8111-111111111111' const REPLICA_A = '11111111-1111-4111-8111-111111111111'
const REPLICA_B = '22222222-2222-4222-8222-222222222222' const REPLICA_B = '22222222-2222-4222-8222-222222222222'

View File

@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { OnlineSession } from '../../src/client/session/online-session.ts' import { OnlineSession } from '../../../src/client/session/online-session.ts'
import { computeCharKey } from '../../src/client/stats/kill-stats-model.ts' import { computeCharKey } from '../../../plugins/kill-stats/kill-stats-model.ts'
import { KillTracker } from '../../src/client/stats/kill-tracker.ts' import { KillTracker } from '../../../plugins/kill-stats/kill-tracker.ts'
import { encodeD2gsCompressedFrame } from '../../src/netproto/d2gs/compression.ts' import { encodeD2gsCompressedFrame } from '../../../src/netproto/d2gs/compression.ts'
import { BitWriter, ByteWriter } from '../../src/netproto/index.ts' import { BitWriter, ByteWriter } from '../../../src/netproto/index.ts'
import { createFakeRealm, type FakeRealm } from '../netproto/fake-realm-servers.ts' import { createFakeRealm, type FakeRealm } from '../../netproto/fake-realm-servers.ts'
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts' import { MemoryKillStatsRepository } from './memory-repository.ts'
function sendD2gsPacket(realm: FakeRealm, rawPacket: Uint8Array): void { function sendD2gsPacket(realm: FakeRealm, rawPacket: Uint8Array): void {
const gameConn = realm.latest('game') const gameConn = realm.latest('game')

View File

@ -5,14 +5,14 @@ import {
type KillCharacterRecord, type KillCharacterRecord,
type KillStatsSnapshot, type KillStatsSnapshot,
type KillTallyRecord, type KillTallyRecord,
} from '../../src/client/stats/kill-stats-model.ts' } from '../../../plugins/kill-stats/kill-stats-model.ts'
import { import {
BOSS_SU_BOARD_ROW_COUNT, BOSS_SU_BOARD_ROW_COUNT,
buildKillStatsViewModel, buildKillStatsViewModel,
getBossSuperUniqueBoardCatalog, getBossSuperUniqueBoardCatalog,
resolveCharacterClassName, resolveCharacterClassName,
resolveLocalizedMonsterName, resolveLocalizedMonsterName,
} from '../../src/client/stats/kill-stats-view.ts' } from '../../../plugins/kill-stats/kill-stats-view.ts'
const REPLICA_A = '11111111-1111-4111-8111-111111111111' const REPLICA_A = '11111111-1111-4111-8111-111111111111'
const REPLICA_B = '22222222-2222-4222-8222-222222222222' const REPLICA_B = '22222222-2222-4222-8222-222222222222'

View File

@ -1,25 +1,26 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { OnlineSession } from '../../src/client/session/online-session.ts' import type { PluginSessionView } from '../../../src/client/plugin-host/api.ts'
import { OnlineSession } from '../../../src/client/session/online-session.ts'
import { import {
computeCharKey, computeCharKey,
type KillCharacterProfile, type KillCharacterProfile,
type KillStatsBroadcastMessage, type KillStatsBroadcastMessage,
} from '../../src/client/stats/kill-stats-model.ts' } from '../../../plugins/kill-stats/kill-stats-model.ts'
import { import {
KillTracker, KillTracker,
resolveSessionIdentity, resolveSessionIdentity,
type BroadcastChannelLike, type BroadcastChannelLike,
type KillTrackerScheduler, type KillTrackerScheduler,
type KillTrackerWindowLike, type KillTrackerWindowLike,
} from '../../src/client/stats/kill-tracker.ts' } from '../../../plugins/kill-stats/kill-tracker.ts'
import type { import type {
ClientCommand, ClientCommand,
CloseReason, CloseReason,
GameServerAdapter, GameServerAdapter,
GameServerStats, GameServerStats,
ServerEvent, ServerEvent,
} from '../../src/netproto/index.ts' } from '../../../src/netproto/index.ts'
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts' import { MemoryKillStatsRepository } from './memory-repository.ts'
class FakeGameServerAdapter implements GameServerAdapter { class FakeGameServerAdapter implements GameServerAdapter {
state: 'loading' | 'ingame' | 'closed' = 'ingame' state: 'loading' | 'ingame' | 'closed' = 'ingame'
@ -798,8 +799,157 @@ describe('KillTracker: Boss / SuperUnique Classification, Solo vs Party, Guards
h.tracker.destroy() h.tracker.destroy()
}) })
it('resolveSessionIdentity returns null when any of (host, realm, account, charName) is missing', () => { it('resolveSessionIdentity fails fast (never defaults to Amazon Lv1 SC) when 4-tuple, hasGameFlags, charClass, or level is missing', async () => {
const session = new OnlineSession() const emptySession = new OnlineSession()
expect(resolveSessionIdentity(session)).toBeNull() expect(resolveSessionIdentity(emptySession)).toEqual({
ok: false,
reason: 'incomplete character identity (host, realm, account, charName)',
})
const baseSnapshot = emptySession.getSnapshot()
const makeMockSessionView = (overrides: {
charClass?: number | null
matchedCharClass?: number | undefined
level?: number | null
matchedLevel?: number | undefined
}): PluginSessionView => {
const eventCbs = new Set<(ev: ServerEvent) => void>()
return {
phase: 'ingame',
loginHost: 'www.laiseek.xyz',
getSnapshot: () => ({
...baseSnapshot,
phase: 'ingame',
username: 'DeckardCain',
selectedRealm: {
id: 1,
title: 'D2CS',
description: 'Realm',
},
realms: [],
characters: [
{
name: 'SorcHero',
charClass: overrides.matchedCharClass as never,
level: overrides.matchedLevel as never,
act: 1,
difficulty: 2,
hardcore: true,
expansion: true,
ladder: true,
dead: false,
expiresAt: 0,
equipmentGraphics: new Uint8Array(0),
rawStatString: new Uint8Array(0),
},
],
selectedChar: 'SorcHero',
}),
world: {
difficulty: 2,
hardcore: true,
expansion: true,
ladder: true,
self: {
unitId: 1,
charClass: (overrides.charClass ?? null) as never,
level: (overrides.level ?? null) as never,
name: 'SorcHero',
},
party: new Map(),
} as never,
onServerEvent: (cb) => {
eventCbs.add(cb)
return () => eventCbs.delete(cb)
},
onStateChange: () => () => {},
}
}
// 1. hasGameFlags === false -> fails fast
const validView = makeMockSessionView({ charClass: 1, level: 88 })
expect(resolveSessionIdentity(validView, false)).toEqual({
ok: false,
reason: 'GameFlags (0x01) difficulty has not been received in this game',
})
// 2. Missing charClass (both self.charClass === null and matchedChar.charClass === undefined) -> fails fast
const missingClassView = makeMockSessionView({
charClass: null,
matchedCharClass: undefined,
level: 88,
})
expect(resolveSessionIdentity(missingClassView, true)).toEqual({
ok: false,
reason: 'missing or invalid character class (expected integer 0..6)',
})
// 3. Missing level (both self.level === null and matchedChar.level === 0) -> fails fast
const missingLevelView = makeMockSessionView({
charClass: 1,
level: null,
matchedLevel: 0,
})
expect(resolveSessionIdentity(missingLevelView, true)).toEqual({
ok: false,
reason: 'missing or invalid character level (expected finite integer >= 1)',
})
// 4. Valid authoritative metadata -> succeeds with exact profile
const resolved = resolveSessionIdentity(validView, true)
expect(resolved).toEqual({
ok: true,
profile: {
charKey: computeCharKey('www.laiseek.xyz', 'D2CS', 'DeckardCain', 'SorcHero'),
host: 'www.laiseek.xyz',
realm: 'D2CS',
account: 'DeckardCain',
charName: 'SorcHero',
charClass: 1,
level: 88,
hardcore: true,
expansion: true,
ladder: true,
},
})
// 5. KillTracker with missing charClass skips recording, logs once via log.warn, and sets lastSkipReason
const warnCalls: unknown[][] = []
const repo = new MemoryKillStatsRepository()
let emitEvent: ((ev: ServerEvent) => void) | null = null
const viewWithMissingClass: PluginSessionView = {
...missingClassView,
onServerEvent: (cb) => {
emitEvent = cb
return () => {}
},
}
const tracker = new KillTracker({
session: viewWithMissingClass,
repository: repo,
log: {
info: () => {},
warn: (...args) => warnCalls.push(args),
error: () => {},
},
})
emitEvent!({
type: 'GameFlags',
difficulty: 2,
hardcore: true,
expansion: true,
ladder: true,
})
emitEvent!({
type: 'UnitAssign',
unit: { unitType: 1, id: 901, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
})
emitEvent!({ type: 'UnitMode', unitType: 1, id: 901, mode: 0 })
expect(warnCalls).toHaveLength(1)
expect(tracker.getStatus().pendingKills).toBe(0)
expect(tracker.getStatus().lastSkipReason).toContain('character class')
await tracker.flushNow()
expect((await repo.loadAll()).characters).toHaveLength(0)
tracker.destroy()
}) })
}) })

View File

@ -1,6 +1,6 @@
/** /**
* In-memory `KillStatsRepository` implementation for unit & integration tests. * In-memory `KillStatsRepository` implementation for unit & integration tests.
* Reuses pure functions from `src/client/stats/kill-stats-model.ts`. * Reuses pure functions from `plugins/kill-stats/kill-stats-model.ts`.
*/ */
import { import {
@ -14,12 +14,12 @@ import {
type KillStatsImportSummary, type KillStatsImportSummary,
type KillStatsSnapshot, type KillStatsSnapshot,
type KillTallyDelta, type KillTallyDelta,
} from '../../../src/client/stats/kill-stats-model.ts' } from '../../../plugins/kill-stats/kill-stats-model.ts'
import { import {
normalizeApplyBatchArgs, normalizeApplyBatchArgs,
type KillStatsBatchInput, type KillStatsBatchInput,
type KillStatsRepository, type KillStatsRepository,
} from '../../../src/client/stats/kill-stats-store.ts' } from '../../../plugins/kill-stats/kill-stats-store.ts'
export interface MemoryKillStatsRepositoryOptions { export interface MemoryKillStatsRepositoryOptions {
readonly initialReplicaId?: string | undefined readonly initialReplicaId?: string | undefined

View File

@ -1,18 +1,18 @@
import { beforeAll, describe, expect, it } from 'vitest' import { beforeAll, describe, expect, it } from 'vitest'
import * as fs from 'node:fs' import * as fs from 'node:fs'
import { MountedArchives } from '../../src/common/mpq/mount.ts' import { MountedArchives } from '../../../src/common/mpq/mount.ts'
import { MpqArchive } from '../../src/common/mpq/archive.ts' import { MpqArchive } from '../../../src/common/mpq/archive.ts'
import { fileSource } from '../../src/baker/io/file-source.ts' import { fileSource } from '../../../src/baker/io/file-source.ts'
import { cell, parseTable, type D2Table } from '../../src/common/world/acts.ts' import { cell, parseTable, type D2Table } from '../../../src/common/world/acts.ts'
import { getMonsterKindByClassId } from '../../src/common/units/monsters-data.ts' import { getMonsterKindByClassId } from '../../../src/common/units/monsters-data.ts'
import { resolveTblKey } from '../../src/common/data/canonical-tbl-strings.ts' import { resolveTblKey } from '../../../src/common/data/canonical-tbl-strings.ts'
import { import {
MONSTATS_ALIGN_NONZERO_HCIDX, MONSTATS_ALIGN_NONZERO_HCIDX,
MONSTATS_MAX_HCIDX, MONSTATS_MAX_HCIDX,
MONSTATS_NEVER_COUNT_HCIDX, MONSTATS_NEVER_COUNT_HCIDX,
isCountableBossClass, isCountableBossClass,
isCountableKillClass, isCountableKillClass,
} from '../../src/common/units/monster-kill-classes.ts' } from '../../../plugins/kill-stats/monster-kill-classes.ts'
const d2Dir = fs.existsSync('samples/d2/Patch_D2.mpq') const d2Dir = fs.existsSync('samples/d2/Patch_D2.mpq')
? 'samples/d2' ? 'samples/d2'

View File

@ -0,0 +1,231 @@
import { readdirSync, statSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { BUILTIN_PLUGINS } from '../../plugins/index.ts'
import {
createKillStatsPlugin,
killStatsPlugin,
} from '../../plugins/kill-stats/index.ts'
import {
validatePluginRegistry,
type PluginActionSpec,
type PluginSessionView,
type PluginSettingSpec,
} from '../../src/client/plugin-host/api.ts'
import { PluginConfigStore } from '../../src/client/plugin-host/plugin-config-store.ts'
import {
PluginHost,
type PluginHostToolbarPort,
} from '../../src/client/plugin-host/plugin-host.ts'
import {
OnlineSession,
type OnlineSessionLobbySnapshot,
type OnlineSessionPhase,
} from '../../src/client/session/online-session.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import type { ServerEvent } from '../../src/netproto/index.ts'
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts'
class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
private readonly map = new Map<string, string>()
getItem(key: string): string | null {
return this.map.get(key) ?? null
}
setItem(key: string, value: string): void {
this.map.set(key, value)
}
removeItem(key: string): void {
this.map.delete(key)
}
}
describe('BUILTIN_PLUGINS Registry & killStatsPlugin Lifecycle', () => {
it('validates BUILTIN_PLUGINS and ensures every plugins/<id>/ directory is registered', () => {
expect(() => validatePluginRegistry(BUILTIN_PLUGINS)).not.toThrow()
const pluginsDir = resolve(process.cwd(), 'plugins')
const dirIds = readdirSync(pluginsDir)
.filter((name) => statSync(resolve(pluginsDir, name)).isDirectory())
.sort()
const registeredIds = BUILTIN_PLUGINS.map((p) => p.id).sort()
expect(registeredIds).toEqual(dirIds)
expect(BUILTIN_PLUGINS).toContain(killStatsPlugin)
})
it('manages #d2-toolbar-plugin-kill-stats-open shortcut, reports mid-game warn/recording/ready statuses, and flushes pending kills on deactivate', async () => {
const repository = new MemoryKillStatsRepository({
initialReplicaId: '11111111-1111-4111-8111-111111111111',
})
const plugin = createKillStatsPlugin({
repository,
})
const baseSnapshot = new OnlineSession().getSnapshot()
let phase: OnlineSessionPhase = 'disconnected'
const eventListeners = new Set<(ev: ServerEvent) => void>()
const stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>()
const world = new ClientWorld()
world.difficulty = 2
world.hardcore = true
world.expansion = true
world.ladder = true
world.self.unitId = 1
world.self.charClass = 1
world.self.level = 90
world.self.name = 'SorcHero'
const getSnapshot = (): OnlineSessionLobbySnapshot => ({
...baseSnapshot,
phase,
username: 'DeckardCain',
selectedRealm: {
id: 1,
title: 'D2CS',
description: 'Realm',
},
realms: [],
characters: [
{
name: 'SorcHero',
charClass: 1,
level: 90,
act: 1,
difficulty: 2,
hardcore: true,
expansion: true,
ladder: true,
dead: false,
expiresAt: 0,
equipmentGraphics: new Uint8Array(0),
rawStatString: new Uint8Array(0),
},
],
selectedChar: 'SorcHero',
})
const session: PluginSessionView = {
get phase() {
return phase
},
loginHost: 'www.laiseek.xyz',
getSnapshot,
world,
onServerEvent(cb) {
eventListeners.add(cb)
return () => eventListeners.delete(cb)
},
onStateChange(cb) {
stateListeners.add(cb)
return () => stateListeners.delete(cb)
},
}
const toolbarLinks = new Map<string, string>()
const toolbarPort: PluginHostToolbarPort = {
setPluginsButtonState() {},
addContribution(spec) {
if (spec.kind === 'link') {
toolbarLinks.set(spec.domId, spec.href)
}
return () => {
toolbarLinks.delete(spec.domId)
}
},
}
const configStore = new PluginConfigStore([plugin], {
storage: new MemoryStorage(),
})
const host = new PluginHost({
plugins: [plugin],
configStore,
session,
toolbar: toolbarPort,
autoStart: false,
})
await host.start()
// 1. Default enabled + showToolbarShortcut = true -> link registered, status Ready (not in game)
expect(toolbarLinks.get('d2-toolbar-plugin-kill-stats-open')).toBe(
'plugins/kill-stats/index.html',
)
expect(host.list()[0]?.status).toEqual({
level: 'ok',
text: {
zh: '就绪(未在游戏中)',
en: 'Ready (not in game)',
},
})
// 2. Toggle showToolbarShortcut off and back on
host.setSetting('kill-stats', 'showToolbarShortcut', false)
expect(toolbarLinks.has('d2-toolbar-plugin-kill-stats-open')).toBe(false)
host.setSetting('kill-stats', 'showToolbarShortcut', true)
expect(toolbarLinks.has('d2-toolbar-plugin-kill-stats-open')).toBe(true)
// 3. Enter game before GameFlags (0x01) arrives -> status is 'warn' (Waiting for next game)
phase = 'ingame'
for (const cb of stateListeners) cb(getSnapshot())
expect(host.list()[0]?.status.level).toBe('warn')
expect(host.list()[0]?.status.text.zh).toContain('等待下一局')
// 4. Emit GameFlags (0x01) -> status transitions to 'ok' (Recording / 记录中)
for (const cb of eventListeners) {
cb({
type: 'GameFlags',
difficulty: 2,
hardcore: true,
expansion: true,
ladder: true,
})
}
expect(host.list()[0]?.status).toEqual({
level: 'ok',
text: {
zh: '记录中',
en: 'Recording',
},
})
// 5. Record a kill (unflushed in 2s batch buffer), then disable plugin -> deactivate() flushes immediately!
for (const cb of eventListeners) {
cb({
type: 'UnitAssign',
unit: {
unitType: 1,
id: 500,
classId: 156, // Andariel
x: 5000,
y: 5000,
lifePct: 128,
mode: 1,
},
})
cb({
type: 'UnitMode',
unitType: 1,
id: 500,
mode: 0,
})
}
await host.setEnabled('kill-stats', false)
expect(toolbarLinks.has('d2-toolbar-plugin-kill-stats-open')).toBe(false)
const snap = await repository.loadAll()
expect(snap.characters).toHaveLength(1)
expect(snap.tallies.map((t) => `${t.kind}:${t.subject}=${t.count}`).sort()).toEqual([
'boss:156=1',
'monster:156=1',
])
// Verify setting and action declarations
const settingSpecs: readonly PluginSettingSpec[] = plugin.settings ?? []
const actionSpecs: readonly PluginActionSpec[] = plugin.actions ?? []
expect(settingSpecs.map((s) => s.key)).toEqual(['showToolbarShortcut'])
expect(actionSpecs.map((a) => a.id)).toEqual(['open-page'])
await host.destroy()
})
})

View File

@ -244,7 +244,7 @@ async function run(): Promise<void> {
if (!port) throw new Error('Failed to start Vite server') if (!port) throw new Error('Failed to start Vite server')
const playUrl = `http://127.0.0.1:${port}/play.html` const playUrl = `http://127.0.0.1:${port}/play.html`
const statsUrl = `http://127.0.0.1:${port}/stats.html` const statsUrl = `http://127.0.0.1:${port}/plugins/kill-stats/index.html`
console.log(`[verify-kill-stats] Vite server listening at http://127.0.0.1:${port}`) console.log(`[verify-kill-stats] Vite server listening at http://127.0.0.1:${port}`)
chromeProc = spawn( chromeProc = spawn(
@ -311,33 +311,45 @@ async function run(): Promise<void> {
playCdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl) playCdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl)
await playCdp.send('Page.navigate', { url: playUrl }) await playCdp.send('Page.navigate', { url: playUrl })
// 1. Wait for play.html boot and verify #d2-toolbar-killstats // 1. Wait for play.html boot + PluginHost ready and verify #d2-toolbar-plugin-kill-stats-open
for (let i = 0; i < 120; i++) { for (let i = 0; i < 120; i++) {
await sleep(200) await sleep(200)
const ready = await playCdp.evaluate<boolean>('Boolean(window.__d2PlayApp && window.__d2PlayApp.ready)') const ready = await playCdp.evaluate<boolean>(
'Boolean(window.__d2PlayApp && window.__d2PlayApp.ready && window.__d2PlayApp.plugins)',
)
if (ready) break if (ready) break
} }
const playReady = await playCdp.evaluate<boolean>('Boolean(window.__d2PlayApp && window.__d2PlayApp.ready)') const playReady = await playCdp.evaluate<boolean>(
'Boolean(window.__d2PlayApp && window.__d2PlayApp.ready && window.__d2PlayApp.plugins)',
)
assert(playReady, 'play.html __d2PlayApp failed to reach ready=true') assert(playReady, 'play.html __d2PlayApp failed to reach ready=true')
await playCdp.evaluate('window.__d2PlayApp.plugins.whenStarted()')
const toolbarLink = await playCdp.evaluate<{ const toolbarLink = await playCdp.evaluate<{
exists: boolean exists: boolean
legacyExists: boolean
href: string href: string
target: string target: string
text: string text: string
}>(`(() => { }>(`(() => {
const el = document.getElementById('d2-toolbar-killstats'); const el = document.getElementById('d2-toolbar-plugin-kill-stats-open');
const legacyEl = document.getElementById('d2-toolbar-killstats');
return { return {
exists: Boolean(el), exists: Boolean(el),
legacyExists: Boolean(legacyEl),
href: el?.getAttribute('href') ?? '', href: el?.getAttribute('href') ?? '',
target: el?.getAttribute('target') ?? '', target: el?.getAttribute('target') ?? '',
text: el?.textContent?.trim() ?? '', text: el?.textContent?.trim() ?? '',
}; };
})()`) })()`)
assert(toolbarLink.exists, '#d2-toolbar-killstats must exist in play.html toolbar') assert(toolbarLink.exists, '#d2-toolbar-plugin-kill-stats-open must exist in play.html toolbar')
assert(toolbarLink.href.endsWith('stats.html'), `Expected href ending with stats.html, got ${toolbarLink.href}`) assert(!toolbarLink.legacyExists, 'Legacy #d2-toolbar-killstats must not exist in play.html toolbar')
assert(
toolbarLink.href === 'plugins/kill-stats/index.html',
`Expected href="plugins/kill-stats/index.html", got ${toolbarLink.href}`,
)
assert(toolbarLink.target === '_blank', `Expected target="_blank", got ${toolbarLink.target}`) assert(toolbarLink.target === '_blank', `Expected target="_blank", got ${toolbarLink.target}`)
console.log(`[verify-kill-stats] Verified play.html toolbar link: href="${toolbarLink.href}" target="${toolbarLink.target}" text="${toolbarLink.text}"`) console.log(`[verify-kill-stats] Verified play.html plugin toolbar link: href="${toolbarLink.href}" target="${toolbarLink.target}" text="${toolbarLink.text}"`)
// 2. Drive OnlineSession + KillTracker in play.html with simulated ServerEvents // 2. Drive OnlineSession + KillTracker in play.html with simulated ServerEvents
await playCdp.evaluate(`(async () => { await playCdp.evaluate(`(async () => {
@ -521,10 +533,10 @@ async function run(): Promise<void> {
}); });
emit({ type: 'UnitMode', unitType: 'monster', id: 104, mode: 12, x: 5040, y: 5040, lifePct: 0 }); emit({ type: 'UnitMode', unitType: 'monster', id: 104, mode: 12, x: 5040, y: 5040, lifePct: 0 });
await app.killStats.flushNow(); await app.plugins.getInstance('kill-stats').tracker.flushNow();
})()`) })()`)
// 3. Open stats.html in a second tab in the same browser context // 3. Open plugins/kill-stats/index.html in a second tab in the same browser context
const statsTarget = await openNewPageTarget(cdpPort, statsUrl) const statsTarget = await openNewPageTarget(cdpPort, statsUrl)
statsCdp = await CdpSession.connect(statsTarget.webSocketDebuggerUrl) statsCdp = await CdpSession.connect(statsTarget.webSocketDebuggerUrl)
@ -534,13 +546,15 @@ async function run(): Promise<void> {
if (ready) break if (ready) break
} }
const statsReady = await statsCdp.evaluate<boolean>('Boolean(window.__d2StatsApp && window.__d2StatsApp.ready)') const statsReady = await statsCdp.evaluate<boolean>('Boolean(window.__d2StatsApp && window.__d2StatsApp.ready)')
assert(statsReady, 'stats.html __d2StatsApp failed to reach ready=true') assert(statsReady, 'plugins/kill-stats/index.html __d2StatsApp failed to reach ready=true')
// 4. Verify isolated scroll layout, sidebar tree, summary cards, Boss/SU board, and Monster Type table // 4. Verify isolated scroll layout, sidebar tree, summary cards, Boss/SU board, and Monster Type table
const initialSnap = await statsCdp.evaluate<{ const initialSnap = await statsCdp.evaluate<{
outerOverflow: string outerOverflow: string
sidebarOverflowY: string sidebarOverflowY: string
mainOverflowY: string mainOverflowY: string
backLinkHref: string
disabledNoticeHidden: boolean
sidebarText: string sidebarText: string
hasHeroSorc: boolean hasHeroSorc: boolean
valNormal: string valNormal: string
@ -554,6 +568,8 @@ async function run(): Promise<void> {
const outer = document.querySelector('.d2-stats-shell') || document.getElementById('d2-stats-app'); const outer = document.querySelector('.d2-stats-shell') || document.getElementById('d2-stats-app');
const sidebar = document.querySelector('.d2-stats__sidebar'); const sidebar = document.querySelector('.d2-stats__sidebar');
const main = document.querySelector('.d2-stats__main'); const main = document.querySelector('.d2-stats__main');
const backLink = document.getElementById('d2-stats-back-link');
const disabledNotice = document.getElementById('d2-stats-disabled-notice');
const andarielRow = document.querySelector('tr[data-canonical-id="andariel"]'); const andarielRow = document.querySelector('tr[data-canonical-id="andariel"]');
const pindleskinRow = document.querySelector('tr[data-canonical-id="Pindleskin"]'); const pindleskinRow = document.querySelector('tr[data-canonical-id="Pindleskin"]');
const fallenRow = document.querySelector('tr[data-monster-hcidx="19"]'); const fallenRow = document.querySelector('tr[data-monster-hcidx="19"]');
@ -561,6 +577,8 @@ async function run(): Promise<void> {
outerOverflow: outer ? getComputedStyle(outer).overflow : '', outerOverflow: outer ? getComputedStyle(outer).overflow : '',
sidebarOverflowY: sidebar ? getComputedStyle(sidebar).overflowY : '', sidebarOverflowY: sidebar ? getComputedStyle(sidebar).overflowY : '',
mainOverflowY: main ? getComputedStyle(main).overflowY : '', mainOverflowY: main ? getComputedStyle(main).overflowY : '',
backLinkHref: backLink?.getAttribute('href') ?? '',
disabledNoticeHidden: Boolean(disabledNotice && disabledNotice.hidden),
sidebarText: sidebar?.textContent ?? '', sidebarText: sidebar?.textContent ?? '',
hasHeroSorc: Boolean(document.querySelector('.d2-stats__char-btn[data-char-name="HeroSorc"]')), hasHeroSorc: Boolean(document.querySelector('.d2-stats__char-btn[data-char-name="HeroSorc"]')),
valNormal: document.getElementById('d2-stats-val-normal')?.textContent?.trim() ?? '', valNormal: document.getElementById('d2-stats-val-normal')?.textContent?.trim() ?? '',
@ -576,6 +594,8 @@ async function run(): Promise<void> {
assert(initialSnap.outerOverflow === 'hidden', `Expected .d2-stats overflow: hidden, got ${initialSnap.outerOverflow}`) assert(initialSnap.outerOverflow === 'hidden', `Expected .d2-stats overflow: hidden, got ${initialSnap.outerOverflow}`)
assert(initialSnap.sidebarOverflowY === 'auto', `Expected .d2-stats__sidebar overflow-y: auto, got ${initialSnap.sidebarOverflowY}`) assert(initialSnap.sidebarOverflowY === 'auto', `Expected .d2-stats__sidebar overflow-y: auto, got ${initialSnap.sidebarOverflowY}`)
assert(initialSnap.mainOverflowY === 'auto', `Expected .d2-stats__main overflow-y: auto, got ${initialSnap.mainOverflowY}`) assert(initialSnap.mainOverflowY === 'auto', `Expected .d2-stats__main overflow-y: auto, got ${initialSnap.mainOverflowY}`)
assert(initialSnap.backLinkHref === '../../play.html', `Expected #d2-stats-back-link href="../../play.html", got ${initialSnap.backLinkHref}`)
assert(initialSnap.disabledNoticeHidden, 'Expected #d2-stats-disabled-notice to be hidden when kill-stats is enabled')
assert(initialSnap.sidebarText.includes('myacct'), `Sidebar must include account name myacct: ${initialSnap.sidebarText}`) assert(initialSnap.sidebarText.includes('myacct'), `Sidebar must include account name myacct: ${initialSnap.sidebarText}`)
assert(initialSnap.hasHeroSorc, 'Sidebar must include character button for HeroSorc') assert(initialSnap.hasHeroSorc, 'Sidebar must include character button for HeroSorc')
assert(initialSnap.valNormal === '0', `Expected Normal=0, got ${initialSnap.valNormal}`) assert(initialSnap.valNormal === '0', `Expected Normal=0, got ${initialSnap.valNormal}`)
@ -663,7 +683,7 @@ async function run(): Promise<void> {
}, },
}); });
emit({ type: 'UnitMode', unitType: 'monster', id: 105, mode: 12, x: 5050, y: 5050, lifePct: 0 }); emit({ type: 'UnitMode', unitType: 'monster', id: 105, mode: 12, x: 5050, y: 5050, lifePct: 0 });
await window.__d2PlayApp.killStats.flushNow(); await window.__d2PlayApp.plugins.getInstance('kill-stats').tracker.flushNow();
})()`) })()`)
let liveTotal = '' let liveTotal = ''

View File

@ -0,0 +1,579 @@
#!/usr/bin/env npx tsx
/**
* Headless Chrome CDP End-to-End Browser Verification for the Plugin System & Modal Menu:
* - Verifies `#d2-toolbar-plugins-btn` opens `[data-d2-modal-overlay="plugins"]`
* - Verifies bilingual (`zh` / `en`) rendering of plugin cards, status badges, settings, and actions
* - Verifies toggling `showToolbarShortcut` (`[data-plugin-setting="kill-stats:showToolbarShortcut"]`) dynamically hides/shows `#d2-toolbar-plugin-kill-stats-open`
* - Verifies disabling `kill-stats` (`[data-plugin-toggle="kill-stats"]`):
* - Removes `#d2-toolbar-plugin-kill-stats-open`
* - Stops recording simulated monster kills while disabled
* - Displays `#d2-stats-disabled-notice` on `/plugins/kill-stats/index.html`
* - Verifies re-enabling `kill-stats`:
* - Live-updates `/plugins/kill-stats/index.html` via `storage` event to hide `#d2-stats-disabled-notice`
* - Resumes recording simulated monster kills
* - Verifies pressing `Escape` while the plugin modal is open closes the modal without triggering in-game Esc menu or HUD hotkeys
* - Verifies zero console errors and zero uncaught page exceptions
*/
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createServer, type ViteDevServer } from 'vite'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT_DIR = resolve(__dirname, '..')
const CHROME_CANDIDATES = [
join(process.env.HOME ?? '', '.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'),
'/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome',
'/usr/bin/google-chrome',
]
function findChromeBinary(): string {
for (const cand of CHROME_CANDIDATES) {
if (cand && existsSync(cand)) return cand
}
throw new Error(`No headless Chrome binary found in candidates: ${CHROME_CANDIDATES.join(', ')}`)
}
function parseOutDir(): string {
const args = process.argv.slice(2)
for (let i = 0; i < args.length; i++) {
const arg = args[i]!
if (arg === '--out' && args[i + 1]) return resolve(args[i + 1]!)
if (arg.startsWith('--out=')) return resolve(arg.slice('--out='.length))
}
return resolve(ROOT_DIR, 'scratch/verify-plugin-menu')
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
function assert(cond: unknown, message: string): asserts cond {
if (!cond) {
throw new Error(`Assertion failed: ${message}`)
}
}
class CdpSession {
private readonly ws: WebSocket
private msgId = 0
private readonly pending = new Map<
number,
{ readonly resolve: (val: any) => void; readonly reject: (err: Error) => void }
>()
readonly consoleErrors: string[] = []
readonly pageExceptions: string[] = []
private constructor(ws: WebSocket) {
this.ws = ws
this.ws.onmessage = (ev) => {
const msg = JSON.parse(String(ev.data))
if (typeof msg.id === 'number') {
const entry = this.pending.get(msg.id)
if (entry) {
this.pending.delete(msg.id)
if (msg.error) {
entry.reject(new Error(msg.error.message || JSON.stringify(msg.error)))
} else {
entry.resolve(msg.result)
}
}
return
}
if (msg.method === 'Runtime.consoleAPICalled') {
const type = msg.params?.type
if (type === 'error') {
const text = (msg.params?.args ?? [])
.map((a: any) => a.value ?? a.description ?? '')
.join(' ')
if (!text.includes('favicon.ico') && !text.includes('404')) {
this.consoleErrors.push(text)
}
}
} else if (msg.method === 'Runtime.exceptionThrown') {
const details = msg.params?.exceptionDetails
const desc =
details?.exception?.description ?? details?.text ?? JSON.stringify(details)
this.pageExceptions.push(String(desc))
}
}
}
static async connect(wsUrl: string): Promise<CdpSession> {
const ws = new WebSocket(wsUrl)
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve()
ws.onerror = (err) => reject(new Error(`CDP WebSocket error: ${String(err)}`))
})
const cdp = new CdpSession(ws)
await cdp.send('Runtime.enable')
await cdp.send('Page.enable')
return cdp
}
send(method: string, params: Record<string, unknown> = {}): Promise<any> {
const id = ++this.msgId
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
this.ws.send(JSON.stringify({ id, method, params }))
})
}
async evaluate<T = any>(expression: string): Promise<T> {
const res = await this.send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
})
if (res?.exceptionDetails) {
const desc =
res.exceptionDetails.exception?.description ??
res.exceptionDetails.text ??
JSON.stringify(res.exceptionDetails)
throw new Error(`CDP evaluate exception: ${String(desc)}`)
}
return res?.result?.value as T
}
async captureScreenshot(outPath: string): Promise<number> {
const shot = await this.send('Page.captureScreenshot', { format: 'png' })
const data = shot?.data
if (typeof data !== 'string' || data.length === 0) {
throw new Error('Failed to capture PNG screenshot via CDP')
}
const buf = Buffer.from(data, 'base64')
mkdirSync(dirname(outPath), { recursive: true })
writeFileSync(outPath, buf)
return buf.byteLength
}
async close(): Promise<void> {
try {
this.ws.close()
} catch {
// ignore
}
}
}
async function openNewPageTarget(
cdpPort: string,
targetUrl: string,
): Promise<{ readonly id: string; readonly webSocketDebuggerUrl: string }> {
const res = await fetch(
`http://127.0.0.1:${cdpPort}/json/new?${encodeURIComponent(targetUrl)}`,
{ method: 'PUT' },
)
if (!res.ok) {
throw new Error(`Failed to open new tab for ${targetUrl}: HTTP ${res.status}`)
}
return (await res.json()) as { id: string; webSocketDebuggerUrl: string }
}
async function run(): Promise<void> {
const chromeBin = findChromeBinary()
const outDir = parseOutDir()
mkdirSync(outDir, { recursive: true })
const publicDir = process.env.D2W_PUBLIC_DIR
? resolve(process.env.D2W_PUBLIC_DIR)
: resolve(ROOT_DIR, 'public')
const tilesIndexPath = join(publicDir, 'act-packs/tiles/index.json')
if (!existsSync(tilesIndexPath)) {
throw new Error(
`Missing required packed tiles at ${tilesIndexPath}. Set D2W_PUBLIC_DIR=/usr/local/google/home/taodao/diablo2-web/public`,
)
}
let viteServer: ViteDevServer | null = null
const userDataDir = mkdtempSync(join(tmpdir(), 'd2-verify-plugin-menu-'))
let chromeProc: ReturnType<typeof spawn> | null = null
let playCdp: CdpSession | null = null
let statsCdp: CdpSession | null = null
try {
viteServer = await createServer({
root: ROOT_DIR,
publicDir,
server: {
host: '127.0.0.1',
port: 0,
strictPort: false,
watch: null,
},
logLevel: 'error',
})
await viteServer.listen()
const addr = viteServer.httpServer?.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
if (!port) throw new Error('Failed to start Vite server')
const playUrl = `http://127.0.0.1:${port}/play.html`
const statsUrl = `http://127.0.0.1:${port}/plugins/kill-stats/index.html`
console.log(`[verify-plugin-menu] Vite server listening at http://127.0.0.1:${port}`)
chromeProc = spawn(
chromeBin,
[
'--headless=new',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu-sandbox',
'--use-angle=swiftshader',
'--use-gl=angle',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-backgrounding-occluded-windows',
'--no-proxy-server',
'--password-store=basic',
'--disable-extensions',
'--no-first-run',
'--no-default-browser-check',
'--force-device-scale-factor=1',
'--window-size=1440,960',
`--user-data-dir=${userDataDir}`,
'--remote-debugging-port=0',
'about:blank',
],
{ stdio: ['ignore', 'ignore', 'pipe'] },
)
let cdpPort: string | null = null
for (let i = 0; i < 100; i++) {
await sleep(150)
try {
const raw = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8')
const firstLine = raw.trim().split('\n')[0]
if (firstLine) {
cdpPort = firstLine
break
}
} catch {
// wait
}
}
if (!cdpPort) throw new Error('Chrome failed to start DevToolsActivePort')
let pageTarget: { readonly url: string; readonly webSocketDebuggerUrl: string } | null = null
for (let i = 0; i < 120; i++) {
await sleep(150)
try {
const res = await fetch(`http://127.0.0.1:${cdpPort}/json`)
const list = (await res.json()) as any[]
const pages = list.filter((p) => p.type === 'page')
if (pages.length > 0 && pages[0]) {
pageTarget = pages[0]
break
}
} catch {
// wait
}
}
if (!pageTarget) throw new Error('Failed to attach to page target in headless Chrome')
playCdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl)
await playCdp.send('Page.navigate', { url: playUrl })
for (let i = 0; i < 120; i++) {
await sleep(200)
const ready = await playCdp.evaluate<boolean>(
'Boolean(window.__d2PlayApp && window.__d2PlayApp.ready && window.__d2PlayApp.plugins)',
)
if (ready) break
}
const playReady = await playCdp.evaluate<boolean>(
'Boolean(window.__d2PlayApp && window.__d2PlayApp.ready && window.__d2PlayApp.plugins)',
)
assert(playReady, 'play.html __d2PlayApp failed to reach ready=true')
await playCdp.evaluate('window.__d2PlayApp.plugins.whenStarted()')
// 1. Open plugin modal menu via #d2-toolbar-plugins-btn
const openStateZh = await playCdp.evaluate<{
btnExists: boolean
btnDisabled: boolean
btnText: string
modalOpen: boolean
cardExists: boolean
shortcutExists: boolean
titleText: string
}>(`(() => {
const btn = document.getElementById('d2-toolbar-plugins-btn');
btn?.click();
const overlay = document.querySelector('[data-d2-modal-overlay="plugins"]');
const card = document.querySelector('[data-plugin-id="kill-stats"]');
const shortcut = document.getElementById('d2-toolbar-plugin-kill-stats-open');
return {
btnExists: Boolean(btn),
btnDisabled: Boolean(btn?.disabled),
btnText: btn?.textContent?.trim() ?? '',
modalOpen: Boolean(overlay && !overlay.hasAttribute('hidden')),
cardExists: Boolean(card),
shortcutExists: Boolean(shortcut),
titleText: overlay?.querySelector('.d2-plugin-menu__title')?.textContent?.trim() ?? '',
};
})()`)
assert(openStateZh.btnExists, '#d2-toolbar-plugins-btn must exist')
assert(!openStateZh.btnDisabled, '#d2-toolbar-plugins-btn must be enabled after PluginHost starts')
assert(openStateZh.modalOpen, 'Plugin modal overlay must be open after clicking #d2-toolbar-plugins-btn')
assert(openStateZh.cardExists, 'Plugin card [data-plugin-id="kill-stats"] must exist')
assert(openStateZh.shortcutExists, '#d2-toolbar-plugin-kill-stats-open must exist initially')
assert(openStateZh.titleText === '插件管理', `Expected Chinese title "插件管理", got "${openStateZh.titleText}"`)
const shot1 = join(outDir, '01-plugin-menu-open-zh.png')
const shot1Bytes = await playCdp.captureScreenshot(shot1)
console.log(`[verify-plugin-menu] Captured ${shot1} (${shot1Bytes} bytes) — Plugin menu open (ZH)`)
// 2. Toggle language to English and verify English labels in plugin modal
await playCdp.evaluate(`(() => {
document.getElementById('d2-toolbar-lang-en')?.click();
})()`)
const openStateEn = await playCdp.evaluate<{
titleText: string
btnText: string
resetText: string
}>(`(() => {
const overlay = document.querySelector('[data-d2-modal-overlay="plugins"]');
return {
titleText: overlay?.querySelector('.d2-plugin-menu__title')?.textContent?.trim() ?? '',
btnText: document.getElementById('d2-toolbar-plugins-btn')?.textContent?.trim() ?? '',
resetText: document.getElementById('d2-plugin-menu-reset')?.textContent?.trim() ?? '',
};
})()`)
assert(openStateEn.titleText === 'Plugins', `Expected English title "Plugins", got "${openStateEn.titleText}"`)
assert(openStateEn.btnText.includes('Plugins'), `Expected toolbar button "Plugins", got "${openStateEn.btnText}"`)
assert(openStateEn.resetText === 'Reset All to Defaults', `Expected "Reset All to Defaults", got "${openStateEn.resetText}"`)
const shot2 = join(outDir, '02-plugin-menu-open-en.png')
const shot2Bytes = await playCdp.captureScreenshot(shot2)
console.log(`[verify-plugin-menu] Captured ${shot2} (${shot2Bytes} bytes) — Plugin menu open (EN)`)
// Switch back to Chinese
await playCdp.evaluate(`(() => {
document.getElementById('d2-toolbar-lang-zh')?.click();
})()`)
// 3. Toggle showToolbarShortcut off -> verify #d2-toolbar-plugin-kill-stats-open disappears, then toggle back on
const afterShortcutOff = await playCdp.evaluate<boolean>(`(() => {
const cb = document.querySelector('[data-plugin-setting="kill-stats:showToolbarShortcut"]');
cb?.click();
return Boolean(document.getElementById('d2-toolbar-plugin-kill-stats-open'));
})()`)
assert(!afterShortcutOff, '#d2-toolbar-plugin-kill-stats-open must be removed when showToolbarShortcut is unchecked')
const shot3 = join(outDir, '03-plugin-shortcut-hidden.png')
const shot3Bytes = await playCdp.captureScreenshot(shot3)
console.log(`[verify-plugin-menu] Captured ${shot3} (${shot3Bytes} bytes) — Toolbar shortcut hidden`)
const afterShortcutOn = await playCdp.evaluate<boolean>(`(() => {
const cb = document.querySelector('[data-plugin-setting="kill-stats:showToolbarShortcut"]');
cb?.click();
return Boolean(document.getElementById('d2-toolbar-plugin-kill-stats-open'));
})()`)
assert(afterShortcutOn, '#d2-toolbar-plugin-kill-stats-open must reappear when showToolbarShortcut is checked')
// 4. Attach fake game server, disable kill-stats, verify kills are NOT recorded while disabled, and verify #d2-stats-disabled-notice in stats page
await playCdp.evaluate(`(async () => {
const app = window.__d2PlayApp;
const session = app.session;
session.loginHostValue = 'www.laiseek.xyz';
session.usernameValue = 'pluginacct';
session.selectedRealmValue = { title: 'D2CS', description: 'Realm' };
session.selectedCharValue = 'PluginSorc';
session.charactersValue = [
{
name: 'PluginSorc',
charClass: 1,
level: 88,
hardcore: true,
expansion: true,
ladder: true,
rawFlags: 0,
act: 5,
},
];
let emitServerEvent = null;
const fakeAdapter = {
isConnected: true,
stats: { packetsIn: 0, packetsOut: 0, bytesIn: 0, bytesOut: 0, rttMs: 12, unknownPackets: 0 },
connect: async () => {},
disconnect: () => {},
send: () => {},
onEvent: (cb) => {
emitServerEvent = cb;
return () => { emitServerEvent = null; };
},
};
session.attachGameServer(fakeAdapter);
window.__d2EmitPluginTestEvent = (ev) => emitServerEvent && emitServerEvent(ev);
// Toggle kill-stats off via DOM checkbox
const toggle = document.querySelector('[data-plugin-toggle="kill-stats"]');
toggle?.click();
await app.plugins.setEnabled('kill-stats', false);
})()`)
const disabledShortcutExists = await playCdp.evaluate<boolean>(
`Boolean(document.getElementById('d2-toolbar-plugin-kill-stats-open'))`,
)
assert(!disabledShortcutExists, '#d2-toolbar-plugin-kill-stats-open must be removed when kill-stats is disabled')
// Emit monster kill while kill-stats is disabled
await playCdp.evaluate(`(() => {
const emit = window.__d2EmitPluginTestEvent;
emit({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
emit({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 'player', id: 1, classId: 1, name: 'PluginSorc', x: 5000, y: 5000, mode: 1, lifePct: 128 },
});
emit({
type: 'UnitAssign',
unit: { unitType: 'monster', id: 301, classId: 156, x: 5010, y: 5010, mode: 1, lifePct: 128, isBoss: true },
});
emit({ type: 'UnitMode', unitType: 'monster', id: 301, mode: 0, x: 5010, y: 5010, lifePct: 0 });
})()`)
// Open /plugins/kill-stats/index.html in second tab and verify #d2-stats-disabled-notice is visible & Total=0
const statsTarget = await openNewPageTarget(cdpPort, statsUrl)
statsCdp = await CdpSession.connect(statsTarget.webSocketDebuggerUrl)
for (let i = 0; i < 100; i++) {
await sleep(150)
const ready = await statsCdp.evaluate<boolean>('Boolean(window.__d2StatsApp && window.__d2StatsApp.ready)')
if (ready) break
}
const statsDisabledState = await statsCdp.evaluate<{
noticeVisible: boolean
noticeText: string
valTotal: string
}>(`(() => {
const notice = document.getElementById('d2-stats-disabled-notice');
return {
noticeVisible: Boolean(notice && !notice.hidden),
noticeText: notice?.textContent?.trim() ?? '',
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
};
})()`)
assert(statsDisabledState.noticeVisible, '#d2-stats-disabled-notice must be visible when kill-stats is disabled')
assert(statsDisabledState.valTotal === '0', `Expected Total=0 while kill-stats was disabled, got ${statsDisabledState.valTotal}`)
const shot4 = join(outDir, '04-stats-disabled-notice.png')
const shot4Bytes = await statsCdp.captureScreenshot(shot4)
console.log(`[verify-plugin-menu] Captured ${shot4} (${shot4Bytes} bytes) — Disabled notice visible on stats page`)
// 5. Re-enable kill-stats in play.html -> verify #d2-stats-disabled-notice hides live via storage event & kills record after GameFlags
await playCdp.evaluate(`(async () => {
const toggle = document.querySelector('[data-plugin-toggle="kill-stats"]');
toggle?.click();
await window.__d2PlayApp.plugins.setEnabled('kill-stats', true);
})()`)
let noticeHiddenAfterReenable = false
for (let i = 0; i < 30; i++) {
await sleep(100)
noticeHiddenAfterReenable = await statsCdp.evaluate<boolean>(
`Boolean(document.getElementById('d2-stats-disabled-notice')?.hidden)`,
)
if (noticeHiddenAfterReenable) break
}
assert(noticeHiddenAfterReenable, '#d2-stats-disabled-notice must hide automatically when kill-stats is re-enabled')
await playCdp.evaluate(`(async () => {
const emit = window.__d2EmitPluginTestEvent;
emit({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
emit({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 'player', id: 1, classId: 1, name: 'PluginSorc', x: 5000, y: 5000, mode: 1, lifePct: 128 },
});
emit({
type: 'UnitAssign',
unit: { unitType: 'monster', id: 302, classId: 156, x: 5010, y: 5010, mode: 1, lifePct: 128, isBoss: true },
});
emit({ type: 'UnitMode', unitType: 'monster', id: 302, mode: 0, x: 5010, y: 5010, lifePct: 0 });
await window.__d2PlayApp.plugins.getInstance('kill-stats').tracker.flushNow();
})()`)
let totalAfterReenable = ''
for (let i = 0; i < 30; i++) {
await sleep(100)
totalAfterReenable = await statsCdp.evaluate<string>(
`document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? ''`,
)
if (totalAfterReenable === '1') break
}
assert(totalAfterReenable === '1', `Expected Total=1 after re-enabling kill-stats, got ${totalAfterReenable}`)
// Clean up recorded character in stats page
await statsCdp.evaluate(`(async () => {
await window.__d2StatsApp.repository.clearAll();
await window.__d2StatsApp.refresh();
})()`)
// 6. Verify Escape key closes the plugin modal WITHOUT triggering in-game Esc menu or HUD panels
const escIsolation = await playCdp.evaluate<{
modalOpenBefore: boolean
modalOpenAfter: boolean
hudListenerTriggered: boolean
}>(`(() => {
const overlay = document.querySelector('[data-d2-modal-overlay="plugins"]');
const modalOpenBefore = Boolean(overlay && !overlay.hasAttribute('hidden'));
let hudBubbleKeySeen = false;
const onBubbleKey = () => { hudBubbleKeySeen = true; };
window.addEventListener('keydown', onBubbleKey, false);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true }));
window.removeEventListener('keydown', onBubbleKey, false);
const modalOpenAfter = Boolean(overlay && !overlay.hasAttribute('hidden'));
return {
modalOpenBefore,
modalOpenAfter,
hudListenerTriggered: hudBubbleKeySeen,
};
})()`)
assert(escIsolation.modalOpenBefore, 'Plugin modal must be open before Escape key')
assert(!escIsolation.modalOpenAfter, 'Plugin modal must close on Escape key')
assert(!escIsolation.hudListenerTriggered, 'Escape key while plugin modal is open must not reach bubble-phase HUD listeners')
assert(
playCdp.consoleErrors.length === 0,
`play.html had console errors: ${JSON.stringify(playCdp.consoleErrors)}`,
)
assert(
playCdp.pageExceptions.length === 0,
`play.html had uncaught page exceptions: ${JSON.stringify(playCdp.pageExceptions)}`,
)
assert(
statsCdp.consoleErrors.length === 0,
`stats page had console errors: ${JSON.stringify(statsCdp.consoleErrors)}`,
)
assert(
statsCdp.pageExceptions.length === 0,
`stats page had uncaught page exceptions: ${JSON.stringify(statsCdp.pageExceptions)}`,
)
console.log('[verify-plugin-menu] ALL END-TO-END BROWSER ASSERTIONS PASSED (0 console errors, 0 page exceptions)')
} finally {
await statsCdp?.close()
await playCdp?.close()
if (chromeProc && !chromeProc.killed) {
chromeProc.kill('SIGKILL')
}
if (viteServer) {
await viteServer.close()
}
rmSync(userDataDir, { recursive: true, force: true })
}
}
run().catch((err) => {
console.error('[verify-plugin-menu] FAILED:', err)
process.exit(1)
})

View File

@ -10,7 +10,8 @@
"src/client/**/*.ts", "src/client/**/*.ts",
"src/common/**/*.ts", "src/common/**/*.ts",
"src/common/**/*.d.ts", "src/common/**/*.d.ts",
"src/netproto/**/*.ts" "src/netproto/**/*.ts",
"plugins/**/*.ts"
], ],
"exclude": [ "exclude": [
"src/server", "src/server",

View File

@ -15,7 +15,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node"] "types": ["node"]
}, },
"include": ["src/**/*.ts", "tools/**/*.ts", "vite.config.ts", "tests/**/*.test.ts"], "include": ["src/**/*.ts", "plugins/**/*.ts", "tools/**/*.ts", "vite.config.ts", "tests/**/*.test.ts"],
"exclude": [ "exclude": [
"tests/e2e-drop-parity", "tests/e2e-drop-parity",
"tests/fail-fast-drop.test.ts" "tests/fail-fast-drop.test.ts"

View File

@ -1,11 +1,27 @@
import { existsSync, readdirSync, statSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
const __dirname = fileURLToPath(new URL('.', import.meta.url)) const __dirname = fileURLToPath(new URL('.', import.meta.url))
function discoverPluginHtmlEntries(rootDir: string): Record<string, string> {
const pluginsDir = resolve(rootDir, 'plugins')
if (!existsSync(pluginsDir)) return {}
const entries: Record<string, string> = {}
for (const name of readdirSync(pluginsDir).sort()) {
const pluginDir = resolve(pluginsDir, name)
if (!statSync(pluginDir).isDirectory()) continue
const htmlPath = resolve(pluginDir, 'index.html')
if (existsSync(htmlPath)) {
entries[`plugin-${name}`] = htmlPath
}
}
return entries
}
/** /**
* Vite configuration for the Diablo II web client (`play.html` and `stats.html`). * Vite configuration for the Diablo II web client (`play.html` and `plugins/<id>/index.html`).
*/ */
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@ -51,7 +67,7 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
input: { input: {
play: resolve(__dirname, 'play.html'), play: resolve(__dirname, 'play.html'),
stats: resolve(__dirname, 'stats.html'), ...discoverPluginHtmlEntries(__dirname),
sw: resolve(__dirname, 'src/client/sw/sw.ts'), sw: resolve(__dirname, 'src/client/sw/sw.ts'),
}, },
output: { output: {

View File

@ -8,7 +8,7 @@ export default defineConfig({
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'html'], reporter: ['text', 'html'],
include: ['src/**'], include: ['src/**', 'plugins/**'],
exclude: ['tools/**', 'samples/**'] exclude: ['tools/**', 'samples/**']
} }
} }