diff --git a/README.md b/README.md index 014a8bf..4a9ac09 100644 --- a/README.md +++ b/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)* ``` --- diff --git a/play.html b/play.html index 1d0f1cb..d4becc6 100644 --- a/play.html +++ b/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 @@
- + diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..117d6fc --- /dev/null +++ b/plugins/README.md @@ -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//**` 内部模块 + - **严禁直接导入 `src/client/world/**`、`src/client/session/**`、`src/client/ui/**`、`src/client/render/**` 等客户端内部实现**。 +4. **Rule 10 — 插件间严格物理隔离**: + - 每个插件必须自包含在 `plugins//` 子目录中,**严禁跨插件导入(`plugins//**` 不得导入 `plugins//**`)**。 + - 所有插件均运行在纯浏览器环境(`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` 接口并注册到 `plugins/index.ts` 的 `BUILTIN_PLUGINS` 数组中接入宿主: + +```ts +export interface D2WebPlugin { + readonly descriptor: PluginDescriptor; + create(ctx: PluginHostContext): PluginLifecycle; +} +``` + +- **`PluginDescriptor`**: + - `id`: 全局唯一 kebab-case 插件标识(如 `'kill-stats'`),必须与目录名 `plugins//` 一致。 + - `version`: 语义化版本号(如 `'1.0.0'`)。 + - `name` / `description`: `{ zh: string; en: string }` 双语名称与简介(在工具栏 **Plugins** 模态框中展示)。 + - `defaultEnabled`: 默认是否启用。 + - `capabilities`: 声明插件能力标签(`'event-subscriber' | 'standalone-page' | 'toolbar-shortcut' | 'storage-indexeddb'`)。 + - `standalonePagePath?`: 若包含独立页面,声明相对路径 `'plugins//index.html'`(Vite 构建会自动扫描 `plugins/*/index.html` 并打包为多页入口 `plugin-`)。 + - `toolbarAction?`: 工具栏快捷按钮与插件配置弹窗中的“打开页面”动作元数据。 +- **`PluginHostContext`**: + - `getLang()` / `setLang(lang)`: 读取或切换全局双语设置(`'zh' | 'en'`)。 + - `getDataRegistry()`: 获取已加载的 `D2DataRegistry`(若尚未加载完成则返回 `null`)。 + - `getSessionView()`: 获取只读会话视图 `PluginSessionView`(当前战网网关、账号名、角色名、角色职业/等级与存活怪物快照 `getMonsterSnapshot(unitId)`)。 + - `onServerEvent(listener)`: 订阅 D2GS `ServerEvent` 流。宿主会自动为每个事件调用包裹 `try/catch` 故障隔离:单个插件抛错不会中断游戏主循环或其他插件。 +- **`bootPluginPage(options)`**: + - 独立插件页面(`plugins//page-main.ts`)专用的标准引导辅助函数。自动读取 `PluginConfigStore` 的启用状态与全局语言,并监听跨标签页 `window.addEventListener('storage', ...)` 事件,实时触发 `onEnabledChange` 与 `onLangChange`。 + +--- + +## 4. 如何开发与接入新插件 + +1. **创建插件目录**:在 `plugins//` 下创建 `index.ts`(若需要独立 HTML 页面,创建 `plugins//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//` 下编写单元测试。 + - 运行 `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 掉落参考。 | diff --git a/plugins/index.ts b/plugins/index.ts new file mode 100644 index 0000000..dcca0e4 --- /dev/null +++ b/plugins/index.ts @@ -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] diff --git a/stats.html b/plugins/kill-stats/index.html similarity index 96% rename from stats.html rename to plugins/kill-stats/index.html index a1e33b6..9247939 100644 --- a/stats.html +++ b/plugins/kill-stats/index.html @@ -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 @@
- + diff --git a/plugins/kill-stats/index.ts b/plugins/kill-stats/index.ts new file mode 100644 index 0000000..daef995 --- /dev/null +++ b/plugins/kill-stats/index.ts @@ -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 +} + +export interface CreateKillStatsPluginOptions { + readonly repository?: KillStatsRepository | undefined + readonly trackerOptions?: Omit | 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 { + unsubSettings() + if (removeToolbarLink) { + removeToolbarLink() + removeToolbarLink = null + } + await tracker.flushNow() + tracker.destroy() + }, + } + }, + } +} + +export const killStatsPlugin: D2WebPlugin = createKillStatsPlugin() diff --git a/src/client/stats/kill-stats-model.ts b/plugins/kill-stats/kill-stats-model.ts similarity index 99% rename from src/client/stats/kill-stats-model.ts rename to plugins/kill-stats/kill-stats-model.ts index a6f7cce..e2eb4ab 100644 --- a/src/client/stats/kill-stats-model.ts +++ b/plugins/kill-stats/kill-stats-model.ts @@ -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 diff --git a/src/client/stats/kill-stats-store.ts b/plugins/kill-stats/kill-stats-store.ts similarity index 100% rename from src/client/stats/kill-stats-store.ts rename to plugins/kill-stats/kill-stats-store.ts diff --git a/src/client/stats/kill-stats-view.ts b/plugins/kill-stats/kill-stats-view.ts similarity index 98% rename from src/client/stats/kill-stats-view.ts rename to plugins/kill-stats/kill-stats-view.ts index 6a7e460..ed7f2e9 100644 --- a/src/client/stats/kill-stats-view.ts +++ b/plugins/kill-stats/kill-stats-view.ts @@ -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, diff --git a/src/client/stats/kill-tracker.ts b/plugins/kill-stats/kill-tracker.ts similarity index 77% rename from src/client/stats/kill-tracker.ts rename to plugins/kill-stats/kill-tracker.ts index c2a6a92..8985b00 100644 --- a/src/client/stats/kill-tracker.ts +++ b/plugins/kill-stats/kill-tracker.ts @@ -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,41 +145,71 @@ 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 { - charKey, - host, - realm, - account, - charName, - charClass, - level, - hardcore, - expansion, - ladder, + ok: true, + profile: { + charKey, + host, + realm, + account, + charName, + charClass, + level, + 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), } +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() private warnedMissingGameFlags = false private warnedMissingIdentity = false + private lastSkipReason: string | null = null private lastPhase: string private readonly pendingByChar = new Map() @@ -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) } diff --git a/src/common/units/monster-kill-classes.ts b/plugins/kill-stats/monster-kill-classes.ts similarity index 97% rename from src/common/units/monster-kill-classes.ts rename to plugins/kill-stats/monster-kill-classes.ts index 8298521..2089e6e 100644 --- a/src/common/units/monster-kill-classes.ts +++ b/plugins/kill-stats/monster-kill-classes.ts @@ -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 diff --git a/plugins/kill-stats/page-main.ts b/plugins/kill-stats/page-main.ts new file mode 100644 index 0000000..058c207 --- /dev/null +++ b/plugins/kill-stats/page-main.ts @@ -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 + getViewModel(): KillStatsViewModel | null + getLastExportJson(): string | null +} + +declare global { + interface Window { + __d2StatsApp?: StatsAppController + } +} + +export async function bootStatsApp(): Promise { + 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() diff --git a/src/client/stats/stats-i18n.ts b/plugins/kill-stats/stats-i18n.ts similarity index 92% rename from src/client/stats/stats-i18n.ts rename to plugins/kill-stats/stats-i18n.ts index fc717aa..7d12fb0 100644 --- a/src/client/stats/stats-i18n.ts +++ b/plugins/kill-stats/stats-i18n.ts @@ -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 @@ -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 { diff --git a/src/client/stats/stats-page.ts b/plugins/kill-stats/stats-page.ts similarity index 96% rename from src/client/stats/stats-page.ts rename to plugins/kill-stats/stats-page.ts index 79968c7..c1bb1a7 100644 --- a/src/client/stats/stats-page.ts +++ b/plugins/kill-stats/stats-page.ts @@ -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 | 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 { 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, diff --git a/src/client/README.md b/src/client/README.md index 1a9f60e..13e8841 100644 --- a/src/client/README.md +++ b/src/client/README.md @@ -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 清单的离线资产增量缓存 diff --git a/src/client/i18n/lang.ts b/src/client/i18n/lang.ts index 3d844c7..4831680 100644 --- a/src/client/i18n/lang.ts +++ b/src/client/i18n/lang.ts @@ -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 { +export async function startPlayApp(options?: { + readonly plugins?: readonly D2WebPlugin[] +}): Promise { window.__d2SkipAutoBoot = true const toolbarContainer = document.querySelector('#d2-toolbar-container') @@ -149,6 +155,7 @@ async function bootPlayApp(): Promise { 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 { onServerHostReconnect: (newHost) => { void sessionRef?.disconnect(`Reconnecting to ${newHost}`) }, + onPluginsToggle: () => { + pluginMenuRef?.toggle() + }, }, }) @@ -384,7 +394,16 @@ async function bootPlayApp(): Promise { }, }) 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 { window.addEventListener( 'keydown', (e) => { + if (isModalDomOverlayOpen()) { + return + } const activeEl = document.activeElement if ( activeEl && @@ -927,6 +949,9 @@ async function bootPlayApp(): Promise { window.addEventListener( 'keyup', (e) => { + if (isModalDomOverlayOpen()) { + return + } if (inGameView) { session.commandMapper.handleKeyUp(e) } @@ -1011,6 +1036,8 @@ async function bootPlayApp(): Promise { renderFrame(performance.now()) requestAnimationFrame(loop) + await plugins.whenStarted() + const controller: PlayAppController = { ready: true, bootProgress: bootProgress.getSnapshot(), @@ -1018,7 +1045,8 @@ async function bootPlayApp(): Promise { settings: settingsStore, settingsStore, session, - killStats, + plugins, + pluginMenu, toolbar, inspector, hudManager: session.hudManager, @@ -1058,11 +1086,3 @@ async function bootPlayApp(): Promise { 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) - }) -} diff --git a/src/client/plugin-host/api.ts b/src/client/plugin-host/api.ts new file mode 100644 index 0000000..198c13a --- /dev/null +++ b/src/client/plugin-host/api.ts @@ -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 +} + +export interface PluginInstance { + deactivate(): void | Promise + 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() + 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() + 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() + 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() + 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//index.html`). + * - Resolves language using URL `?lang=` > stored `d2web.settings.v1` > `` + * - 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() + }, + } +} diff --git a/src/client/plugin-host/plugin-config-store.ts b/src/client/plugin-host/plugin-config-store.ts new file mode 100644 index 0000000..8c80f6f --- /dev/null +++ b/src/client/plugin-host/plugin-config-store.ts @@ -0,0 +1,384 @@ +/** + * Persistent Plugin Configuration Store (`d2web.plugins.v1`). + * + * Schema: + * `{ version: 1, plugins: { [id]: { enabled: boolean, settings: Record } } }` + * + * 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 +} + +export interface StoredPluginsPayloadV1 { + readonly version: 1 + readonly plugins: Record +} + +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 | null | undefined +} + +interface MutablePluginState { + enabled: boolean + settings: Record +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function resolveDefaultStorage(): Pick | 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() + private readonly settingSpecsByPlugin = new Map>() + private readonly states = new Map() + private readonly unknownPlugins = new Map() + private readonly listeners = new Set() + private readonly storage: Pick | 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() + 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( + 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> { + 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 = {} + 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 = {} + 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) + } + } +} diff --git a/src/client/plugin-host/plugin-host.ts b/src/client/plugin-host/plugin-host.ts new file mode 100644 index 0000000..820231d --- /dev/null +++ b/src/client/plugin-host/plugin-host.ts @@ -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> + 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 + 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() + private readonly listeners = new Set<() => void>() + private readonly unsubConfig: () => void + private ready = false + private destroyed = false + private startPromise: Promise | 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 { + if (this.startPromise) { + return this.startPromise + } + const tasks: Promise[] = [] + 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 { + 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(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 { + const record = this.requireRecord(id) + this.configStore.setEnabled(id, enabled) + this.syncToolbarState() + this.notifyListeners() + return this.enqueueTransition(record, enabled) + } + + getSetting( + id: string, + key: string, + ): T { + this.requireRecord(id) + return this.configStore.getSetting(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 { + this.configStore.resetAllToDefaults() + const tasks: Promise[] = [] + 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 { + if (this.destroyed) return + this.destroyed = true + this.unsubConfig() + this.configStore.detachStorageListener() + const tasks: Promise[] = [] + 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 { + 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 { + 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 { + 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() + } + } +} diff --git a/src/client/plugin-host/plugin-menu-i18n.ts b/src/client/plugin-host/plugin-menu-i18n.ts new file mode 100644 index 0000000..efa6896 --- /dev/null +++ b/src/client/plugin-host/plugin-menu-i18n.ts @@ -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 +> = { + '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 +} diff --git a/src/client/plugin-host/plugin-menu-model.ts b/src/client/plugin-host/plugin-menu-model.ts new file mode 100644 index 0000000..b1df98e --- /dev/null +++ b/src/client/plugin-host/plugin-menu-model.ts @@ -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, + } +} diff --git a/src/client/plugin-host/plugin-menu.ts b/src/client/plugin-host/plugin-menu.ts new file mode 100644 index 0000000..cc07b6c --- /dev/null +++ b/src/client/plugin-host/plugin-menu.ts @@ -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) + } + } +} diff --git a/src/client/stats/stats-main.ts b/src/client/stats/stats-main.ts deleted file mode 100644 index 627b976..0000000 --- a/src/client/stats/stats-main.ts +++ /dev/null @@ -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 - getViewModel(): KillStatsViewModel | null - getLastExportJson(): string | null -} - -declare global { - interface Window { - __d2StatsApp?: StatsAppController - } -} - -async function bootStatsApp(): Promise { - 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() diff --git a/src/client/toolbar/toolbar.ts b/src/client/toolbar/toolbar.ts index ddb2779..4ff2fe9 100644 --- a/src/client/toolbar/toolbar.ts +++ b/src/client/toolbar/toolbar.ts @@ -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) | 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 = diff --git a/src/client/ui/dom-overlay.ts b/src/client/ui/dom-overlay.ts new file mode 100644 index 0000000..c25efad --- /dev/null +++ b/src/client/ui/dom-overlay.ts @@ -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 +} diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index 4d93572..0e6d8c1 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -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 diff --git a/tests/arch/boundaries.test.ts b/tests/arch/boundaries.test.ts index cbbcf1c..a9a51c0 100644 --- a/tests/arch/boundaries.test.ts +++ b/tests/arch/boundaries.test.ts @@ -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,31 +240,36 @@ 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(/]*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) - expect(viteCfg).not.toContain(legacyHtml) + if (legacyHtml === 'index.html') { + expect(viteCfg).not.toMatch(/resolve\(\s*__dirname\s*,\s*['"]index\.html['"]\)/) + } else { + expect(viteCfg).not.toContain(legacyHtml) + } } }) @@ -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) - 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) ?? ''})`) + 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 .//index.ts and src/client/plugin-host/api.ts + const allowedIndexTargets = new Set([ + '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: .//index.ts or src/client/plugin-host/api.ts)`, + ) + } + } + + // 2. plugins//** may only import within plugins//**, 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 ( - ts.isNewExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === 'Function' + spec.startsWith('node:') || + spec === 'fs' || + spec === 'path' || + spec === 'child_process' || + spec === 'url' || + spec === 'crypto' ) { - const callText = node.getText(sf) - if (callText.includes('import(')) { - dynamicViolations.push(`${relFile}: new Function dynamic import bypass (${callText})`) + 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.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//index.html declares local D2Exocet & D2Formal436 @font-face rules, contains zero http(s):// URLs, references only /plugins//... module scripts, and is auto-included in vite.config.ts build inputs', () => { + const pluginIds = listPluginIds() + const rawInput = (viteConfig.build?.rollupOptions?.input ?? {}) as Record + 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//... module scripts + const scriptTags = [...html.matchAll(/]*)>([\s\S]*?)<\/script>/gi)] + expect(scriptTags.length, `${relHtml} must contain at least one