feat(plugins): implement top-level plugin architecture, toolbar plugin modal menu, and kill-stats plugin migration
This commit is contained in:
parent
db1b69f440
commit
ff76655d4d
15
README.md
15
README.md
|
|
@ -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)** 战网服务器。
|
||||
- **种子驱动的确定性 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 流畅体验。
|
||||
- **严格的模块化架构隔离**:全仓按原版 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/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/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) |
|
||||
|
|
@ -37,8 +38,14 @@
|
|||
┌─────────────┐ ┌──────────────┐ ┌─────────────────────────┐
|
||||
│ src/baker │ │ src/server │◄──┤ src/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)*
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
16
play.html
16
play.html
|
|
@ -158,12 +158,22 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.d2-toolbar__btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.d2-toolbar__btn--active {
|
||||
background: linear-gradient(180deg, #524020 0%, #2e2310 100%);
|
||||
border-color: #d4b46a;
|
||||
color: #fff5d6;
|
||||
}
|
||||
|
||||
.d2-toolbar__btn--warn {
|
||||
border-color: #d4882a;
|
||||
color: #ffb84d;
|
||||
}
|
||||
|
||||
.d2-toolbar__diag {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
|
|
@ -446,6 +456,10 @@
|
|||
</div>
|
||||
<div id="d2-inspector-container"></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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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 掉落参考。 |
|
||||
|
|
@ -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]
|
||||
|
|
@ -218,6 +218,16 @@
|
|||
border-color: var(--d2-gold);
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(130px, 1fr));
|
||||
|
|
@ -428,6 +438,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</html>
|
||||
|
|
@ -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()
|
||||
|
|
@ -3,11 +3,11 @@
|
|||
* validation for per-account, per-character monster kill statistics.
|
||||
*/
|
||||
|
||||
import type { CharClassId } from '../../src/netproto/index.ts'
|
||||
import {
|
||||
isCountableBossClass,
|
||||
isCountableKillClass,
|
||||
} from '../../common/units/monster-kill-classes.ts'
|
||||
import type { CharClassId } from '../../netproto/index.ts'
|
||||
} from './monster-kill-classes.ts'
|
||||
|
||||
export const KILL_STATS_FORMAT = 'd2web-kill-stats' as const
|
||||
export const KILL_STATS_VERSION = 1 as const
|
||||
|
|
@ -10,18 +10,18 @@
|
|||
* - 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 {
|
||||
MONSTATS_MAX_HCIDX,
|
||||
isCountableBossClass,
|
||||
isCountableKillClass,
|
||||
} from '../../common/units/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'
|
||||
} from './monster-kill-classes.ts'
|
||||
import {
|
||||
SUPERUNIQUE_COUNT,
|
||||
type KillCharacterRecord,
|
||||
|
|
@ -7,14 +7,16 @@
|
|||
* `KillStatsRepository`, and notifies open `stats.html` tabs via `BroadcastChannel`.
|
||||
*/
|
||||
|
||||
import { isCountableKillClass } from '../../common/units/monster-kill-classes.ts'
|
||||
import { getMonsterKindByClassId } from '../../common/units/monsters-data.ts'
|
||||
import type {
|
||||
PluginLogger,
|
||||
PluginSessionView,
|
||||
} from '../../src/client/plugin-host/api.ts'
|
||||
import { getMonsterKindByClassId } from '../../src/common/units/monsters-data.ts'
|
||||
import {
|
||||
unitTypeFromId,
|
||||
type CharClassId,
|
||||
type ServerEvent,
|
||||
} from '../../netproto/index.ts'
|
||||
import type { OnlineSession } from '../session/online-session.ts'
|
||||
} from '../../src/netproto/index.ts'
|
||||
import {
|
||||
KILL_STATS_CHANNEL_NAME,
|
||||
SUPERUNIQUE_COUNT,
|
||||
|
|
@ -30,6 +32,7 @@ import {
|
|||
IndexedDbKillStatsRepository,
|
||||
type KillStatsRepository,
|
||||
} from './kill-stats-store.ts'
|
||||
import { isCountableKillClass } from './monster-kill-classes.ts'
|
||||
|
||||
export interface KillTrackerScheduler {
|
||||
setTimeout(cb: () => void, delayMs: number): unknown
|
||||
|
|
@ -53,18 +56,27 @@ export interface KillTrackerWindowLike {
|
|||
readonly document?: KillTrackerDocumentLike | undefined
|
||||
}
|
||||
|
||||
export type SessionIdentityResolution =
|
||||
| { readonly ok: true; readonly profile: KillCharacterProfile }
|
||||
| { readonly ok: false; readonly reason: string }
|
||||
|
||||
export interface KillTrackerStatus {
|
||||
readonly pendingKills: number
|
||||
readonly pendingDeltasCount: number
|
||||
readonly lastFlushAt: number | null
|
||||
readonly lastError: string | null
|
||||
readonly lastSkipReason: string | null
|
||||
readonly difficulty: KillDifficulty | null
|
||||
}
|
||||
|
||||
export interface KillTrackerOptions {
|
||||
readonly session: OnlineSession
|
||||
readonly session: PluginSessionView
|
||||
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 scheduler?: KillTrackerScheduler | 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
|
||||
* character profile metadata from `OnlineSession`. Returns `null` if any of the 4 identity
|
||||
* components is missing or blank.
|
||||
* character profile metadata from `PluginSessionView`.
|
||||
*
|
||||
* 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(
|
||||
session: OnlineSession,
|
||||
session: PluginSessionView,
|
||||
hasGameFlags = true,
|
||||
): KillCharacterProfile | null {
|
||||
): SessionIdentityResolution {
|
||||
const host = (session.loginHost ?? '').trim()
|
||||
const snap = session.getSnapshot()
|
||||
const realm = (snap.selectedRealm?.title ?? '').trim()
|
||||
|
|
@ -101,19 +116,28 @@ export function resolveSessionIdentity(
|
|||
const charName = (snap.selectedChar ?? '').trim()
|
||||
|
||||
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 matchedChar = snap.characters.find(
|
||||
(c) => c.name.trim().toLowerCase() === lowerName,
|
||||
)
|
||||
|
||||
const selfClass = session.world.self.charClass
|
||||
let charClass: CharClassId = 0
|
||||
let charClass: CharClassId | null = null
|
||||
if (
|
||||
selfClass !== null &&
|
||||
typeof selfClass === 'number' &&
|
||||
Number.isInteger(selfClass) &&
|
||||
selfClass >= 0 &&
|
||||
selfClass <= 6
|
||||
|
|
@ -121,31 +145,60 @@ export function resolveSessionIdentity(
|
|||
charClass = selfClass as CharClassId
|
||||
} else if (
|
||||
matchedChar &&
|
||||
typeof matchedChar.charClass === 'number' &&
|
||||
Number.isInteger(matchedChar.charClass) &&
|
||||
matchedChar.charClass >= 0 &&
|
||||
matchedChar.charClass <= 6
|
||||
) {
|
||||
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 rawLevel =
|
||||
Number.isFinite(selfLevel) && selfLevel >= 1
|
||||
? selfLevel
|
||||
: (matchedChar?.level ?? 1)
|
||||
const level = Math.min(99, Math.max(1, Math.trunc(rawLevel)))
|
||||
let rawLevel: number | null = null
|
||||
if (
|
||||
typeof selfLevel === 'number' &&
|
||||
Number.isFinite(selfLevel) &&
|
||||
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
|
||||
? Boolean(session.world.hardcore)
|
||||
: Boolean(matchedChar?.hardcore ?? false)
|
||||
const expansion = hasGameFlags
|
||||
? Boolean(session.world.expansion)
|
||||
: Boolean(matchedChar?.expansion ?? true)
|
||||
const ladder = hasGameFlags
|
||||
? Boolean(session.world.ladder)
|
||||
: Boolean(matchedChar?.ladder ?? false)
|
||||
if (
|
||||
typeof session.world.hardcore !== 'boolean' ||
|
||||
typeof session.world.expansion !== 'boolean' ||
|
||||
typeof session.world.ladder !== 'boolean'
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing authoritative world mode flags (hardcore, expansion, ladder)',
|
||||
}
|
||||
}
|
||||
|
||||
const charKey = computeCharKey(host, realm, account, charName)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
profile: {
|
||||
charKey,
|
||||
host,
|
||||
realm,
|
||||
|
|
@ -153,9 +206,10 @@ export function resolveSessionIdentity(
|
|||
charName,
|
||||
charClass,
|
||||
level,
|
||||
hardcore,
|
||||
expansion,
|
||||
ladder,
|
||||
hardcore: session.world.hardcore,
|
||||
expansion: session.world.expansion,
|
||||
ladder: session.world.ladder,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -164,6 +218,12 @@ const DEFAULT_SCHEDULER: KillTrackerScheduler = {
|
|||
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 {
|
||||
if (typeof globalThis.BroadcastChannel !== 'function') return null
|
||||
try {
|
||||
|
|
@ -175,8 +235,12 @@ function defaultChannelFactory(name: string): BroadcastChannelLike | null {
|
|||
|
||||
export class KillTracker {
|
||||
readonly repository: KillStatsRepository
|
||||
private readonly session: OnlineSession
|
||||
private readonly resolveIdentityFn: (session: OnlineSession) => KillCharacterProfile | null
|
||||
private readonly session: PluginSessionView
|
||||
private readonly resolveIdentityFn: (
|
||||
session: PluginSessionView,
|
||||
) => KillCharacterProfile | SessionIdentityResolution | null
|
||||
private readonly log: PluginLogger
|
||||
private readonly onStatusChange: (() => void) | undefined
|
||||
private readonly now: () => number
|
||||
private readonly scheduler: KillTrackerScheduler
|
||||
private readonly channel: BroadcastChannelLike | null
|
||||
|
|
@ -189,6 +253,7 @@ export class KillTracker {
|
|||
private readonly owned = new Set<number>()
|
||||
private warnedMissingGameFlags = false
|
||||
private warnedMissingIdentity = false
|
||||
private lastSkipReason: string | null = null
|
||||
private lastPhase: string
|
||||
|
||||
private readonly pendingByChar = new Map<string, PendingCharacterBatch>()
|
||||
|
|
@ -211,6 +276,8 @@ export class KillTracker {
|
|||
this.resolveIdentityFn =
|
||||
options.resolveIdentity ??
|
||||
((s) => resolveSessionIdentity(s, this.difficulty !== null))
|
||||
this.log = options.log ?? DEFAULT_LOGGER
|
||||
this.onStatusChange = options.onStatusChange
|
||||
this.scheduler = options.scheduler ?? DEFAULT_SCHEDULER
|
||||
const createChannel = options.channelFactory ?? defaultChannelFactory
|
||||
this.channel = createChannel(KILL_STATS_CHANNEL_NAME)
|
||||
|
|
@ -281,6 +348,7 @@ export class KillTracker {
|
|||
pendingDeltasCount,
|
||||
lastFlushAt: this.lastFlushAt,
|
||||
lastError: this.lastError,
|
||||
lastSkipReason: this.lastSkipReason,
|
||||
difficulty: this.difficulty,
|
||||
}
|
||||
}
|
||||
|
|
@ -304,7 +372,8 @@ export class KillTracker {
|
|||
private recordInternalError(prefix: string, err: unknown): void {
|
||||
const detail = err instanceof Error ? err.message : String(err)
|
||||
this.lastError = `${prefix}: ${detail}`
|
||||
console.error(`[KillTracker] ${prefix}:`, err)
|
||||
this.log.error(`${prefix}:`, err)
|
||||
this.onStatusChange?.()
|
||||
}
|
||||
|
||||
private resetPerGameState(): void {
|
||||
|
|
@ -313,6 +382,7 @@ export class KillTracker {
|
|||
this.owned.clear()
|
||||
this.warnedMissingGameFlags = false
|
||||
this.warnedMissingIdentity = false
|
||||
this.lastSkipReason = null
|
||||
}
|
||||
|
||||
private handlePhaseChange(nextPhase: string): void {
|
||||
|
|
@ -321,8 +391,10 @@ export class KillTracker {
|
|||
|
||||
if (prevPhase !== 'ingame' && nextPhase === 'ingame') {
|
||||
this.resetPerGameState()
|
||||
this.onStatusChange?.()
|
||||
} else if (prevPhase === 'ingame' && nextPhase !== 'ingame') {
|
||||
this.resetPerGameState()
|
||||
this.onStatusChange?.()
|
||||
void this.flushNow()
|
||||
}
|
||||
}
|
||||
|
|
@ -338,6 +410,7 @@ export class KillTracker {
|
|||
if (ev.difficulty === 0 || ev.difficulty === 1 || ev.difficulty === 2) {
|
||||
this.difficulty = ev.difficulty
|
||||
}
|
||||
this.onStatusChange?.()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -449,35 +522,64 @@ export class KillTracker {
|
|||
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 {
|
||||
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) {
|
||||
this.warnedMissingGameFlags = true
|
||||
const msg =
|
||||
'Skipping monster kill count: GameFlags (0x01) difficulty has not been received in this game'
|
||||
this.lastError = msg
|
||||
console.warn(`[KillTracker] ${msg}`)
|
||||
this.log.warn(`Skipping monster kill count: ${reason}`)
|
||||
}
|
||||
if (changed) {
|
||||
this.onStatusChange?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const profile = this.resolveIdentityFn(this.session)
|
||||
if (
|
||||
!profile ||
|
||||
!profile.host.trim() ||
|
||||
!profile.realm.trim() ||
|
||||
!profile.account.trim() ||
|
||||
!profile.charName.trim()
|
||||
) {
|
||||
const identityRes = this.evaluateIdentity()
|
||||
if (!identityRes.ok) {
|
||||
const changed = this.lastSkipReason !== identityRes.reason
|
||||
this.lastSkipReason = identityRes.reason
|
||||
if (!this.warnedMissingIdentity) {
|
||||
this.warnedMissingIdentity = true
|
||||
const msg =
|
||||
'Skipping monster kill count: incomplete character identity (host, realm, account, charName)'
|
||||
this.lastError = msg
|
||||
console.warn(`[KillTracker] ${msg}`)
|
||||
this.log.warn(`Skipping monster kill count: ${identityRes.reason}`)
|
||||
}
|
||||
if (changed) {
|
||||
this.onStatusChange?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
const profile = identityRes.profile
|
||||
if (this.lastSkipReason !== null) {
|
||||
this.lastSkipReason = null
|
||||
this.onStatusChange?.()
|
||||
}
|
||||
|
||||
if (!isCountableKillClass(unit.classId)) {
|
||||
return
|
||||
|
|
@ -511,7 +613,8 @@ export class KillTracker {
|
|||
} else {
|
||||
const msg = `Out-of-range superUniqueId ${String(unit.superUniqueId)} for monster classId ${unit.classId}`
|
||||
this.lastError = msg
|
||||
console.error(`[KillTracker] ${msg}`)
|
||||
this.log.error(msg)
|
||||
this.onStatusChange?.()
|
||||
}
|
||||
} else if (kind.boss === true) {
|
||||
this.enqueueDelta(profile, 'boss', difficulty, unit.classId, isSolo, at)
|
||||
|
|
@ -666,7 +769,8 @@ export class KillTracker {
|
|||
const msg =
|
||||
failedError instanceof Error ? failedError.message : String(failedError)
|
||||
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(
|
||||
this.maxBackoffMs,
|
||||
this.flushDelayMs * Math.pow(2, this.retryAttempts - 1),
|
||||
|
|
@ -674,6 +778,9 @@ export class KillTracker {
|
|||
this.scheduleFlush(backoffMs)
|
||||
} else {
|
||||
this.retryAttempts = 0
|
||||
if (anySucceeded) {
|
||||
this.onStatusChange?.()
|
||||
}
|
||||
if (this.pendingByChar.size > 0 && this.flushTimer === null) {
|
||||
this.scheduleFlush(this.flushDelayMs)
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
* - `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
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* 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 =
|
||||
| 'stats.page.title'
|
||||
|
|
@ -54,10 +54,12 @@ export type StatsI18nKey =
|
|||
| 'stats.emptyState.body'
|
||||
| 'stats.footnote.attribution'
|
||||
| 'stats.import.successSummary'
|
||||
| 'stats.notice.pluginDisabled'
|
||||
| 'stats.error.idbUnavailable'
|
||||
| 'stats.error.idbBlocked'
|
||||
| 'stats.error.idbVersionChange'
|
||||
| 'stats.error.importFailedPrefix'
|
||||
| 'stats.error.bootFailedPrefix'
|
||||
|
||||
export const STATS_I18N: Readonly<
|
||||
Record<StatsI18nKey, { readonly zh: string; readonly en: string }>
|
||||
|
|
@ -258,6 +260,10 @@ export const STATS_I18N: Readonly<
|
|||
zh: '导入合并完成',
|
||||
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': {
|
||||
zh: 'IndexedDB 在当前浏览器环境下不可用,无法读取或保存击杀统计。',
|
||||
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 导入校验失败:',
|
||||
en: 'JSON import validation failed: ',
|
||||
},
|
||||
'stats.error.bootFailedPrefix': {
|
||||
zh: '击杀统计页面启动失败:',
|
||||
en: 'Failed to initialize Kill Statistics page: ',
|
||||
},
|
||||
}
|
||||
|
||||
export function tStats(key: StatsI18nKey, lang: GameLang): string {
|
||||
|
|
@ -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:
|
||||
* - 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.
|
||||
* - Displays `role="alert"` error banners on IndexedDB errors or `KillStatsImportError` violations.
|
||||
*/
|
||||
|
||||
import { getGameLang, setGameLang, type GameLang } from '../i18n/lang.ts'
|
||||
import { getGlobalSettingsStore, type SettingsStore } from '../settings/settings-store.ts'
|
||||
import {
|
||||
bootPluginPage,
|
||||
type GameLang,
|
||||
type PluginPageContext,
|
||||
} from '../../src/client/plugin-host/api.ts'
|
||||
import { killStatsPlugin } from './index.ts'
|
||||
import {
|
||||
KILL_STATS_CHANNEL_NAME,
|
||||
KillStatsImportError,
|
||||
|
|
@ -34,7 +38,7 @@ import { tStats } from './stats-i18n.ts'
|
|||
export interface KillStatsPageOptions {
|
||||
readonly root: HTMLElement
|
||||
readonly repository?: KillStatsRepository | undefined
|
||||
readonly settingsStore?: SettingsStore | undefined
|
||||
readonly pageContext?: PluginPageContext | undefined
|
||||
readonly confirmFn?: ((message: string) => boolean) | undefined
|
||||
readonly refreshDebounceMs?: number | undefined
|
||||
}
|
||||
|
|
@ -47,6 +51,7 @@ interface BuiltDomRefs {
|
|||
readonly allAccountsLabelEl: HTMLElement
|
||||
readonly allAccountsCountEl: HTMLElement
|
||||
readonly accountTreeEl: HTMLElement
|
||||
readonly disabledNoticeEl: HTMLElement
|
||||
readonly errorBannerEl: HTMLElement
|
||||
readonly infoBannerEl: HTMLElement
|
||||
readonly cardNormalLabelEl: HTMLElement
|
||||
|
|
@ -117,7 +122,7 @@ export class KillStatsPage {
|
|||
readonly repository: KillStatsRepository
|
||||
private readonly root: HTMLElement
|
||||
private readonly doc: Document
|
||||
private readonly settingsStore: SettingsStore
|
||||
private readonly pageContext: PluginPageContext
|
||||
private readonly confirmFn: (message: string) => boolean
|
||||
private readonly refreshDebounceMs: number
|
||||
private readonly dom: BuiltDomRefs
|
||||
|
|
@ -143,14 +148,14 @@ export class KillStatsPage {
|
|||
|
||||
private channel: BroadcastChannel | 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) {
|
||||
this.root = options.root
|
||||
this.doc = options.root.ownerDocument
|
||||
this.settingsStore = options.settingsStore ?? getGlobalSettingsStore()
|
||||
this.lang = this.settingsStore.get().lang ?? getGameLang()
|
||||
setGameLang(this.lang)
|
||||
this.pageContext = options.pageContext ?? bootPluginPage(killStatsPlugin)
|
||||
this.lang = this.pageContext.getLang()
|
||||
this.confirmFn =
|
||||
options.confirmFn ??
|
||||
((msg: string) =>
|
||||
|
|
@ -176,13 +181,15 @@ export class KillStatsPage {
|
|||
|
||||
async init(): Promise<void> {
|
||||
this.setupBroadcastChannel()
|
||||
this.unsubSettings = this.settingsStore.subscribe((next) => {
|
||||
if (next.lang !== this.lang) {
|
||||
this.lang = next.lang
|
||||
setGameLang(this.lang)
|
||||
this.unsubLang = this.pageContext.subscribeLang((nextLang) => {
|
||||
if (nextLang !== this.lang) {
|
||||
this.lang = nextLang
|
||||
this.render()
|
||||
}
|
||||
})
|
||||
this.unsubEnabled = this.pageContext.subscribeEnabled(() => {
|
||||
this.render()
|
||||
})
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
|
|
@ -191,8 +198,11 @@ export class KillStatsPage {
|
|||
clearTimeout(this.refreshTimer)
|
||||
this.refreshTimer = null
|
||||
}
|
||||
this.unsubSettings?.()
|
||||
this.unsubSettings = null
|
||||
this.unsubLang?.()
|
||||
this.unsubLang = null
|
||||
this.unsubEnabled?.()
|
||||
this.unsubEnabled = null
|
||||
this.pageContext.destroy()
|
||||
try {
|
||||
this.channel?.close()
|
||||
} catch {
|
||||
|
|
@ -420,8 +430,7 @@ export class KillStatsPage {
|
|||
toggleLanguage(): GameLang {
|
||||
const nextLang: GameLang = this.lang === 'en' ? 'zh' : 'en'
|
||||
this.lang = nextLang
|
||||
setGameLang(nextLang)
|
||||
this.settingsStore.set({ lang: nextLang })
|
||||
this.pageContext.setLang(nextLang)
|
||||
this.render()
|
||||
return nextLang
|
||||
}
|
||||
|
|
@ -519,6 +528,10 @@ export class KillStatsPage {
|
|||
}
|
||||
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.backToGameLink.textContent = tStats('stats.action.backToGame', lang)
|
||||
this.dom.sidebarTitleEl.textContent = tStats('stats.sidebar.title', lang)
|
||||
|
|
@ -894,7 +907,7 @@ export class KillStatsPage {
|
|||
const backToGameLink = this.doc.createElement('a')
|
||||
backToGameLink.id = 'd2-stats-back-link'
|
||||
backToGameLink.className = 'd2-stats__btn d2-stats__btn--link'
|
||||
backToGameLink.href = 'play.html'
|
||||
backToGameLink.href = '../../play.html'
|
||||
|
||||
sidebarHeader.append(pageTitleEl, backToGameLink)
|
||||
|
||||
|
|
@ -923,6 +936,12 @@ export class KillStatsPage {
|
|||
main.id = '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')
|
||||
errorBannerEl.id = 'd2-stats-error-banner'
|
||||
errorBannerEl.className = 'd2-stats__banner d2-stats__banner--error'
|
||||
|
|
@ -1180,6 +1199,7 @@ export class KillStatsPage {
|
|||
footnoteEl.className = 'd2-stats__footnote'
|
||||
|
||||
main.append(
|
||||
disabledNoticeEl,
|
||||
errorBannerEl,
|
||||
infoBannerEl,
|
||||
summaryGrid,
|
||||
|
|
@ -1200,6 +1220,7 @@ export class KillStatsPage {
|
|||
allAccountsLabelEl,
|
||||
allAccountsCountEl,
|
||||
accountTreeEl,
|
||||
disabledNoticeEl,
|
||||
errorBannerEl,
|
||||
infoBannerEl,
|
||||
cardNormalLabelEl: normalCard.label,
|
||||
|
|
@ -6,9 +6,10 @@
|
|||
|
||||
## 1. 模块职责与架构边界
|
||||
|
||||
- **双 HTML 页面入口**:
|
||||
- **主页面入口与非侵入式插件宿主**:
|
||||
- [`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`)**:
|
||||
- `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。
|
||||
- **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。
|
||||
|
|
@ -20,18 +21,18 @@
|
|||
|
||||
```text
|
||||
src/client/
|
||||
├── main.ts # 客户端主入口:组装 WebGL2 画布、工具栏、前端战网状态机、OnlineSession 与 60fps 渲染循环
|
||||
├── main.ts # 客户端主入口:组装 WebGL2 画布、工具栏、前端战网状态机、OnlineSession、PluginHost 与 60fps 渲染循环
|
||||
├── session/ # OnlineSession 会话协调器与 C2S 发包速率限制器 (OutboundRateLimiter)
|
||||
├── world/ # ClientWorld:由 ServerEvent 驱动的客户端权威状态镜像 (玩家/怪物/NPC/地面物品/投射物/传送门)
|
||||
├── map/ # 种子驱动的客户端 DRLG 地图服务 (MapService, LevelView, TileAtlas GPU 瓦片加载)
|
||||
├── motion/ # 本地玩家移动预测 (LocalMovementPredictor)、远端实体插值器与 A* 寻路 (pathfind)
|
||||
├── render/ # WebGL2 渲染管线 (SpriteRenderer, TileAtlasGpuCache, EntityArtLoader, 投射物/覆盖层/地面物品渲染)
|
||||
├── scene/ # 场景摄像机、屏幕/世界等距坐标转换与分幕环境光照预设 (lighting-presets)
|
||||
├── ui/ # 游戏内 HUD 渲染与交互 (HudManager, 底部状态栏, 小地图 Automap, 7 大面板, D2 点阵字体渲染)
|
||||
├── ui/ # 游戏内 HUD 渲染与交互 (HudManager, 底部状态栏, 小地图 Automap, 7 大面板, D2 点阵字体渲染, DOM 浮层焦点守卫)
|
||||
├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑)
|
||||
├── frontend/ # 战网前置界面 (FrontendRenderer:Gateway 选择、账号登录/注册、8 角色选择、建房/加房大厅)
|
||||
├── toolbar/ # 顶部 32px 窄条工具栏 (FPS/Ping、网络抓包开关、击杀统计入口、设置弹窗、全屏与音量控制)
|
||||
├── stats/ # 按账号/角色隔离的怪物击杀统计 (KillTracker、IndexedDB G-counter 存储与导入导出、stats.html 视图)
|
||||
├── toolbar/ # 顶部 32px 窄条工具栏 (FPS/Ping、网络抓包开关、插件快捷按钮插槽、Plugins 配置菜单入口、设置弹窗、全屏与音量控制)
|
||||
├── plugin-host/ # 非侵入式插件宿主与配置中心 (PluginHost, PluginConfigStore, PluginMenuModal 与只读契约 api.ts)
|
||||
├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理 WS Bridge 地址与显示偏好)
|
||||
├── inspector/ # 实时网络封包检查器 (PacketInspector) 与 .d2cap 录制/回放面板
|
||||
├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存
|
||||
|
|
|
|||
|
|
@ -108,7 +108,11 @@ export function detectLangFromUrl(urlStr?: string): TblLang {
|
|||
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) {
|
||||
const params = new URLSearchParams(window.location.search || '')
|
||||
const q = params.get('lang')?.trim().toLowerCase()
|
||||
|
|
@ -121,10 +125,9 @@ export function detectInitialLang(): GameLang {
|
|||
return fromPath
|
||||
}
|
||||
if (
|
||||
options?.preferStoredSetting === true ||
|
||||
path === 'play.html' ||
|
||||
path.endsWith('/play.html') ||
|
||||
path === 'stats.html' ||
|
||||
path.endsWith('/stats.html')
|
||||
path.endsWith('/play.html')
|
||||
) {
|
||||
try {
|
||||
const raw = window.localStorage?.getItem('d2web.settings.v1')
|
||||
|
|
@ -223,8 +226,8 @@ export function setTblLang(lang: TblLang | GameLang | null): void {
|
|||
setGameLang(lang)
|
||||
}
|
||||
|
||||
export function initLangFromBrowser(): TblLang {
|
||||
overrideLang = detectInitialLang()
|
||||
export function initLangFromBrowser(options?: DetectLangOptions): TblLang {
|
||||
overrideLang = detectInitialLang(options)
|
||||
notifyListeners()
|
||||
return getTblLang()
|
||||
}
|
||||
|
|
@ -279,7 +282,7 @@ export type ToolbarI18nKey =
|
|||
| 'toolbar.automap.act'
|
||||
| 'toolbar.viewport.label'
|
||||
| 'toolbar.inspector.toggle'
|
||||
| 'toolbar.killStats.open'
|
||||
| 'toolbar.plugins.open'
|
||||
| 'toolbar.cache.clear'
|
||||
| 'toolbar.cache.cleared'
|
||||
| 'toolbar.diag.quotaExceeded'
|
||||
|
|
@ -308,7 +311,7 @@ export const TOOLBAR_I18N: Readonly<Record<ToolbarI18nKey, { readonly zh: string
|
|||
'toolbar.automap.act': { zh: '全幕', en: 'Act' },
|
||||
'toolbar.viewport.label': { zh: '分辨率', en: 'Viewport' },
|
||||
'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.cleared': { zh: '已清缓存', en: 'Cleared' },
|
||||
'toolbar.diag.quotaExceeded': {
|
||||
|
|
|
|||
|
|
@ -51,14 +51,17 @@ import {
|
|||
BootProgressTracker,
|
||||
type BootProgressSnapshot,
|
||||
} from './ui/boot-progress.ts'
|
||||
import { isModalDomOverlayOpen } from './ui/dom-overlay.ts'
|
||||
import { HudManager } from './ui/hud-manager.ts'
|
||||
import { Minimap } from './ui/minimap.ts'
|
||||
import { CommandMapper } from './input/command-mapper.ts'
|
||||
import { PacketInspector } from './inspector/packet-inspector.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 { getGlobalSettingsStore, type SettingsStore } from './settings/settings-store.ts'
|
||||
import { KillTracker } from './stats/kill-tracker.ts'
|
||||
import {
|
||||
getAssetCacheStatus,
|
||||
registerAssetCache,
|
||||
|
|
@ -93,7 +96,8 @@ export interface PlayAppController {
|
|||
readonly settings: SettingsStore
|
||||
readonly settingsStore: SettingsStore
|
||||
readonly session: OnlineSession
|
||||
readonly killStats: KillTracker
|
||||
readonly plugins: PluginHost
|
||||
readonly pluginMenu: PluginMenu
|
||||
readonly toolbar: Toolbar
|
||||
readonly inspector: PacketInspector
|
||||
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
|
||||
|
||||
const toolbarContainer = document.querySelector<HTMLElement>('#d2-toolbar-container')
|
||||
|
|
@ -149,6 +155,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
|||
setLang(initialSettings.lang)
|
||||
|
||||
let sessionRef: OnlineSession | null = null
|
||||
let pluginMenuRef: PluginMenu | null = null
|
||||
|
||||
const syncStageViewportFit = (
|
||||
profileOrId: ViewportProfile | typeof initialSettings.viewport = settingsStore.get().viewport,
|
||||
|
|
@ -185,6 +192,9 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
|||
onServerHostReconnect: (newHost) => {
|
||||
void sessionRef?.disconnect(`Reconnecting to ${newHost}`)
|
||||
},
|
||||
onPluginsToggle: () => {
|
||||
pluginMenuRef?.toggle()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -384,7 +394,16 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
|||
},
|
||||
})
|
||||
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
|
||||
const inspector = session.packetInspector
|
||||
|
|
@ -902,6 +921,9 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
|||
window.addEventListener(
|
||||
'keydown',
|
||||
(e) => {
|
||||
if (isModalDomOverlayOpen()) {
|
||||
return
|
||||
}
|
||||
const activeEl = document.activeElement
|
||||
if (
|
||||
activeEl &&
|
||||
|
|
@ -927,6 +949,9 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
|||
window.addEventListener(
|
||||
'keyup',
|
||||
(e) => {
|
||||
if (isModalDomOverlayOpen()) {
|
||||
return
|
||||
}
|
||||
if (inGameView) {
|
||||
session.commandMapper.handleKeyUp(e)
|
||||
}
|
||||
|
|
@ -1011,6 +1036,8 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
|||
renderFrame(performance.now())
|
||||
requestAnimationFrame(loop)
|
||||
|
||||
await plugins.whenStarted()
|
||||
|
||||
const controller: PlayAppController = {
|
||||
ready: true,
|
||||
bootProgress: bootProgress.getSnapshot(),
|
||||
|
|
@ -1018,7 +1045,8 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
|||
settings: settingsStore,
|
||||
settingsStore,
|
||||
session,
|
||||
killStats,
|
||||
plugins,
|
||||
pluginMenu,
|
||||
toolbar,
|
||||
inspector,
|
||||
hudManager: session.hudManager,
|
||||
|
|
@ -1058,11 +1086,3 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
|||
window.__d2Play = 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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -5,6 +5,8 @@ import {
|
|||
tToolbar,
|
||||
type GameLang,
|
||||
} from '../i18n/lang.ts'
|
||||
import { resolveLocalizedText } from '../plugin-host/api.ts'
|
||||
import type { ToolbarContributionSpec } from '../plugin-host/plugin-host.ts'
|
||||
import {
|
||||
CLIENT_LIGHTING_PRESET_ORDER,
|
||||
LIGHTING_PRESETS,
|
||||
|
|
@ -60,6 +62,7 @@ export interface ToolbarCallbacks {
|
|||
readonly onAutomapRevealChange?: ((mode: AutomapRevealMode) => void) | undefined
|
||||
readonly onViewportChange?: ((profile: ViewportProfile) => void) | undefined
|
||||
readonly onInspectorToggle?: ((open: boolean) => void) | undefined
|
||||
readonly onPluginsToggle?: (() => 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:
|
||||
* 1. Connection Status + RTT (`ms`) + Diagnostics (5-per-IP quota / WSS TLS error) + Inspector toggle
|
||||
* 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 + Plugins modal button (`#d2-toolbar-plugins-btn`) + Dynamic Plugin Contribution Slot (`#d2-toolbar-plugin-slot`)
|
||||
* 2. Live Language Toggle (`中` / `EN`)
|
||||
* 3. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`)
|
||||
* 4. Automap Reveal Selector (`off | level | act`)
|
||||
|
|
@ -104,6 +107,13 @@ export class Toolbar {
|
|||
private serverInputValid = true
|
||||
private cacheUsageBytes = 0
|
||||
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 unsubscribeLang: (() => void) | null = null
|
||||
|
||||
|
|
@ -121,7 +131,8 @@ export class Toolbar {
|
|||
private viewportLabelEl: HTMLElement | null = null
|
||||
private viewportSelectEl: HTMLSelectElement | 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 diagBannerEl: HTMLElement | null = null
|
||||
|
||||
|
|
@ -332,6 +343,67 @@ export class Toolbar {
|
|||
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 {
|
||||
this.cacheUsageBytes = Number.isFinite(bytes) && bytes > 0 ? bytes : 0
|
||||
this.syncDom()
|
||||
|
|
@ -388,6 +460,7 @@ export class Toolbar {
|
|||
this.unsubscribeStore = null
|
||||
this.unsubscribeLang?.()
|
||||
this.unsubscribeLang = null
|
||||
this.contributions.clear()
|
||||
if (this.rootEl && this.rootEl.parentNode) {
|
||||
this.rootEl.parentNode.removeChild(this.rootEl)
|
||||
}
|
||||
|
|
@ -505,11 +578,19 @@ a.d2-toolbar__btn {
|
|||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.d2-toolbar__btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.d2-toolbar__btn--active {
|
||||
background: linear-gradient(180deg, #524020 0%, #2e2310 100%);
|
||||
border-color: #d4b46a;
|
||||
color: #fff5d6;
|
||||
}
|
||||
.d2-toolbar__btn--warn {
|
||||
border-color: #d4882a;
|
||||
color: #ffb84d;
|
||||
}
|
||||
.d2-toolbar__diag {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
|
|
@ -541,7 +622,7 @@ a.d2-toolbar__btn {
|
|||
root.id = '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')
|
||||
statusGroup.className = 'd2-toolbar__group'
|
||||
|
||||
|
|
@ -567,16 +648,24 @@ a.d2-toolbar__btn {
|
|||
this.toggleInspector()
|
||||
})
|
||||
|
||||
const killStatsLink = doc.createElement('a')
|
||||
killStatsLink.id = 'd2-toolbar-killstats'
|
||||
killStatsLink.className = 'd2-toolbar__btn'
|
||||
killStatsLink.href = 'stats.html'
|
||||
killStatsLink.target = '_blank'
|
||||
killStatsLink.rel = 'noopener'
|
||||
const pluginsBtn = doc.createElement('button')
|
||||
pluginsBtn.type = 'button'
|
||||
pluginsBtn.id = 'd2-toolbar-plugins-btn'
|
||||
pluginsBtn.className = 'd2-toolbar__btn'
|
||||
pluginsBtn.disabled = !this.pluginsReady
|
||||
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(inspectorBtn)
|
||||
statusGroup.appendChild(killStatsLink)
|
||||
statusGroup.appendChild(pluginsBtn)
|
||||
statusGroup.appendChild(pluginSlot)
|
||||
|
||||
// Group 2: Language Toggle (中 / EN)
|
||||
const langGroup = doc.createElement('div')
|
||||
|
|
@ -688,7 +777,8 @@ a.d2-toolbar__btn {
|
|||
this.viewportLabelEl = viewportLabel
|
||||
this.viewportSelectEl = viewportSelect
|
||||
this.inspectorBtnEl = inspectorBtn
|
||||
this.killStatsLinkEl = killStatsLink
|
||||
this.pluginsBtnEl = pluginsBtn
|
||||
this.pluginSlotEl = pluginSlot
|
||||
this.cacheClearBtnEl = cacheClearBtn
|
||||
this.diagBannerEl = diagBanner
|
||||
|
||||
|
|
@ -715,8 +805,24 @@ a.d2-toolbar__btn {
|
|||
? 'd2-toolbar__btn d2-toolbar__btn--active'
|
||||
: 'd2-toolbar__btn'
|
||||
}
|
||||
if (this.killStatsLinkEl) {
|
||||
this.killStatsLinkEl.textContent = tToolbar('toolbar.killStats.open', lang)
|
||||
if (this.pluginsBtnEl) {
|
||||
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) {
|
||||
this.langBtnZhEl.className =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -65,6 +65,7 @@ import {
|
|||
type CursorAnimation,
|
||||
type CursorState,
|
||||
} from './cursor.ts'
|
||||
import { isModalDomOverlayOpen } from './dom-overlay.ts'
|
||||
import type { CharacterClassCode } from '../../common/units/classes.ts'
|
||||
import {
|
||||
detectInitialLang,
|
||||
|
|
@ -1549,6 +1550,9 @@ export class HudManager {
|
|||
}
|
||||
|
||||
handleKeyDown(e: KeyboardEvent): void {
|
||||
if (isModalDomOverlayOpen()) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
typeof HTMLInputElement !== 'undefined' &&
|
||||
(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement)
|
||||
|
|
@ -1676,6 +1680,7 @@ export class HudManager {
|
|||
window.addEventListener('keydown', (e) => this.handleKeyDown(e))
|
||||
|
||||
window.addEventListener('keyup', (e) => {
|
||||
if (isModalDomOverlayOpen()) return
|
||||
if (e.key === 'Alt') {
|
||||
e.preventDefault()
|
||||
this.showGroundLabels = false
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ import { dirname, relative, resolve } from 'node:path'
|
|||
import { fileURLToPath } from 'node:url'
|
||||
import * as ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import viteConfig from '../../vite.config.ts'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT_DIR = resolve(__dirname, '../..')
|
||||
const SRC_DIR = resolve(ROOT_DIR, 'src')
|
||||
const PLUGINS_DIR = resolve(ROOT_DIR, 'plugins')
|
||||
const TOOLS_DIR = resolve(ROOT_DIR, 'tools')
|
||||
|
||||
const CANONICAL_MODULES = ['baker', 'client', 'common', 'netproto', 'server'] as const
|
||||
|
|
@ -40,6 +42,33 @@ function collectTsFiles(dir: string): string[] {
|
|||
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 {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
||||
|
|
@ -61,7 +90,40 @@ function extractImportSpecifiers(source: string): string[] {
|
|||
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)', () => {
|
||||
const entries = readdirSync(SRC_DIR).sort()
|
||||
expect(entries).toEqual([...CANONICAL_MODULES].sort())
|
||||
|
|
@ -178,32 +240,37 @@ describe('Architectural Boundaries & 5-Module Isolation (R3, F9 — Zero Whiteli
|
|||
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)
|
||||
.filter((f) => f.endsWith('.html'))
|
||||
.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)
|
||||
const playHtml = readFileSync(resolve(ROOT_DIR, 'play.html'), 'utf8')
|
||||
expect(playHtml).toContain('/src/client/main.ts')
|
||||
expect(playHtml).toContain('/plugins/index.ts')
|
||||
expect(playHtml).toContain('D2Exocet')
|
||||
expect(playHtml).toContain('D2Formal436')
|
||||
|
||||
expect(existsSync(resolve(ROOT_DIR, 'stats.html'))).toBe(true)
|
||||
const statsHtml = readFileSync(resolve(ROOT_DIR, 'stats.html'), 'utf8')
|
||||
expect(statsHtml).toContain('/src/client/stats/stats-main.ts')
|
||||
expect(statsHtml).toContain('D2Exocet')
|
||||
expect(statsHtml).toContain('D2Formal436')
|
||||
const moduleScriptBlocks = [...playHtml.matchAll(/<script\b[^>]*type=["']module["'][^>]*>([\s\S]*?)<\/script>/gi)]
|
||||
.map((m) => m[1] ?? '')
|
||||
const inlineImports = moduleScriptBlocks.flatMap((block) => extractImportSpecifiers(block))
|
||||
expect(inlineImports).toEqual(['/src/client/main.ts', '/plugins/index.ts'])
|
||||
|
||||
const viteCfg = readFileSync(resolve(ROOT_DIR, 'vite.config.ts'), 'utf8')
|
||||
expect(viteCfg).toContain('play.html')
|
||||
expect(viteCfg).toContain('stats.html')
|
||||
expect(viteCfg).not.toContain('stats.html')
|
||||
|
||||
for (const legacyHtml of LEGACY_HTML_FILES) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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__'))
|
||||
expect(testFilesInSrc).toEqual([])
|
||||
|
||||
const dynamicViolations: string[] = []
|
||||
for (const file of allSrcFiles) {
|
||||
const relFile = relative(ROOT_DIR, file)
|
||||
expect(checkNoDynamicImportBypasses(allSrcFiles)).toEqual([])
|
||||
})
|
||||
|
||||
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')
|
||||
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 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) => {
|
||||
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})`)
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
checkLiteral(node.text)
|
||||
} else if (ts.isTemplateExpression(node)) {
|
||||
checkLiteral(node.head.text)
|
||||
for (const span of node.templateSpans) {
|
||||
checkLiteral(span.literal.text)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -280,9 +280,9 @@ describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => {
|
|||
expect(store.get().inspectorOpen).toBe(true)
|
||||
expect(inspectorSpy).toHaveBeenCalledWith(true)
|
||||
|
||||
// Kill Stats link i18n
|
||||
expect(tToolbar('toolbar.killStats.open', 'zh')).toBe('击杀统计')
|
||||
expect(tToolbar('toolbar.killStats.open', 'en')).toBe('Kill Stats')
|
||||
// Plugins button i18n
|
||||
expect(tToolbar('toolbar.plugins.open', 'zh')).toBe('插件')
|
||||
expect(tToolbar('toolbar.plugins.open', 'en')).toBe('Plugins')
|
||||
|
||||
toolbar.destroy()
|
||||
setLang('zh')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
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) {
|
||||
if (legacyHtml === 'index.html') {
|
||||
expect(viteCfg).not.toMatch(/resolve\(\s*__dirname\s*,\s*['"]index\.html['"]\)/)
|
||||
} else {
|
||||
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/)', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
const viteSource = stripComments(readRepoFile('vite.config.ts'))
|
||||
expect(viteSource).not.toContain('stats.html')
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('F7.B5: src/client/main.ts contains zero imports from src/server, src/baker, or legacy src/* directories', () => {
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ describe('Tier 4: Real-World End-to-End Application Scenarios', () => {
|
|||
}
|
||||
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 configs = [
|
||||
'tsconfig.common.json',
|
||||
|
|
@ -209,16 +209,20 @@ describe('Tier 4: Real-World End-to-End Application Scenarios', () => {
|
|||
'tsconfig.baker.json',
|
||||
'tsconfig.json',
|
||||
]
|
||||
for (const cfg of configs) {
|
||||
const { stdout } = await execFileAsync(tscBin, ['--noEmit', '-p', cfg], {
|
||||
const results = await Promise.all(
|
||||
configs.map((cfg) =>
|
||||
execFileAsync(tscBin, ['--noEmit', '-p', cfg], {
|
||||
cwd: ROOT_DIR,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
for (const { stdout } of results) {
|
||||
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-'))
|
||||
try {
|
||||
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, '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) {
|
||||
expect(
|
||||
existsSync(join(tmpOutDir, legacyHtml)),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
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 {
|
||||
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', () => {
|
||||
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) {
|
||||
const content = readFileSync(join(process.cwd(), file), 'utf8')
|
||||
expect(content, `${file} must declare D2Exocet @font-face`).toMatch(/font-family:\s*['"]D2Exocet['"]/)
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ import {
|
|||
type KillStatsSnapshot,
|
||||
type KillTallyDelta,
|
||||
type KillTallyRecord,
|
||||
} from '../../src/client/stats/kill-stats-model.ts'
|
||||
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts'
|
||||
} from '../../../plugins/kill-stats/kill-stats-model.ts'
|
||||
import { MemoryKillStatsRepository } from './memory-repository.ts'
|
||||
|
||||
const REPLICA_A = '11111111-1111-4111-8111-111111111111'
|
||||
const REPLICA_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
||||
import { computeCharKey } from '../../src/client/stats/kill-stats-model.ts'
|
||||
import { KillTracker } from '../../src/client/stats/kill-tracker.ts'
|
||||
import { encodeD2gsCompressedFrame } from '../../src/netproto/d2gs/compression.ts'
|
||||
import { BitWriter, ByteWriter } from '../../src/netproto/index.ts'
|
||||
import { createFakeRealm, type FakeRealm } from '../netproto/fake-realm-servers.ts'
|
||||
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts'
|
||||
import { OnlineSession } from '../../../src/client/session/online-session.ts'
|
||||
import { computeCharKey } from '../../../plugins/kill-stats/kill-stats-model.ts'
|
||||
import { KillTracker } from '../../../plugins/kill-stats/kill-tracker.ts'
|
||||
import { encodeD2gsCompressedFrame } from '../../../src/netproto/d2gs/compression.ts'
|
||||
import { BitWriter, ByteWriter } from '../../../src/netproto/index.ts'
|
||||
import { createFakeRealm, type FakeRealm } from '../../netproto/fake-realm-servers.ts'
|
||||
import { MemoryKillStatsRepository } from './memory-repository.ts'
|
||||
|
||||
function sendD2gsPacket(realm: FakeRealm, rawPacket: Uint8Array): void {
|
||||
const gameConn = realm.latest('game')
|
||||
|
|
@ -5,14 +5,14 @@ import {
|
|||
type KillCharacterRecord,
|
||||
type KillStatsSnapshot,
|
||||
type KillTallyRecord,
|
||||
} from '../../src/client/stats/kill-stats-model.ts'
|
||||
} from '../../../plugins/kill-stats/kill-stats-model.ts'
|
||||
import {
|
||||
BOSS_SU_BOARD_ROW_COUNT,
|
||||
buildKillStatsViewModel,
|
||||
getBossSuperUniqueBoardCatalog,
|
||||
resolveCharacterClassName,
|
||||
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_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
|
@ -1,25 +1,26 @@
|
|||
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 {
|
||||
computeCharKey,
|
||||
type KillCharacterProfile,
|
||||
type KillStatsBroadcastMessage,
|
||||
} from '../../src/client/stats/kill-stats-model.ts'
|
||||
} from '../../../plugins/kill-stats/kill-stats-model.ts'
|
||||
import {
|
||||
KillTracker,
|
||||
resolveSessionIdentity,
|
||||
type BroadcastChannelLike,
|
||||
type KillTrackerScheduler,
|
||||
type KillTrackerWindowLike,
|
||||
} from '../../src/client/stats/kill-tracker.ts'
|
||||
} from '../../../plugins/kill-stats/kill-tracker.ts'
|
||||
import type {
|
||||
ClientCommand,
|
||||
CloseReason,
|
||||
GameServerAdapter,
|
||||
GameServerStats,
|
||||
ServerEvent,
|
||||
} from '../../src/netproto/index.ts'
|
||||
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts'
|
||||
} from '../../../src/netproto/index.ts'
|
||||
import { MemoryKillStatsRepository } from './memory-repository.ts'
|
||||
|
||||
class FakeGameServerAdapter implements GameServerAdapter {
|
||||
state: 'loading' | 'ingame' | 'closed' = 'ingame'
|
||||
|
|
@ -798,8 +799,157 @@ describe('KillTracker: Boss / SuperUnique Classification, Solo vs Party, Guards
|
|||
h.tracker.destroy()
|
||||
})
|
||||
|
||||
it('resolveSessionIdentity returns null when any of (host, realm, account, charName) is missing', () => {
|
||||
const session = new OnlineSession()
|
||||
expect(resolveSessionIdentity(session)).toBeNull()
|
||||
it('resolveSessionIdentity fails fast (never defaults to Amazon Lv1 SC) when 4-tuple, hasGameFlags, charClass, or level is missing', async () => {
|
||||
const emptySession = new OnlineSession()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* 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 {
|
||||
|
|
@ -14,12 +14,12 @@ import {
|
|||
type KillStatsImportSummary,
|
||||
type KillStatsSnapshot,
|
||||
type KillTallyDelta,
|
||||
} from '../../../src/client/stats/kill-stats-model.ts'
|
||||
} from '../../../plugins/kill-stats/kill-stats-model.ts'
|
||||
import {
|
||||
normalizeApplyBatchArgs,
|
||||
type KillStatsBatchInput,
|
||||
type KillStatsRepository,
|
||||
} from '../../../src/client/stats/kill-stats-store.ts'
|
||||
} from '../../../plugins/kill-stats/kill-stats-store.ts'
|
||||
|
||||
export interface MemoryKillStatsRepositoryOptions {
|
||||
readonly initialReplicaId?: string | undefined
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import * as fs from 'node:fs'
|
||||
import { MountedArchives } from '../../src/common/mpq/mount.ts'
|
||||
import { MpqArchive } from '../../src/common/mpq/archive.ts'
|
||||
import { fileSource } from '../../src/baker/io/file-source.ts'
|
||||
import { cell, parseTable, type D2Table } from '../../src/common/world/acts.ts'
|
||||
import { getMonsterKindByClassId } from '../../src/common/units/monsters-data.ts'
|
||||
import { resolveTblKey } from '../../src/common/data/canonical-tbl-strings.ts'
|
||||
import { MountedArchives } from '../../../src/common/mpq/mount.ts'
|
||||
import { MpqArchive } from '../../../src/common/mpq/archive.ts'
|
||||
import { fileSource } from '../../../src/baker/io/file-source.ts'
|
||||
import { cell, parseTable, type D2Table } from '../../../src/common/world/acts.ts'
|
||||
import { getMonsterKindByClassId } from '../../../src/common/units/monsters-data.ts'
|
||||
import { resolveTblKey } from '../../../src/common/data/canonical-tbl-strings.ts'
|
||||
import {
|
||||
MONSTATS_ALIGN_NONZERO_HCIDX,
|
||||
MONSTATS_MAX_HCIDX,
|
||||
MONSTATS_NEVER_COUNT_HCIDX,
|
||||
isCountableBossClass,
|
||||
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')
|
||||
? 'samples/d2'
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -244,7 +244,7 @@ async function run(): Promise<void> {
|
|||
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}/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}`)
|
||||
|
||||
chromeProc = spawn(
|
||||
|
|
@ -311,33 +311,45 @@ async function run(): Promise<void> {
|
|||
playCdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl)
|
||||
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++) {
|
||||
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
|
||||
}
|
||||
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')
|
||||
await playCdp.evaluate('window.__d2PlayApp.plugins.whenStarted()')
|
||||
|
||||
const toolbarLink = await playCdp.evaluate<{
|
||||
exists: boolean
|
||||
legacyExists: boolean
|
||||
href: string
|
||||
target: 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 {
|
||||
exists: Boolean(el),
|
||||
legacyExists: Boolean(legacyEl),
|
||||
href: el?.getAttribute('href') ?? '',
|
||||
target: el?.getAttribute('target') ?? '',
|
||||
text: el?.textContent?.trim() ?? '',
|
||||
};
|
||||
})()`)
|
||||
assert(toolbarLink.exists, '#d2-toolbar-killstats must exist in play.html toolbar')
|
||||
assert(toolbarLink.href.endsWith('stats.html'), `Expected href ending with stats.html, got ${toolbarLink.href}`)
|
||||
assert(toolbarLink.exists, '#d2-toolbar-plugin-kill-stats-open must exist in play.html toolbar')
|
||||
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}`)
|
||||
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
|
||||
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 });
|
||||
|
||||
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)
|
||||
statsCdp = await CdpSession.connect(statsTarget.webSocketDebuggerUrl)
|
||||
|
||||
|
|
@ -534,13 +546,15 @@ async function run(): Promise<void> {
|
|||
if (ready) break
|
||||
}
|
||||
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
|
||||
const initialSnap = await statsCdp.evaluate<{
|
||||
outerOverflow: string
|
||||
sidebarOverflowY: string
|
||||
mainOverflowY: string
|
||||
backLinkHref: string
|
||||
disabledNoticeHidden: boolean
|
||||
sidebarText: string
|
||||
hasHeroSorc: boolean
|
||||
valNormal: string
|
||||
|
|
@ -554,6 +568,8 @@ async function run(): Promise<void> {
|
|||
const outer = document.querySelector('.d2-stats-shell') || document.getElementById('d2-stats-app');
|
||||
const sidebar = document.querySelector('.d2-stats__sidebar');
|
||||
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 pindleskinRow = document.querySelector('tr[data-canonical-id="Pindleskin"]');
|
||||
const fallenRow = document.querySelector('tr[data-monster-hcidx="19"]');
|
||||
|
|
@ -561,6 +577,8 @@ async function run(): Promise<void> {
|
|||
outerOverflow: outer ? getComputedStyle(outer).overflow : '',
|
||||
sidebarOverflowY: sidebar ? getComputedStyle(sidebar).overflowY : '',
|
||||
mainOverflowY: main ? getComputedStyle(main).overflowY : '',
|
||||
backLinkHref: backLink?.getAttribute('href') ?? '',
|
||||
disabledNoticeHidden: Boolean(disabledNotice && disabledNotice.hidden),
|
||||
sidebarText: sidebar?.textContent ?? '',
|
||||
hasHeroSorc: Boolean(document.querySelector('.d2-stats__char-btn[data-char-name="HeroSorc"]')),
|
||||
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.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.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.hasHeroSorc, 'Sidebar must include character button for HeroSorc')
|
||||
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 });
|
||||
await window.__d2PlayApp.killStats.flushNow();
|
||||
await window.__d2PlayApp.plugins.getInstance('kill-stats').tracker.flushNow();
|
||||
})()`)
|
||||
|
||||
let liveTotal = ''
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -10,7 +10,8 @@
|
|||
"src/client/**/*.ts",
|
||||
"src/common/**/*.ts",
|
||||
"src/common/**/*.d.ts",
|
||||
"src/netproto/**/*.ts"
|
||||
"src/netproto/**/*.ts",
|
||||
"plugins/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/server",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"skipLibCheck": true,
|
||||
"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": [
|
||||
"tests/e2e-drop-parity",
|
||||
"tests/fail-fast-drop.test.ts"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,27 @@
|
|||
import { existsSync, readdirSync, statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
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({
|
||||
plugins: [
|
||||
|
|
@ -51,7 +67,7 @@ export default defineConfig({
|
|||
rollupOptions: {
|
||||
input: {
|
||||
play: resolve(__dirname, 'play.html'),
|
||||
stats: resolve(__dirname, 'stats.html'),
|
||||
...discoverPluginHtmlEntries(__dirname),
|
||||
sw: resolve(__dirname, 'src/client/sw/sw.ts'),
|
||||
},
|
||||
output: {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export default defineConfig({
|
|||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/**'],
|
||||
include: ['src/**', 'plugins/**'],
|
||||
exclude: ['tools/**', 'samples/**']
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue