feat(plugins): migrate automap-reveal (#714), lighting-presets (#715), extended-viewports (#716) and remove packet-inspector (#717)
This commit is contained in:
parent
290f433104
commit
fdde06b9cb
|
|
@ -19,8 +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` / 零 `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/index.ts` 与 `src/client/plugin-host/api.ts`,插件间严格物理隔离 | 独立于核心客户端的可拔插辅助扩展(如 `plugins/kill-stats/` 实时怪物击杀统计与独立页面 `plugins/kill-stats/index.html`),支持通过顶部工具栏 **Plugins** 菜单动态启停与快捷按钮配置 | [plugins/README.md](./plugins/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/index.ts` 与 `src/client/plugin-host/api.ts`,插件间严格物理隔离 | 独立于核心客户端的可拔插非 1.13c 辅助扩展(`kill-stats` 怪物击杀统计、`automap-reveal` 小地图全图揭示、`lighting-presets` 环境光照预设、`extended-viewports` 扩展高分宽屏视口),支持通过顶部工具栏 **Plugins** 菜单热插拔启停与 1.13c 原版回退 | [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) |
|
||||
|
|
|
|||
10
play.html
10
play.html
|
|
@ -419,15 +419,6 @@
|
|||
font-size: 11px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#d2-inspector-container {
|
||||
flex: 0 0 auto;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
z-index: 40;
|
||||
background: #0e0c0a;
|
||||
border-left: 1px solid #2b2319;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -464,7 +455,6 @@
|
|||
<div id="play-disconnect-banner" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="d2-inspector-container"></div>
|
||||
</div>
|
||||
<script type="module">
|
||||
import { startPlayApp } from '/src/client/main.ts'
|
||||
|
|
|
|||
|
|
@ -35,19 +35,25 @@
|
|||
|
||||
```text
|
||||
plugins/
|
||||
├── README.md # 插件系统架构说明、API 契约与后续迁移路线图
|
||||
├── README.md # 插件系统架构说明、API 契约与内置插件一览
|
||||
├── index.ts # BUILTIN_PLUGINS 静态注册表(由组合根 play.html 导入并注入 startPlayApp)
|
||||
└── kill-stats/ # 内置插件:怪物击杀统计 (id: 'kill-stats')
|
||||
├── index.ts # killStatsPlugin 定义、激活/卸载生命周期、状态汇报与工具栏快捷入口管理
|
||||
├── index.html # 独立统计页面入口 (/plugins/kill-stats/index.html)
|
||||
├── page-main.ts # 独立页面启动引导(调用 bootPluginPage(killStatsPlugin) 并捕获启动异常)
|
||||
├── stats-page.ts # 独立统计页面 DOM 控制器(IndexedDB 读取、BroadcastChannel 同步、导入/导出/删除)
|
||||
├── kill-tracker.ts # 实时击杀追踪器(监听 D2GS ServerEvent、严格校验角色身份并批量落盘)
|
||||
├── kill-stats-model.ts # G-counter CRDT 模型、键值规范化与严格导入校验 (KillStatsImportError)
|
||||
├── kill-stats-store.ts # IndexedDB ('d2web-kill-stats' v1) 存储仓库实现
|
||||
├── kill-stats-view.ts # 86 行首领/超级精英看板与怪物分类表纯视图模型构建器
|
||||
├── monster-kill-classes.ts# 基于 MonStats.txt (Align / killable / neverCount) 的可计数怪物分类表
|
||||
└── stats-i18n.ts # 统计页面中英双语词典
|
||||
├── kill-stats/ # 内置插件:怪物击杀统计 (id: 'kill-stats')
|
||||
│ ├── index.ts # killStatsPlugin 定义、激活/卸载生命周期、状态汇报与工具栏快捷入口管理
|
||||
│ ├── index.html # 独立统计页面入口 (/plugins/kill-stats/index.html)
|
||||
│ ├── page-main.ts # 独立页面启动引导(调用 bootPluginPage(killStatsPlugin) 并捕获启动异常)
|
||||
│ ├── stats-page.ts # 独立统计页面 DOM 控制器(IndexedDB 读取、BroadcastChannel 同步、导入/导出/删除)
|
||||
│ ├── kill-tracker.ts # 实时击杀追踪器(监听 D2GS ServerEvent、严格校验角色身份并批量落盘)
|
||||
│ ├── kill-stats-model.ts # G-counter CRDT 模型、键值规范化与严格导入校验 (KillStatsImportError)
|
||||
│ ├── kill-stats-store.ts # IndexedDB ('d2web-kill-stats' v1) 存储仓库实现
|
||||
│ ├── kill-stats-view.ts # 86 行首领/超级精英看板与怪物分类表纯视图模型构建器
|
||||
│ ├── monster-kill-classes.ts# 基于 MonStats.txt (Align / killable / neverCount) 的可计数怪物分类表
|
||||
│ └── stats-i18n.ts # 统计页面中英双语词典
|
||||
├── automap-reveal/ # 内置插件:小地图全图揭示 (id: 'automap-reveal', Issue #714)
|
||||
│ └── index.ts # 管理 automapReveal ('off' | 'level' | 'act') 与工具栏下拉快捷框 (#d2-toolbar-automap-select),禁用时强制回退 1.13c 'off'
|
||||
├── lighting-presets/ # 内置插件:环境光照预设 (id: 'lighting-presets', Issue #715)
|
||||
│ └── index.ts # 管理 lightingPreset (7 档光照预设) 与工具栏下拉快捷框 (#d2-toolbar-lighting-select),禁用时强制回退 1.13c 'auto'
|
||||
└── extended-viewports/ # 内置插件:扩展分辨率视口 (id: 'extended-viewports', Issue #716)
|
||||
└── index.ts # 解锁 1024x768 / 1068x600 / 1280x720 扩展视口,禁用时仅开放 1.13c 原版 640x480 / 800x600 并自动收敛至 800x600
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -57,7 +63,12 @@ plugins/
|
|||
`src/client/plugin-host/api.ts` 是 `plugins/**` 唯一允许导入的 `src/client/` 模块,导出以下核心类型与函数:
|
||||
|
||||
```ts
|
||||
export type { GameLang }
|
||||
export type {
|
||||
AutomapRevealMode,
|
||||
GameLang,
|
||||
LightingPresetId,
|
||||
ViewportResolutionId,
|
||||
}
|
||||
|
||||
export interface LocalizedText {
|
||||
readonly zh: string
|
||||
|
|
@ -121,16 +132,39 @@ export type PluginSessionView = Pick<
|
|||
'phase' | 'world' | 'loginHost' | 'getSnapshot' | 'onServerEvent' | 'onStateChange'
|
||||
>
|
||||
|
||||
export interface PluginClientCapabilities {
|
||||
getAutomapReveal(): AutomapRevealMode
|
||||
setAutomapReveal(mode: AutomapRevealMode): void
|
||||
subscribeAutomapReveal(fn: (mode: AutomapRevealMode) => void): () => void
|
||||
getLightingPreset(): LightingPresetId
|
||||
setLightingPreset(preset: LightingPresetId): void
|
||||
subscribeLightingPreset(fn: (preset: LightingPresetId) => void): () => void
|
||||
getViewport(): ViewportResolutionId
|
||||
setViewport(viewport: ViewportResolutionId): void
|
||||
getAllowedViewports(): readonly ViewportResolutionId[]
|
||||
setAllowedViewports(viewports: readonly ViewportResolutionId[]): void
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
readonly pluginId: string
|
||||
readonly session: PluginSessionView
|
||||
readonly client: PluginClientCapabilities
|
||||
readonly settings: {
|
||||
get(key: string): boolean | string
|
||||
set(key: string, value: boolean | string): void
|
||||
subscribe(fn: (key: string) => void): () => void
|
||||
}
|
||||
readonly toolbar: {
|
||||
addLink(spec: { id: string; label: LocalizedText; href: string }): () => void
|
||||
addButton(spec: { id: string; label: LocalizedText; onClick(): void }): () => void
|
||||
addSelect(spec: {
|
||||
id: string
|
||||
domId?: string
|
||||
ariaLabel: LocalizedText
|
||||
options: readonly { value: string; label: LocalizedText }[]
|
||||
getValue(): string
|
||||
onChange(value: string): void
|
||||
}): () => void
|
||||
}
|
||||
readonly lang: {
|
||||
get(): GameLang
|
||||
|
|
@ -160,7 +194,7 @@ export function bootPluginPage(plugin: D2WebPlugin): PluginPageContext
|
|||
|
||||
### 宿主生命周期与故障隔离保证(`PluginHost`)
|
||||
- **启动期快速失败校验(`validatePluginRegistry`)**:校验每个插件的 kebab-case `id` 唯一性、非空双语 `name`/`description`、唯一设置项 `key`、`select` 默认值合法性以及相对路径 `link.href`(拒绝 `http://`、`https://`、`//`、`javascript:` 或 `/` 开头路径)。
|
||||
- **串行化热插拔(`setEnabled(id, enabled)`)**:同一插件的启停切换按序串行收敛;`deactivate` 时等待 `await instance.deactivate()`(例如 `kill-stats` 会先执行 `await tracker.flushNow()` 落盘待写入击杀增量),随后宿主自动清理该插件注册的全部工具栏按钮/链接(`#d2-toolbar-plugin-slot`)及设置/语言订阅。
|
||||
- **串行化热插拔与 1.13c 原版回退(`setEnabled(id, enabled)`)**:同一插件的启停切换按序串行收敛;`deactivate` 时等待 `await instance.deactivate()`(例如 `kill-stats` 会先执行 `await tracker.flushNow()` 落盘待写入击杀增量),自动清理该插件注册的全部工具栏控件(`#d2-toolbar-plugin-slot`)及订阅,并自动将失去插件声明支撑的客户端能力回退至 1.13c 原版状态(`automapReveal -> 'off'`、`lightingPreset -> 'auto'`、`allowedViewports -> ['640x480', '800x600']`)。
|
||||
- **插件级异常隔离**:`activate`、`deactivate`、动作回调或订阅回调抛错/reject 时,宿主通过 `console.error` 记录堆栈、将该插件状态标记为 `level: 'error'` 并在工具栏 `#d2-toolbar-plugins-btn` 显示 `⚠`,绝不中断主游戏循环或其他插件。
|
||||
- **独立页面引导(`bootPluginPage(plugin)`)**:供 `plugins/<id>/page-main.ts` 调用,无需直接导入 `src/client/i18n/lang.ts` 或 `src/client/settings/settings-store.ts`,自动解析语言(URL `?lang=` > `localStorage['d2web.settings.v1']` > `<html lang>`)并通过 `storage` 事件实时同步语言与 `localStorage['d2web.plugins.v1']` 中的插件启停/设置状态。
|
||||
|
||||
|
|
@ -185,12 +219,12 @@ export function bootPluginPage(plugin: D2WebPlugin): PluginPageContext
|
|||
|
||||
---
|
||||
|
||||
## 6. 已迁移插件与后续插件迁移 Gitea Issues(#714–#717)
|
||||
## 6. 内置插件与 Gitea Issues(#714–#717)
|
||||
|
||||
| 插件目录 | 状态 | 追踪链接 | 说明 |
|
||||
| 插件目录 / 事项 | 状态 | 追踪链接 | 说明 |
|
||||
| :--- | :---: | :--- | :--- |
|
||||
| [`plugins/kill-stats/`](./kill-stats) | ✅ 已迁移 | 本轮已落地 | 怪物击杀统计插件与独立统计页面(`/plugins/kill-stats/index.html`),按 `(host, realm, account, charName, hardcore)` 追踪普通/噩梦/地狱难度下的怪物、首领与超级精英击杀数。 |
|
||||
| `plugins/automap-reveal` | 📋 待迁移 | [Issue #714](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/714) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/714`) | 将小地图全图揭示增强功能(Automap Reveal)从核心客户端解耦迁移至 `plugins/automap-reveal`。 |
|
||||
| `plugins/lighting-presets` | 📋 待迁移 | [Issue #715](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/715) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/715`) | 将分幕环境光照预设增强功能(Lighting Presets)从核心客户端解耦迁移至 `plugins/lighting-presets`。 |
|
||||
| `plugins/extended-viewports` | 📋 待迁移 | [Issue #716](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/716) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/716`) | 将非原版扩展分辨率视口预设(Extended Viewports,如宽屏/高分辨率档位)解耦迁移至 `plugins/extended-viewports`。 |
|
||||
| `plugins/packet-inspector` | 📋 待迁移 | [Issue #717](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/717) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/717`) | 将实时网络封包检查器与录制回放面板(Packet Inspector)解耦迁移至 `plugins/packet-inspector`。 |
|
||||
| [`plugins/kill-stats/`](./kill-stats) | ✅ 已落地 | 已上线 | 怪物击杀统计插件与独立统计页面(`/plugins/kill-stats/index.html`),按 `(host, realm, account, charName, hardcore)` 追踪普通/噩梦/地狱难度下的怪物、首领与超级精英击杀数。 |
|
||||
| [`plugins/automap-reveal/`](./automap-reveal) | ✅ 已落地 | [Issue #714](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/714) | 小地图全图揭示增强插件(`off / level / act`),可选在顶部工具栏显示 `#d2-toolbar-automap-select` 快捷下拉框;禁用时强制恢复 1.13c 原版战争迷雾(`off`)。 |
|
||||
| [`plugins/lighting-presets/`](./lighting-presets) | ✅ 已落地 | [Issue #715](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/715) | 环境光照预设增强插件(`auto / noon / dusk / night / torch / cold / fullbright`),可选在顶部工具栏显示 `#d2-toolbar-lighting-select` 快捷下拉框;禁用时强制恢复 1.13c 原版关卡环境光(`auto`)。 |
|
||||
| [`plugins/extended-viewports/`](./extended-viewports) | ✅ 已落地 | [Issue #716](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/716) | 扩展分辨率视口插件,启用时在工具栏分辨率下拉框中解锁 `1024x768`、`1068x600`、`1280x720`;禁用时仅保留 1.13c 原版 `640x480` 与 `800x600`,并将扩展分辨率自动收敛回 `800x600`。 |
|
||||
| 移除封包检查器 (`PacketInspector`) | ✅ 已移除 | [Issue #717](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/717) | 按精简要求彻底移除 `src/client/inspector/packet-inspector.ts` 与工具栏检查器按钮(保留 `OnlineSession` 的 `.d2cap` 离线录制重放能力)。 |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* Automap Reveal Plugin (`plugins/automap-reveal/index.ts` — Issue #714).
|
||||
*
|
||||
* Isolates the non-1.13c full-level (`level`) and full-act (`act`) automap reveal
|
||||
* enhancement outside core `src/`. When disabled, `PluginHost` releases the capability
|
||||
* claim and strictly enforces 1.13c fog-of-war (`automapReveal = 'off'`).
|
||||
*/
|
||||
|
||||
import {
|
||||
VALID_AUTOMAP_REVEAL_MODES,
|
||||
type AutomapRevealMode,
|
||||
type D2WebPlugin,
|
||||
type LocalizedText,
|
||||
type PluginContext,
|
||||
type PluginInstance,
|
||||
type PluginStatus,
|
||||
type PluginToolbarSelectOption,
|
||||
} from '../../src/client/plugin-host/api.ts'
|
||||
|
||||
const AUTOMAP_MODE_LABELS: Readonly<Record<AutomapRevealMode, LocalizedText>> = {
|
||||
off: {
|
||||
zh: '关闭',
|
||||
en: 'Off',
|
||||
},
|
||||
level: {
|
||||
zh: '本层全开',
|
||||
en: 'Current Level',
|
||||
},
|
||||
act: {
|
||||
zh: '整幕全开',
|
||||
en: 'Full Act',
|
||||
},
|
||||
}
|
||||
|
||||
const AUTOMAP_SELECT_OPTIONS: readonly PluginToolbarSelectOption[] =
|
||||
VALID_AUTOMAP_REVEAL_MODES.map((mode) => ({
|
||||
value: mode,
|
||||
label: AUTOMAP_MODE_LABELS[mode],
|
||||
}))
|
||||
|
||||
function isAutomapRevealMode(value: unknown): value is AutomapRevealMode {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(VALID_AUTOMAP_REVEAL_MODES as readonly string[]).includes(value)
|
||||
)
|
||||
}
|
||||
|
||||
export interface AutomapRevealPluginInstance extends PluginInstance {
|
||||
getMode(): AutomapRevealMode
|
||||
setMode(mode: AutomapRevealMode): void
|
||||
}
|
||||
|
||||
export function createAutomapRevealPlugin(): D2WebPlugin {
|
||||
return {
|
||||
id: 'automap-reveal',
|
||||
name: {
|
||||
zh: '小地图全图透视',
|
||||
en: 'Automap Reveal',
|
||||
},
|
||||
description: {
|
||||
zh: '非 1.13c 增强:支持一键揭开本层或整幕全部小地图房间与传送点标记。关闭插件后严格恢复 1.13c 原版战争迷雾(仅显示服务端已探索区域)。',
|
||||
en: 'Non-1.13c enhancement: reveals all rooms and warp markers for the current level or entire Act. When disabled, strictly enforces 1.13c server-driven fog of war.',
|
||||
},
|
||||
defaultEnabled: true,
|
||||
settings: [
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'mode',
|
||||
label: {
|
||||
zh: '透视范围',
|
||||
en: 'Reveal Scope',
|
||||
},
|
||||
default: 'off',
|
||||
options: AUTOMAP_SELECT_OPTIONS,
|
||||
},
|
||||
{
|
||||
kind: 'boolean',
|
||||
key: 'showToolbarShortcut',
|
||||
label: {
|
||||
zh: '在工具栏显示快捷下拉框',
|
||||
en: 'Show selector in toolbar',
|
||||
},
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
activate(ctx: PluginContext): AutomapRevealPluginInstance {
|
||||
const storedSetting = ctx.settings.get('mode')
|
||||
const initialMode: AutomapRevealMode = isAutomapRevealMode(storedSetting)
|
||||
? storedSetting
|
||||
: 'off'
|
||||
|
||||
ctx.client.setAutomapReveal(initialMode)
|
||||
|
||||
const unsubClient = ctx.client.subscribeAutomapReveal((clientMode) => {
|
||||
if (ctx.settings.get('mode') !== clientMode) {
|
||||
ctx.settings.set('mode', clientMode)
|
||||
}
|
||||
ctx.notifyStatusChanged()
|
||||
})
|
||||
|
||||
let removeToolbarSelect: (() => void) | null = null
|
||||
const syncToolbarShortcut = (): void => {
|
||||
const show = ctx.settings.get('showToolbarShortcut') === true
|
||||
if (show && !removeToolbarSelect) {
|
||||
removeToolbarSelect = ctx.toolbar.addSelect({
|
||||
id: 'mode',
|
||||
selectDomId: 'd2-toolbar-automap-select',
|
||||
label: {
|
||||
zh: '小地图',
|
||||
en: 'Automap',
|
||||
},
|
||||
options: AUTOMAP_SELECT_OPTIONS,
|
||||
getValue: () => {
|
||||
const raw = ctx.settings.get('mode')
|
||||
return isAutomapRevealMode(raw) ? raw : 'off'
|
||||
},
|
||||
onChange: (value: string) => {
|
||||
if (!isAutomapRevealMode(value)) {
|
||||
throw new Error(`Invalid automap reveal mode: ${value}`)
|
||||
}
|
||||
ctx.settings.set('mode', value)
|
||||
},
|
||||
})
|
||||
} else if (!show && removeToolbarSelect) {
|
||||
removeToolbarSelect()
|
||||
removeToolbarSelect = null
|
||||
}
|
||||
}
|
||||
|
||||
syncToolbarShortcut()
|
||||
|
||||
const unsubSettings = ctx.settings.subscribe((key) => {
|
||||
if (key === 'mode') {
|
||||
const next = ctx.settings.get('mode')
|
||||
if (isAutomapRevealMode(next)) {
|
||||
ctx.client.setAutomapReveal(next)
|
||||
ctx.notifyStatusChanged()
|
||||
}
|
||||
} else if (key === 'showToolbarShortcut') {
|
||||
syncToolbarShortcut()
|
||||
}
|
||||
})
|
||||
|
||||
const getStatus = (): PluginStatus => {
|
||||
const mode = ctx.client.getAutomapReveal()
|
||||
const label = AUTOMAP_MODE_LABELS[mode]
|
||||
return {
|
||||
level: 'ok',
|
||||
text: {
|
||||
zh: `当前:${label.zh}`,
|
||||
en: `Mode: ${label.en}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getMode: () => ctx.client.getAutomapReveal(),
|
||||
setMode: (mode: AutomapRevealMode) => {
|
||||
if (!isAutomapRevealMode(mode)) {
|
||||
throw new Error(`Invalid automap reveal mode: ${String(mode)}`)
|
||||
}
|
||||
ctx.settings.set('mode', mode)
|
||||
},
|
||||
getStatus,
|
||||
deactivate(): void {
|
||||
unsubClient()
|
||||
unsubSettings()
|
||||
if (removeToolbarSelect) {
|
||||
removeToolbarSelect()
|
||||
removeToolbarSelect = null
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const automapRevealPlugin: D2WebPlugin = createAutomapRevealPlugin()
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* Extended Viewports Plugin (`plugins/extended-viewports/index.ts` — Issue #716).
|
||||
*
|
||||
* Isolates the non-1.13c extended resolutions (`1024x768`, `1068x600`, `1280x720`)
|
||||
* outside core `src/`. When enabled, unlocks all 5 resolutions in the toolbar viewport
|
||||
* selector; when disabled, `PluginHost` restricts allowed viewports to native 1.13c
|
||||
* `640x480` and `800x600` and clamps any extended resolution back to `800x600`.
|
||||
*/
|
||||
|
||||
import {
|
||||
VALID_VIEWPORT_RESOLUTIONS,
|
||||
formatViewportLabel,
|
||||
type D2WebPlugin,
|
||||
type PluginContext,
|
||||
type PluginInstance,
|
||||
type PluginStatus,
|
||||
type PluginToolbarSelectOption,
|
||||
type ViewportResolution,
|
||||
} from '../../src/client/plugin-host/api.ts'
|
||||
|
||||
const VIEWPORT_SELECT_OPTIONS: readonly PluginToolbarSelectOption[] =
|
||||
VALID_VIEWPORT_RESOLUTIONS.map((vp) => {
|
||||
const label = formatViewportLabel(vp)
|
||||
return {
|
||||
value: vp,
|
||||
label: {
|
||||
zh: label,
|
||||
en: label,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function isViewportResolution(value: unknown): value is ViewportResolution {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(VALID_VIEWPORT_RESOLUTIONS as readonly string[]).includes(value)
|
||||
)
|
||||
}
|
||||
|
||||
export interface ExtendedViewportsPluginInstance extends PluginInstance {
|
||||
getViewport(): ViewportResolution
|
||||
setViewport(viewport: ViewportResolution): void
|
||||
}
|
||||
|
||||
export function createExtendedViewportsPlugin(): D2WebPlugin {
|
||||
return {
|
||||
id: 'extended-viewports',
|
||||
name: {
|
||||
zh: '扩展高分与宽屏视口',
|
||||
en: 'Extended Viewports',
|
||||
},
|
||||
description: {
|
||||
zh: '非 1.13c 增强:解锁 1024×768、1068×600 (16:9) 与 1280×720 (HD) 扩展视口。关闭插件后仅开放 1.13c 原版 640×480 与 800×600 分辨率,并自动回退至 800×600。',
|
||||
en: 'Non-1.13c enhancement: unlocks 1024×768, 1068×600 (16:9), and 1280×720 (HD) viewports. When disabled, restricts the client to native 1.13c 640×480 and 800×600 resolutions.',
|
||||
},
|
||||
defaultEnabled: true,
|
||||
settings: [
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'viewport',
|
||||
label: {
|
||||
zh: '当前视口分辨率',
|
||||
en: 'Active Viewport',
|
||||
},
|
||||
default: '800x600',
|
||||
options: VIEWPORT_SELECT_OPTIONS,
|
||||
},
|
||||
],
|
||||
activate(ctx: PluginContext): ExtendedViewportsPluginInstance {
|
||||
// Unlock all 5 viewport profiles (native 640x480, 800x600 + extended 1024x768, 1068x600, 1280x720)
|
||||
ctx.client.setAllowedViewports(VALID_VIEWPORT_RESOLUTIONS)
|
||||
|
||||
const storedSetting = ctx.settings.get('viewport')
|
||||
const initialViewport: ViewportResolution = isViewportResolution(storedSetting)
|
||||
? storedSetting
|
||||
: '800x600'
|
||||
|
||||
ctx.client.setViewport(initialViewport)
|
||||
|
||||
const unsubClient = ctx.client.subscribeViewport((clientVp) => {
|
||||
if (ctx.settings.get('viewport') !== clientVp) {
|
||||
ctx.settings.set('viewport', clientVp)
|
||||
}
|
||||
ctx.notifyStatusChanged()
|
||||
})
|
||||
|
||||
const unsubSettings = ctx.settings.subscribe((key) => {
|
||||
if (key === 'viewport') {
|
||||
const next = ctx.settings.get('viewport')
|
||||
if (isViewportResolution(next)) {
|
||||
ctx.client.setViewport(next)
|
||||
ctx.notifyStatusChanged()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const getStatus = (): PluginStatus => {
|
||||
const vp = ctx.client.getViewport()
|
||||
const label = formatViewportLabel(vp)
|
||||
return {
|
||||
level: 'ok',
|
||||
text: {
|
||||
zh: `当前:${label}`,
|
||||
en: `Viewport: ${label}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getViewport: () => ctx.client.getViewport(),
|
||||
setViewport: (viewport: ViewportResolution) => {
|
||||
if (!isViewportResolution(viewport)) {
|
||||
throw new Error(`Invalid viewport resolution: ${String(viewport)}`)
|
||||
}
|
||||
ctx.settings.set('viewport', viewport)
|
||||
},
|
||||
getStatus,
|
||||
deactivate(): void {
|
||||
unsubClient()
|
||||
unsubSettings()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const extendedViewportsPlugin: D2WebPlugin = createExtendedViewportsPlugin()
|
||||
|
|
@ -3,6 +3,14 @@
|
|||
*/
|
||||
|
||||
import type { D2WebPlugin } from '../src/client/plugin-host/api.ts'
|
||||
import { automapRevealPlugin } from './automap-reveal/index.ts'
|
||||
import { extendedViewportsPlugin } from './extended-viewports/index.ts'
|
||||
import { killStatsPlugin } from './kill-stats/index.ts'
|
||||
import { lightingPresetsPlugin } from './lighting-presets/index.ts'
|
||||
|
||||
export const BUILTIN_PLUGINS: readonly D2WebPlugin[] = [killStatsPlugin]
|
||||
export const BUILTIN_PLUGINS: readonly D2WebPlugin[] = [
|
||||
killStatsPlugin,
|
||||
automapRevealPlugin,
|
||||
lightingPresetsPlugin,
|
||||
extendedViewportsPlugin,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Lighting Presets Plugin (`plugins/lighting-presets/index.ts` — Issue #715).
|
||||
*
|
||||
* Isolates the non-1.13c manual lighting preset selector (`noon`, `dusk`, `night`,
|
||||
* `torch`, `cold`, `fullbright`) outside core `src/`. When disabled, `PluginHost`
|
||||
* releases the capability claim and strictly enforces 1.13c native level lighting (`auto`).
|
||||
*/
|
||||
|
||||
import {
|
||||
CLIENT_LIGHTING_PRESET_ORDER,
|
||||
LIGHTING_PRESETS,
|
||||
VALID_LIGHTING_PRESETS,
|
||||
type ClientLightingPreset,
|
||||
type D2WebPlugin,
|
||||
type PluginContext,
|
||||
type PluginInstance,
|
||||
type PluginStatus,
|
||||
type PluginToolbarSelectOption,
|
||||
} from '../../src/client/plugin-host/api.ts'
|
||||
|
||||
const LIGHTING_SELECT_OPTIONS: readonly PluginToolbarSelectOption[] =
|
||||
CLIENT_LIGHTING_PRESET_ORDER.map((presetId) => {
|
||||
const cfg = LIGHTING_PRESETS[presetId]
|
||||
return {
|
||||
value: presetId,
|
||||
label: {
|
||||
zh: cfg.labelZh,
|
||||
en: cfg.labelEn,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function isLightingPreset(value: unknown): value is ClientLightingPreset {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(VALID_LIGHTING_PRESETS as readonly string[]).includes(value)
|
||||
)
|
||||
}
|
||||
|
||||
export interface LightingPresetsPluginInstance extends PluginInstance {
|
||||
getPreset(): ClientLightingPreset
|
||||
setPreset(preset: ClientLightingPreset): void
|
||||
}
|
||||
|
||||
export function createLightingPresetsPlugin(): D2WebPlugin {
|
||||
return {
|
||||
id: 'lighting-presets',
|
||||
name: {
|
||||
zh: '光照预设切换',
|
||||
en: 'Lighting Presets',
|
||||
},
|
||||
description: {
|
||||
zh: '非 1.13c 增强:允许手动切换全局环境光与火把照明预设(正午/黄昏/黑夜/火把/寒光/全亮)。关闭插件后严格恢复 1.13c 原版关卡环境光(自动)。',
|
||||
en: 'Non-1.13c enhancement: manually override ambient RGB and player torch radius (Noon, Dusk, Night, Torch, Cold, Fullbright). When disabled, strictly enforces 1.13c level lighting (Auto).',
|
||||
},
|
||||
defaultEnabled: true,
|
||||
settings: [
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'preset',
|
||||
label: {
|
||||
zh: '光照模式',
|
||||
en: 'Lighting Mode',
|
||||
},
|
||||
default: 'auto',
|
||||
options: LIGHTING_SELECT_OPTIONS,
|
||||
},
|
||||
{
|
||||
kind: 'boolean',
|
||||
key: 'showToolbarShortcut',
|
||||
label: {
|
||||
zh: '在工具栏显示快捷下拉框',
|
||||
en: 'Show selector in toolbar',
|
||||
},
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
activate(ctx: PluginContext): LightingPresetsPluginInstance {
|
||||
const storedSetting = ctx.settings.get('preset')
|
||||
const initialPreset: ClientLightingPreset = isLightingPreset(storedSetting)
|
||||
? storedSetting
|
||||
: 'auto'
|
||||
|
||||
ctx.client.setLightingPreset(initialPreset)
|
||||
|
||||
const unsubClient = ctx.client.subscribeLightingPreset((clientPreset) => {
|
||||
if (ctx.settings.get('preset') !== clientPreset) {
|
||||
ctx.settings.set('preset', clientPreset)
|
||||
}
|
||||
ctx.notifyStatusChanged()
|
||||
})
|
||||
|
||||
let removeToolbarSelect: (() => void) | null = null
|
||||
const syncToolbarShortcut = (): void => {
|
||||
const show = ctx.settings.get('showToolbarShortcut') === true
|
||||
if (show && !removeToolbarSelect) {
|
||||
removeToolbarSelect = ctx.toolbar.addSelect({
|
||||
id: 'preset',
|
||||
selectDomId: 'd2-toolbar-lighting-select',
|
||||
label: {
|
||||
zh: '光照',
|
||||
en: 'Lighting',
|
||||
},
|
||||
options: LIGHTING_SELECT_OPTIONS,
|
||||
getValue: () => {
|
||||
const raw = ctx.settings.get('preset')
|
||||
return isLightingPreset(raw) ? raw : 'auto'
|
||||
},
|
||||
onChange: (value: string) => {
|
||||
if (!isLightingPreset(value)) {
|
||||
throw new Error(`Invalid lighting preset: ${value}`)
|
||||
}
|
||||
ctx.settings.set('preset', value)
|
||||
},
|
||||
})
|
||||
} else if (!show && removeToolbarSelect) {
|
||||
removeToolbarSelect()
|
||||
removeToolbarSelect = null
|
||||
}
|
||||
}
|
||||
|
||||
syncToolbarShortcut()
|
||||
|
||||
const unsubSettings = ctx.settings.subscribe((key) => {
|
||||
if (key === 'preset') {
|
||||
const next = ctx.settings.get('preset')
|
||||
if (isLightingPreset(next)) {
|
||||
ctx.client.setLightingPreset(next)
|
||||
ctx.notifyStatusChanged()
|
||||
}
|
||||
} else if (key === 'showToolbarShortcut') {
|
||||
syncToolbarShortcut()
|
||||
}
|
||||
})
|
||||
|
||||
const getStatus = (): PluginStatus => {
|
||||
const preset = ctx.client.getLightingPreset()
|
||||
const cfg = LIGHTING_PRESETS[preset]
|
||||
return {
|
||||
level: 'ok',
|
||||
text: {
|
||||
zh: `当前:${cfg.labelZh}`,
|
||||
en: `Preset: ${cfg.labelEn}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getPreset: () => ctx.client.getLightingPreset(),
|
||||
setPreset: (preset: ClientLightingPreset) => {
|
||||
if (!isLightingPreset(preset)) {
|
||||
throw new Error(`Invalid lighting preset: ${String(preset)}`)
|
||||
}
|
||||
ctx.settings.set('preset', preset)
|
||||
},
|
||||
getStatus,
|
||||
deactivate(): void {
|
||||
unsubClient()
|
||||
unsubSettings()
|
||||
if (removeToolbarSelect) {
|
||||
removeToolbarSelect()
|
||||
removeToolbarSelect = null
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const lightingPresetsPlugin: D2WebPlugin = createLightingPresetsPlugin()
|
||||
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
- **主页面入口与非侵入式插件宿主**:
|
||||
- [`play.html`](../../play.html) 作为顶层组合根(Composition Root),通过内联 `<script type="module">` 导入 [`src/client/main.ts`](./main.ts) 的 `startPlayApp` 与 [`plugins/index.ts`](../../plugins/index.ts) 的 `BUILTIN_PLUGINS` 并启动客户端。
|
||||
- 可选客户端扩展插件统一解耦至顶层 [`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`)、生命周期与语言切换钩子以及顶部工具栏插件配置菜单(`PluginMenu`,挂载 `data-d2-modal-overlay="plugins"` 并配合 `ui/dom-overlay.ts` 的 `isModalDomOverlayOpen()` 实现键盘输入隔离)。组合根位于 [`play.html`](../../play.html),**`src/client/**` 具有零个来自 `plugins/**` 的导入且严禁包含任何具体插件 ID 引用**(由 `tests/arch/boundaries.test.ts` Rule 2 与 Rule 7–10 强制守卫)。
|
||||
- 可选客户端扩展插件统一解耦至顶层 [`plugins/`](../../plugins) 目录(含 `kill-stats`、`automap-reveal`、`lighting-presets` 与 `extended-viewports` 四个内置插件,以及独立统计页 [`plugins/kill-stats/index.html`](../../plugins/kill-stats/index.html))。
|
||||
- `src/client` 通过 [`src/client/plugin-host/`](./plugin-host) 向插件提供只读会话视图(`PluginSessionView`)、受控客户端增强能力门面(`PluginClientCapabilities`)、生命周期与语言切换钩子以及顶部工具栏插件配置菜单(`PluginMenu`,挂载 `data-d2-modal-overlay="plugins"` 并配合 `ui/dom-overlay.ts` 的 `isModalDomOverlayOpen()` 实现键盘输入隔离)。当插件被禁用时,宿主自动回退至 1.13c 原版默认行为(迷雾探索 `automapReveal: 'off'`、原版关卡光照 `lightingPreset: 'auto'`、原版 `640x480` / `800x600` 分辨率)。组合根位于 [`play.html`](../../play.html),**`src/client/**` 具有零个来自 `plugins/**` 的导入且严禁包含任何具体插件 ID 引用**(由 `tests/arch/boundaries.test.ts` Rule 2 与 Rule 7–10 强制守卫)。
|
||||
- **TypeScript 工程约束(`tsconfig.client.json`)**:
|
||||
- `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。
|
||||
- **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
```text
|
||||
src/client/
|
||||
├── main.ts # 客户端主入口:组装 WebGL2 画布、工具栏、前端战网状态机、OnlineSession、PluginHost 与 60fps 渲染循环
|
||||
├── session/ # OnlineSession 会话协调器与 C2S 发包速率限制器 (OutboundRateLimiter)
|
||||
├── session/ # OnlineSession 会话协调器、.d2cap 离线重放器与 C2S 发包速率限制器 (OutboundRateLimiter)
|
||||
├── world/ # ClientWorld:由 ServerEvent 驱动的客户端权威状态镜像 (玩家/怪物/NPC/地面物品/投射物/传送门)
|
||||
├── map/ # 种子驱动的客户端 DRLG 地图服务 (MapService, LevelView, TileAtlas GPU 瓦片加载)
|
||||
├── motion/ # 本地玩家移动预测 (LocalMovementPredictor)、远端实体插值器与 A* 寻路 (pathfind)
|
||||
|
|
@ -31,10 +31,9 @@ src/client/
|
|||
├── ui/ # 游戏内 HUD 渲染与交互 (HudManager, 底部状态栏, 小地图 Automap, 7 大面板, D2 点阵字体渲染, DOM 浮层焦点守卫)
|
||||
├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑)
|
||||
├── frontend/ # 战网前置界面 (FrontendRenderer:Gateway 选择、账号登录/注册、8 角色选择、建房/加房大厅)
|
||||
├── toolbar/ # 顶部 32px 窄条工具栏 (FPS/Ping、网络抓包开关、插件快捷按钮插槽、Plugins 配置菜单入口、设置弹窗、全屏与音量控制)
|
||||
├── plugin-host/ # 非侵入式插件宿主与配置中心 (PluginHost, PluginConfigStore, PluginMenu [data-d2-modal-overlay="plugins"] 与只读契约 api.ts)
|
||||
├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理 WS Bridge 地址与显示偏好)
|
||||
├── inspector/ # 实时网络封包检查器 (PacketInspector) 与 .d2cap 录制/回放面板
|
||||
├── toolbar/ # 顶部 32px 窄条工具栏 (连接状态与 RTT、服务器地址、分辨率切换、音量/语言控制、Plugins 菜单入口与动态插件工具栏插槽)
|
||||
├── plugin-host/ # 非侵入式插件宿主与配置中心 (PluginHost, PluginConfigStore, PluginMenu [data-d2-modal-overlay="plugins"] 与公共契约 api.ts)
|
||||
├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理服务器地址与显示偏好)
|
||||
├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存
|
||||
├── audio/ # Web Audio API 音效与背景音乐服务 (SoundService)
|
||||
└── assets/ # OPFS (Origin Private File System) 本地 MPQ 导入与缓存支持
|
||||
|
|
|
|||
|
|
@ -281,7 +281,6 @@ export type ToolbarI18nKey =
|
|||
| 'toolbar.automap.level'
|
||||
| 'toolbar.automap.act'
|
||||
| 'toolbar.viewport.label'
|
||||
| 'toolbar.inspector.toggle'
|
||||
| 'toolbar.plugins.open'
|
||||
| 'toolbar.cache.clear'
|
||||
| 'toolbar.cache.cleared'
|
||||
|
|
@ -310,7 +309,6 @@ export const TOOLBAR_I18N: Readonly<Record<ToolbarI18nKey, { readonly zh: string
|
|||
'toolbar.automap.level': { zh: '本层', en: 'Level' },
|
||||
'toolbar.automap.act': { zh: '全幕', en: 'Act' },
|
||||
'toolbar.viewport.label': { zh: '分辨率', en: 'Viewport' },
|
||||
'toolbar.inspector.toggle': { zh: '抓包面板', en: 'Packets' },
|
||||
'toolbar.plugins.open': { zh: '插件', en: 'Plugins' },
|
||||
'toolbar.cache.clear': { zh: '清除缓存', en: 'Clear Cache' },
|
||||
'toolbar.cache.cleared': { zh: '已清缓存', en: 'Cleared' },
|
||||
|
|
|
|||
|
|
@ -1,556 +0,0 @@
|
|||
import {
|
||||
bytesToHex,
|
||||
formatD2CapEntry,
|
||||
hexToBytes,
|
||||
parseD2CapJsonl,
|
||||
type ClientCommand,
|
||||
type D2CapHeader,
|
||||
type D2CapRecord,
|
||||
type PacketConn,
|
||||
type PacketDirection,
|
||||
type PacketTap,
|
||||
type PacketTapEntry,
|
||||
type ServerEvent,
|
||||
} from '../../netproto/index.ts'
|
||||
import { getGlobalSettingsStore, type SettingsStore } from '../settings/settings-store.ts'
|
||||
|
||||
export type PacketPhase = 'bncs' | 'mcp' | 'd2gs' | 'ts'
|
||||
|
||||
export function connToPhase(conn: PacketConn | PacketPhase): PacketPhase {
|
||||
if (conn === 'bnet' || conn === 'bncs') return 'bncs'
|
||||
if (conn === 'realm' || conn === 'mcp') return 'mcp'
|
||||
if (conn === 'game' || conn === 'd2gs') return 'd2gs'
|
||||
return 'ts'
|
||||
}
|
||||
|
||||
export function phaseToConn(phaseOrConn: PacketPhase | PacketConn): PacketConn {
|
||||
if (phaseOrConn === 'bncs' || phaseOrConn === 'bnet') return 'bnet'
|
||||
if (phaseOrConn === 'mcp' || phaseOrConn === 'realm') return 'realm'
|
||||
if (phaseOrConn === 'd2gs' || phaseOrConn === 'game') return 'game'
|
||||
return 'ts'
|
||||
}
|
||||
|
||||
export function resolvePacketOpcode(
|
||||
conn: PacketConn,
|
||||
raw: Uint8Array,
|
||||
explicitPacketId?: number,
|
||||
): number {
|
||||
if (explicitPacketId !== undefined) {
|
||||
return explicitPacketId & 0xff
|
||||
}
|
||||
if (raw.byteLength === 0) return 0
|
||||
if (conn === 'bnet' && raw[0] === 0xff && raw.byteLength >= 2) {
|
||||
return raw[1]! & 0xff
|
||||
}
|
||||
return raw[0]! & 0xff
|
||||
}
|
||||
|
||||
export interface InspectedPacketRow {
|
||||
readonly seq: number
|
||||
readonly timestampMs: number
|
||||
readonly conn: PacketConn
|
||||
readonly phase: PacketPhase
|
||||
readonly direction: PacketDirection
|
||||
readonly opcode: number
|
||||
readonly packetId: number
|
||||
readonly packetName?: string | undefined
|
||||
readonly opcodeHex: string
|
||||
readonly byteLength: number
|
||||
readonly bytes: Uint8Array
|
||||
readonly hexDump: string
|
||||
readonly decodedSummary?: string | undefined
|
||||
readonly decodedPayload?: ServerEvent | ClientCommand | Record<string, unknown> | unknown
|
||||
readonly decoded?: ServerEvent | ClientCommand | Record<string, unknown> | unknown
|
||||
}
|
||||
|
||||
export interface PacketInspectorFilter {
|
||||
readonly phase?: PacketPhase | PacketConn | 'all' | undefined
|
||||
readonly conn?: PacketConn | PacketPhase | 'all' | undefined
|
||||
readonly direction?: PacketDirection | 'all' | undefined
|
||||
readonly opcode?: number | string | null | undefined
|
||||
readonly search?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a `Uint8Array` into a canonical 16-byte-per-line hex + ASCII dump.
|
||||
*/
|
||||
export function formatHexDump(bytes: Uint8Array, maxBytes = 256): string {
|
||||
const len = Math.min(bytes.byteLength, maxBytes)
|
||||
if (len === 0) return ''
|
||||
const lines: string[] = []
|
||||
for (let offset = 0; offset < len; offset += 16) {
|
||||
const rowEnd = Math.min(len, offset + 16)
|
||||
const hexParts: string[] = []
|
||||
let ascii = ''
|
||||
for (let i = offset; i < offset + 16; i++) {
|
||||
if (i < rowEnd) {
|
||||
const b = bytes[i]!
|
||||
hexParts.push(b.toString(16).padStart(2, '0'))
|
||||
ascii += b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : '.'
|
||||
} else {
|
||||
hexParts.push(' ')
|
||||
}
|
||||
}
|
||||
const addr = offset.toString(16).padStart(4, '0')
|
||||
lines.push(`${addr} ${hexParts.join(' ')} |${ascii}|`)
|
||||
}
|
||||
if (bytes.byteLength > maxBytes) {
|
||||
lines.push(`... (${bytes.byteLength - maxBytes} more bytes)`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function formatOpcodeHex(opcode: number): string {
|
||||
return `0x${(opcode & 0xff).toString(16).padStart(2, '0').toUpperCase()}`
|
||||
}
|
||||
|
||||
function parseOpcodeFilter(raw: number | string | null | undefined): number | null {
|
||||
if (raw === null || raw === undefined) return null
|
||||
if (typeof raw === 'number') {
|
||||
return Number.isInteger(raw) && raw >= 0 && raw <= 0xff ? raw : null
|
||||
}
|
||||
const clean = raw.trim().toLowerCase()
|
||||
if (clean === '' || clean === 'all') return null
|
||||
const num = clean.startsWith('0x')
|
||||
? Number.parseInt(clean.slice(2), 16)
|
||||
: Number.parseInt(clean, 16)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 0xff ? num : null
|
||||
}
|
||||
|
||||
export interface ReplayedD2CapFrame {
|
||||
readonly record: D2CapRecord
|
||||
readonly opcode: number
|
||||
readonly packetId: number
|
||||
readonly conn: PacketConn
|
||||
readonly phase: PacketPhase
|
||||
readonly dir: PacketDirection
|
||||
readonly bytes: Uint8Array
|
||||
readonly index: number
|
||||
}
|
||||
|
||||
export interface PacketInspectorOptions {
|
||||
readonly store?: SettingsStore | undefined
|
||||
readonly tap?: PacketTap | undefined
|
||||
readonly maxEntries?: number | undefined
|
||||
readonly maxRows?: number | undefined
|
||||
readonly container?: HTMLElement | null | undefined
|
||||
readonly onReplayRecord?:
|
||||
| ((frame: ReplayedD2CapFrame, rawBytes: Uint8Array, index: number) => void)
|
||||
| undefined
|
||||
readonly decodeReplayEntry?:
|
||||
| ((conn: PacketConn, dir: PacketDirection, bytes: Uint8Array, record: D2CapRecord) => ServerEvent | ClientCommand | Record<string, unknown> | undefined)
|
||||
| undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Dockable Packet Inspector & `.d2cap` deterministic capture/replay controller (F20).
|
||||
* Implements `PacketTap` directly and can also wrap an existing `PacketTap` / `InMemoryPacketTap`.
|
||||
*/
|
||||
export class PacketInspector implements PacketTap {
|
||||
private readonly store: SettingsStore
|
||||
private readonly maxEntries: number
|
||||
private readonly onReplayRecord:
|
||||
| ((frame: ReplayedD2CapFrame, rawBytes: Uint8Array, index: number) => void)
|
||||
| undefined
|
||||
private readonly decodeReplayEntry:
|
||||
| ((conn: PacketConn, dir: PacketDirection, bytes: Uint8Array, record: D2CapRecord) => ServerEvent | ClientCommand | Record<string, unknown> | undefined)
|
||||
| undefined
|
||||
private readonly rows: InspectedPacketRow[] = []
|
||||
private readonly rawTapEntries: PacketTapEntry[] = []
|
||||
private nextSeq = 1
|
||||
private paused = false
|
||||
private filter: PacketInspectorFilter = {
|
||||
phase: 'all',
|
||||
conn: 'all',
|
||||
direction: 'all',
|
||||
opcode: null,
|
||||
search: '',
|
||||
}
|
||||
private selectedSeq: number | null = null
|
||||
private replayHeader: D2CapHeader | null = null
|
||||
private replayRecords: D2CapRecord[] = []
|
||||
private replayCursor = 0
|
||||
private detachTapHook: (() => void) | null = null
|
||||
private unsubscribeStore: (() => void) | null = null
|
||||
|
||||
private rootEl: HTMLElement | null = null
|
||||
private listEl: HTMLElement | null = null
|
||||
private detailEl: HTMLElement | null = null
|
||||
|
||||
constructor(options?: PacketInspectorOptions) {
|
||||
this.store = options?.store ?? getGlobalSettingsStore()
|
||||
this.maxEntries = Math.max(1, options?.maxRows ?? options?.maxEntries ?? 500)
|
||||
this.onReplayRecord = options?.onReplayRecord
|
||||
this.decodeReplayEntry = options?.decodeReplayEntry
|
||||
|
||||
if (options?.tap) {
|
||||
this.attachTap(options.tap)
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
|
||||
this.mountDom(options?.container ?? null)
|
||||
}
|
||||
|
||||
this.unsubscribeStore = this.store.subscribe((next, prev) => {
|
||||
if (next.inspectorOpen !== prev.inspectorOpen) {
|
||||
this.syncDom()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.store.get().inspectorOpen
|
||||
}
|
||||
|
||||
setOpen(open: boolean): void {
|
||||
this.store.set({ inspectorOpen: open })
|
||||
this.syncDom()
|
||||
}
|
||||
|
||||
isPaused(): boolean {
|
||||
return this.paused
|
||||
}
|
||||
|
||||
setPaused(paused: boolean): void {
|
||||
this.paused = paused
|
||||
}
|
||||
|
||||
/**
|
||||
* Implement `PacketTap.record` so `PacketInspector` can be passed directly as a `PacketTap`.
|
||||
*/
|
||||
record(entry: PacketTapEntry): void {
|
||||
this.recordTapEntry(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept `tap.record(...)` calls on an existing `PacketTap` (such as `InMemoryPacketTap`).
|
||||
*/
|
||||
attachTap(tap: PacketTap): void {
|
||||
this.detachTapHook?.()
|
||||
if (tap === this) {
|
||||
this.detachTapHook = null
|
||||
return
|
||||
}
|
||||
const origRecord = tap.record.bind(tap)
|
||||
tap.record = (entry: PacketTapEntry): void => {
|
||||
origRecord(entry)
|
||||
this.recordTapEntry(entry)
|
||||
}
|
||||
this.detachTapHook = (): void => {
|
||||
tap.record = origRecord
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `PacketTapEntry` plus optional decoded `ServerEvent` / `ClientCommand`.
|
||||
*/
|
||||
recordTapEntry(
|
||||
entry: PacketTapEntry,
|
||||
decodedPayload?: ServerEvent | ClientCommand | Record<string, unknown>,
|
||||
): InspectedPacketRow | null {
|
||||
if (this.paused) return null
|
||||
|
||||
const copyBytes = new Uint8Array(entry.raw)
|
||||
const opcode = resolvePacketOpcode(entry.conn, copyBytes, entry.packetId)
|
||||
const phase = connToPhase(entry.conn)
|
||||
const effectiveDecoded = decodedPayload ?? entry.decoded
|
||||
|
||||
const tapCopy: PacketTapEntry = {
|
||||
t: entry.t,
|
||||
conn: entry.conn,
|
||||
dir: entry.dir,
|
||||
raw: copyBytes,
|
||||
packetId: opcode,
|
||||
...(entry.packetName !== undefined ? { packetName: entry.packetName } : {}),
|
||||
...(effectiveDecoded !== undefined ? { decoded: effectiveDecoded } : {}),
|
||||
...(entry.isRawStreamChunk !== undefined ? { isRawStreamChunk: entry.isRawStreamChunk } : {}),
|
||||
}
|
||||
this.rawTapEntries.push(tapCopy)
|
||||
if (this.rawTapEntries.length > this.maxEntries) {
|
||||
this.rawTapEntries.shift()
|
||||
}
|
||||
|
||||
const safeJson = (val: unknown): string => {
|
||||
try {
|
||||
return JSON.stringify(val, (_k, v: unknown) =>
|
||||
typeof v === 'bigint' ? v.toString() : v instanceof Uint8Array ? bytesToHex(v) : v,
|
||||
)
|
||||
} catch {
|
||||
return String(val)
|
||||
}
|
||||
}
|
||||
|
||||
const decodedSummary =
|
||||
effectiveDecoded &&
|
||||
typeof effectiveDecoded === 'object' &&
|
||||
'type' in effectiveDecoded &&
|
||||
typeof (effectiveDecoded as { readonly type?: unknown }).type === 'string'
|
||||
? `${String((effectiveDecoded as { readonly type: string }).type)} ${safeJson(effectiveDecoded)}`
|
||||
: effectiveDecoded !== undefined
|
||||
? safeJson(effectiveDecoded)
|
||||
: entry.packetName
|
||||
|
||||
const row: InspectedPacketRow = {
|
||||
seq: this.nextSeq++,
|
||||
timestampMs: entry.t,
|
||||
conn: entry.conn,
|
||||
phase,
|
||||
direction: entry.dir,
|
||||
opcode,
|
||||
packetId: opcode,
|
||||
packetName: entry.packetName,
|
||||
opcodeHex: formatOpcodeHex(opcode),
|
||||
byteLength: copyBytes.byteLength,
|
||||
bytes: copyBytes,
|
||||
hexDump: formatHexDump(copyBytes),
|
||||
decodedSummary,
|
||||
decodedPayload: effectiveDecoded,
|
||||
decoded: effectiveDecoded,
|
||||
}
|
||||
|
||||
this.rows.push(row)
|
||||
if (this.rows.length > this.maxEntries) {
|
||||
this.rows.shift()
|
||||
}
|
||||
this.syncDom()
|
||||
return row
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.rows.length = 0
|
||||
this.rawTapEntries.length = 0
|
||||
this.selectedSeq = null
|
||||
this.syncDom()
|
||||
}
|
||||
|
||||
setFilter(patch: Partial<PacketInspectorFilter>): PacketInspectorFilter {
|
||||
this.filter = {
|
||||
...this.filter,
|
||||
...patch,
|
||||
}
|
||||
this.syncDom()
|
||||
return this.filter
|
||||
}
|
||||
|
||||
getFilter(): PacketInspectorFilter {
|
||||
return this.filter
|
||||
}
|
||||
|
||||
getAllRows(): readonly InspectedPacketRow[] {
|
||||
return this.rows
|
||||
}
|
||||
|
||||
getFilteredRows(): readonly InspectedPacketRow[] {
|
||||
const rawPhaseOrConn =
|
||||
this.filter.conn !== undefined && this.filter.conn !== 'all'
|
||||
? this.filter.conn
|
||||
: this.filter.phase ?? 'all'
|
||||
const targetConn = rawPhaseOrConn === 'all' ? 'all' : phaseToConn(rawPhaseOrConn)
|
||||
const dirFilter = this.filter.direction ?? 'all'
|
||||
const opcodeFilter = parseOpcodeFilter(this.filter.opcode)
|
||||
const search = (this.filter.search ?? '').trim().toLowerCase()
|
||||
|
||||
return this.rows.filter(r => {
|
||||
if (targetConn !== 'all' && r.conn !== targetConn) return false
|
||||
if (dirFilter !== 'all' && r.direction !== dirFilter) return false
|
||||
if (opcodeFilter !== null && r.opcode !== opcodeFilter) return false
|
||||
if (search !== '') {
|
||||
const hay = `${r.opcodeHex} ${r.phase} ${r.conn} ${r.direction} ${r.decodedSummary ?? ''} ${r.hexDump}`.toLowerCase()
|
||||
if (!hay.includes(search)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
selectRow(seq: number | null): InspectedPacketRow | null {
|
||||
this.selectedSeq = seq
|
||||
this.syncDom()
|
||||
if (seq === null) return null
|
||||
return this.rows.find(r => r.seq === seq) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Export captured packets as a `.d2cap` JSON Lines string.
|
||||
*/
|
||||
exportD2CapJsonl(): string {
|
||||
return this.rawTapEntries.map(e => formatD2CapEntry(e)).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a `.d2cap` JSONL capture string for stepping or full deterministic replay.
|
||||
*/
|
||||
loadD2CapJsonl(jsonlText: string): readonly D2CapRecord[] {
|
||||
const parsed = parseD2CapJsonl(jsonlText)
|
||||
this.replayHeader = parsed.header
|
||||
this.replayRecords = parsed.records
|
||||
this.replayCursor = 0
|
||||
return this.replayRecords
|
||||
}
|
||||
|
||||
getReplayHeader(): D2CapHeader | null {
|
||||
return this.replayHeader
|
||||
}
|
||||
|
||||
getReplayStatus(): { readonly total: number; readonly cursor: number } {
|
||||
return {
|
||||
total: this.replayRecords.length,
|
||||
cursor: this.replayCursor,
|
||||
}
|
||||
}
|
||||
|
||||
resetReplayCursor(): void {
|
||||
this.replayCursor = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the `.d2cap` replay cursor by 1 frame and invoke `callback` (and `onReplayRecord`).
|
||||
*/
|
||||
stepReplay(
|
||||
callback?: (frame: ReplayedD2CapFrame, rawBytes: Uint8Array, index: number) => void,
|
||||
): ReplayedD2CapFrame | null {
|
||||
if (this.replayCursor >= this.replayRecords.length) {
|
||||
return null
|
||||
}
|
||||
const index = this.replayCursor++
|
||||
const record = this.replayRecords[index]!
|
||||
const bytes = hexToBytes(record.hex)
|
||||
const opcode = resolvePacketOpcode(record.conn, bytes, record.packetId)
|
||||
const phase = connToPhase(record.conn)
|
||||
const decodedPayload = this.decodeReplayEntry?.(record.conn, record.dir, bytes, record)
|
||||
|
||||
this.recordTapEntry(
|
||||
{
|
||||
t: record.t,
|
||||
conn: record.conn,
|
||||
dir: record.dir,
|
||||
raw: bytes,
|
||||
packetId: opcode,
|
||||
...(record.packetName !== undefined ? { packetName: record.packetName } : {}),
|
||||
...(record.stream !== undefined ? { isRawStreamChunk: record.stream } : {}),
|
||||
},
|
||||
decodedPayload,
|
||||
)
|
||||
|
||||
const frame: ReplayedD2CapFrame = {
|
||||
record,
|
||||
opcode,
|
||||
packetId: opcode,
|
||||
conn: record.conn,
|
||||
phase,
|
||||
dir: record.dir,
|
||||
bytes,
|
||||
index,
|
||||
}
|
||||
|
||||
this.onReplayRecord?.(frame, bytes, index)
|
||||
callback?.(frame, bytes, index)
|
||||
return frame
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay all remaining (or all matching) `.d2cap` records synchronously in deterministic order.
|
||||
*/
|
||||
replayAll(
|
||||
callback?: (frame: ReplayedD2CapFrame, rawBytes: Uint8Array, index: number) => void,
|
||||
options?: {
|
||||
readonly phase?: PacketPhase | PacketConn | undefined
|
||||
readonly direction?: PacketDirection | undefined
|
||||
},
|
||||
): number {
|
||||
let count = 0
|
||||
const targetConn = options?.phase ? phaseToConn(options.phase) : undefined
|
||||
while (this.replayCursor < this.replayRecords.length) {
|
||||
const idx = this.replayCursor++
|
||||
const rec = this.replayRecords[idx]!
|
||||
if (targetConn && rec.conn !== targetConn) continue
|
||||
if (options?.direction && rec.dir !== options.direction) continue
|
||||
const bytes = hexToBytes(rec.hex)
|
||||
const opcode = resolvePacketOpcode(rec.conn, bytes, rec.packetId)
|
||||
const phase = connToPhase(rec.conn)
|
||||
const decodedPayload = this.decodeReplayEntry?.(rec.conn, rec.dir, bytes, rec)
|
||||
|
||||
this.recordTapEntry(
|
||||
{
|
||||
t: rec.t,
|
||||
conn: rec.conn,
|
||||
dir: rec.dir,
|
||||
raw: bytes,
|
||||
packetId: opcode,
|
||||
...(rec.packetName !== undefined ? { packetName: rec.packetName } : {}),
|
||||
...(rec.stream !== undefined ? { isRawStreamChunk: rec.stream } : {}),
|
||||
},
|
||||
decodedPayload,
|
||||
)
|
||||
|
||||
const frame: ReplayedD2CapFrame = {
|
||||
record: rec,
|
||||
opcode,
|
||||
packetId: opcode,
|
||||
conn: rec.conn,
|
||||
phase,
|
||||
dir: rec.dir,
|
||||
bytes,
|
||||
index: idx,
|
||||
}
|
||||
this.onReplayRecord?.(frame, bytes, idx)
|
||||
callback?.(frame, bytes, idx)
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.detachTapHook?.()
|
||||
this.detachTapHook = null
|
||||
this.unsubscribeStore?.()
|
||||
this.unsubscribeStore = null
|
||||
if (this.rootEl && this.rootEl.parentNode) {
|
||||
this.rootEl.parentNode.removeChild(this.rootEl)
|
||||
}
|
||||
this.rootEl = null
|
||||
}
|
||||
|
||||
private mountDom(container: HTMLElement | null): void {
|
||||
const doc = document
|
||||
const root = doc.createElement('aside')
|
||||
root.id = 'd2-packet-inspector'
|
||||
root.className = 'd2-packet-inspector'
|
||||
|
||||
const list = doc.createElement('div')
|
||||
list.id = 'd2-packet-inspector-list'
|
||||
const detail = doc.createElement('pre')
|
||||
detail.id = 'd2-packet-inspector-detail'
|
||||
|
||||
root.appendChild(list)
|
||||
root.appendChild(detail)
|
||||
if (container) {
|
||||
container.appendChild(root)
|
||||
}
|
||||
|
||||
this.rootEl = root
|
||||
this.listEl = list
|
||||
this.detailEl = detail
|
||||
this.syncDom()
|
||||
}
|
||||
|
||||
private syncDom(): void {
|
||||
if (!this.rootEl) return
|
||||
const open = this.isOpen()
|
||||
this.rootEl.style.display = open ? 'flex' : 'none'
|
||||
if (!open) return
|
||||
|
||||
if (this.listEl) {
|
||||
const filtered = this.getFilteredRows()
|
||||
this.listEl.textContent = `${filtered.length} / ${this.rows.length} packets`
|
||||
}
|
||||
if (this.detailEl) {
|
||||
const selected =
|
||||
this.selectedSeq !== null
|
||||
? this.rows.find(r => r.seq === this.selectedSeq)
|
||||
: this.rows[this.rows.length - 1]
|
||||
this.detailEl.textContent = selected
|
||||
? `[${selected.phase.toUpperCase()} ${selected.direction.toUpperCase()} ${selected.opcodeHex}] (${selected.byteLength}B)\n${selected.decodedSummary ?? ''}\n${selected.hexDump}`
|
||||
: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
* - Battle.net Pre-Game & Live Lobby (`FrontendRenderer` + `BnetFlowState` + `OnlineSession`)
|
||||
* - In-Game Isometric World + Automap + Lighting (`ClientWorldSceneSource`, `AutomapView`)
|
||||
* - Authentic D2 HUD Overlay (`HudManager` + `HudModel` + `CommandMapper` for every registered `ViewportProfile`)
|
||||
* - Dockable Packet Inspector & Deterministic `.d2cap` Capture Replay (`PacketInspector`)
|
||||
* - Hot-Swappable Plugin Host & Configuration Modal (`PluginHost` + `PluginMenu`)
|
||||
* - Graceful disconnect & error recovery banner (`#play-disconnect-banner`)
|
||||
*
|
||||
* Strict import boundary: never imports `src/netproto/{bncs,mcp,d2gs,crypto}/**`.
|
||||
|
|
@ -55,7 +55,6 @@ import { isModalDomOverlayOpen } from './ui/dom-overlay.ts'
|
|||
import { HudManager } from './ui/hud-manager.ts'
|
||||
import { Minimap } from './ui/minimap.ts'
|
||||
import { CommandMapper } from './input/command-mapper.ts'
|
||||
import { PacketInspector } from './inspector/packet-inspector.ts'
|
||||
import { MapService } from './map/map-service.ts'
|
||||
import type { D2WebPlugin } from './plugin-host/api.ts'
|
||||
import { PluginHost } from './plugin-host/plugin-host.ts'
|
||||
|
|
@ -99,7 +98,6 @@ export interface PlayAppController {
|
|||
readonly plugins: PluginHost
|
||||
readonly pluginMenu: PluginMenu
|
||||
readonly toolbar: Toolbar
|
||||
readonly inspector: PacketInspector
|
||||
readonly hudManager: HudManager
|
||||
readonly hudModel: HudModel
|
||||
readonly commandMapper: CommandMapper
|
||||
|
|
@ -135,7 +133,6 @@ export async function startPlayApp(options?: {
|
|||
window.__d2SkipAutoBoot = true
|
||||
|
||||
const toolbarContainer = document.querySelector<HTMLElement>('#d2-toolbar-container')
|
||||
const inspectorContainer = document.querySelector<HTMLElement>('#d2-inspector-container')
|
||||
const stageWrapperEl = document.querySelector<HTMLElement>('#play-stage-wrapper')
|
||||
const stageEl = document.querySelector<HTMLElement>('#play-stage')
|
||||
const bnetShell = document.querySelector<HTMLElement>('#bnet-shell')
|
||||
|
|
@ -191,10 +188,6 @@ export async function startPlayApp(options?: {
|
|||
onAutomapRevealChange: (mode) => {
|
||||
sessionRef?.automapView.setMode(mode)
|
||||
},
|
||||
onInspectorToggle: (open) => {
|
||||
sessionRef?.packetInspector.setOpen(open)
|
||||
syncStageViewportFit()
|
||||
},
|
||||
onServerHostReconnect: (newHost) => {
|
||||
void sessionRef?.disconnect(`Reconnecting to ${newHost}`)
|
||||
},
|
||||
|
|
@ -404,6 +397,8 @@ export async function startPlayApp(options?: {
|
|||
plugins: options?.plugins ?? [],
|
||||
session,
|
||||
toolbar,
|
||||
settingsStore,
|
||||
enforceClientDefaults: true,
|
||||
})
|
||||
const pluginMenu = new PluginMenu({
|
||||
host: plugins,
|
||||
|
|
@ -411,22 +406,13 @@ export async function startPlayApp(options?: {
|
|||
})
|
||||
pluginMenuRef = pluginMenu
|
||||
|
||||
// Mount PacketInspector inside #d2-inspector-container if available
|
||||
const inspector = session.packetInspector
|
||||
if (inspectorContainer && inspectorContainer.firstChild === null) {
|
||||
const existingInspectorEl = document.querySelector('#d2-packet-inspector')
|
||||
if (existingInspectorEl && existingInspectorEl.parentElement !== inspectorContainer) {
|
||||
inspectorContainer.appendChild(existingInspectorEl)
|
||||
}
|
||||
}
|
||||
|
||||
void assetsReadyPromise.then(async () => {
|
||||
const updatedStatus = await getAssetCacheStatus()
|
||||
toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes)
|
||||
})
|
||||
|
||||
// Apply the initial viewport profile's dimensions, stretched to fit the browser at its own aspect ratio
|
||||
syncStageViewportFit(initialSettings.viewport)
|
||||
syncStageViewportFit(settingsStore.get().viewport)
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
syncStageViewportFit()
|
||||
|
|
@ -450,10 +436,6 @@ export async function startPlayApp(options?: {
|
|||
if (next.automapReveal !== prev.automapReveal) {
|
||||
session.automapView.setMode(next.automapReveal)
|
||||
}
|
||||
if (next.inspectorOpen !== prev.inspectorOpen) {
|
||||
inspector.setOpen(next.inspectorOpen)
|
||||
syncStageViewportFit(next.viewport)
|
||||
}
|
||||
})
|
||||
|
||||
const flowState = createInitialFlowState()
|
||||
|
|
@ -1054,7 +1036,6 @@ export async function startPlayApp(options?: {
|
|||
plugins,
|
||||
pluginMenu,
|
||||
toolbar,
|
||||
inspector,
|
||||
hudManager: session.hudManager,
|
||||
hudModel: session.hudModel,
|
||||
commandMapper: session.commandMapper,
|
||||
|
|
|
|||
|
|
@ -13,13 +13,53 @@ import {
|
|||
setLang,
|
||||
type GameLang,
|
||||
} from '../i18n/lang.ts'
|
||||
import {
|
||||
CLIENT_LIGHTING_PRESET_ORDER,
|
||||
LIGHTING_PRESETS,
|
||||
type LightingPresetConfig,
|
||||
} from '../scene/lighting-presets.ts'
|
||||
import type { OnlineSession } from '../session/online-session.ts'
|
||||
import {
|
||||
VALID_AUTOMAP_REVEAL_MODES,
|
||||
VALID_LIGHTING_PRESETS,
|
||||
VALID_VIEWPORT_RESOLUTIONS,
|
||||
type AutomapRevealMode,
|
||||
type ClientLightingPreset,
|
||||
type ViewportResolution,
|
||||
} from '../settings/client-settings.ts'
|
||||
import { getGlobalSettingsStore } from '../settings/settings-store.ts'
|
||||
import {
|
||||
EXTENDED_VIEWPORT_RESOLUTIONS,
|
||||
NATIVE_113C_VIEWPORT_RESOLUTIONS,
|
||||
VIEWPORT_PROFILES,
|
||||
formatViewportLabel,
|
||||
getViewportProfile,
|
||||
isExtendedViewport,
|
||||
type ViewportProfile,
|
||||
} from '../view/viewport-profile.ts'
|
||||
import {
|
||||
PluginConfigStore,
|
||||
} from './plugin-config-store.ts'
|
||||
|
||||
export type { GameLang }
|
||||
export {
|
||||
CLIENT_LIGHTING_PRESET_ORDER,
|
||||
EXTENDED_VIEWPORT_RESOLUTIONS,
|
||||
LIGHTING_PRESETS,
|
||||
NATIVE_113C_VIEWPORT_RESOLUTIONS,
|
||||
VALID_AUTOMAP_REVEAL_MODES,
|
||||
VALID_LIGHTING_PRESETS,
|
||||
VALID_VIEWPORT_RESOLUTIONS,
|
||||
VIEWPORT_PROFILES,
|
||||
formatViewportLabel,
|
||||
getViewportProfile,
|
||||
isExtendedViewport,
|
||||
type AutomapRevealMode,
|
||||
type ClientLightingPreset,
|
||||
type GameLang,
|
||||
type LightingPresetConfig,
|
||||
type ViewportProfile,
|
||||
type ViewportResolution,
|
||||
}
|
||||
|
||||
export interface LocalizedText {
|
||||
readonly zh: string
|
||||
|
|
@ -89,17 +129,50 @@ export interface PluginLogger {
|
|||
error(msg: string, ...args: unknown[]): void
|
||||
}
|
||||
|
||||
export interface PluginToolbarSelectOption {
|
||||
readonly value: string
|
||||
readonly label: LocalizedText
|
||||
}
|
||||
|
||||
export interface PluginToolbarSelectSpec {
|
||||
readonly id: string
|
||||
readonly selectDomId?: string | undefined
|
||||
readonly label: LocalizedText
|
||||
readonly options: readonly PluginToolbarSelectOption[]
|
||||
getValue(): string
|
||||
onChange(value: string): void
|
||||
}
|
||||
|
||||
export interface PluginClientCapabilities {
|
||||
getAutomapReveal(): AutomapRevealMode
|
||||
setAutomapReveal(mode: AutomapRevealMode): void
|
||||
subscribeAutomapReveal(fn: (mode: AutomapRevealMode) => void): () => void
|
||||
|
||||
getLightingPreset(): ClientLightingPreset
|
||||
setLightingPreset(preset: ClientLightingPreset): void
|
||||
subscribeLightingPreset(fn: (preset: ClientLightingPreset) => void): () => void
|
||||
|
||||
getViewport(): ViewportResolution
|
||||
setViewport(viewport: ViewportResolution): void
|
||||
subscribeViewport(fn: (viewport: ViewportResolution) => void): () => void
|
||||
getAllowedViewports(): readonly ViewportResolution[]
|
||||
setAllowedViewports(resolutions: readonly ViewportResolution[]): void
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
readonly pluginId: string
|
||||
readonly session: PluginSessionView
|
||||
readonly settings: {
|
||||
get(key: string): boolean | string
|
||||
set(key: string, value: boolean | string): void
|
||||
subscribe(fn: (key: string) => void): () => void
|
||||
}
|
||||
readonly toolbar: {
|
||||
addLink(spec: { id: string; label: LocalizedText; href: string }): () => void
|
||||
addButton(spec: { id: string; label: LocalizedText; onClick(): void }): () => void
|
||||
addSelect(spec: PluginToolbarSelectSpec): () => void
|
||||
}
|
||||
readonly client: PluginClientCapabilities
|
||||
readonly lang: {
|
||||
get(): GameLang
|
||||
subscribe(fn: (lang: GameLang) => void): () => void
|
||||
|
|
|
|||
|
|
@ -5,25 +5,42 @@
|
|||
* - 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.
|
||||
* - Automatic teardown of toolbar contributions (`addLink` / `addButton` / `addSelect`),
|
||||
* client capability claims (`automapReveal`, `lightingPreset`, `allowedViewports`),
|
||||
* 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 {
|
||||
VALID_AUTOMAP_REVEAL_MODES,
|
||||
VALID_LIGHTING_PRESETS,
|
||||
VALID_VIEWPORT_RESOLUTIONS,
|
||||
type AutomapRevealMode,
|
||||
type ClientLightingPreset,
|
||||
type ViewportResolution,
|
||||
} from '../settings/client-settings.ts'
|
||||
import {
|
||||
getGlobalSettingsStore,
|
||||
type SettingsStore,
|
||||
} from '../settings/settings-store.ts'
|
||||
import { NATIVE_113C_VIEWPORT_RESOLUTIONS } from '../view/viewport-profile.ts'
|
||||
import {
|
||||
isRelativePluginHref,
|
||||
isValidLocalizedText,
|
||||
validatePluginRegistry,
|
||||
type D2WebPlugin,
|
||||
type LocalizedText,
|
||||
type PluginClientCapabilities,
|
||||
type PluginContext,
|
||||
type PluginInstance,
|
||||
type PluginLogger,
|
||||
type PluginSessionView,
|
||||
type PluginStatus,
|
||||
type PluginToolbarSelectOption,
|
||||
type PluginToolbarSelectSpec,
|
||||
} from './api.ts'
|
||||
import {
|
||||
PluginConfigStore,
|
||||
|
|
@ -43,6 +60,15 @@ export type ToolbarContributionSpec =
|
|||
readonly label: LocalizedText
|
||||
readonly onClick: () => void
|
||||
}
|
||||
| {
|
||||
readonly domId: string
|
||||
readonly selectDomId: string
|
||||
readonly kind: 'select'
|
||||
readonly label: LocalizedText
|
||||
readonly options: readonly PluginToolbarSelectOption[]
|
||||
readonly getValue: () => string
|
||||
readonly onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export interface PluginHostToolbarPort {
|
||||
setPluginsButtonState(state: {
|
||||
|
|
@ -51,6 +77,8 @@ export interface PluginHostToolbarPort {
|
|||
readonly open?: boolean | undefined
|
||||
}): void
|
||||
addContribution(spec: ToolbarContributionSpec): () => void
|
||||
getAllowedViewports?(): readonly ViewportResolution[]
|
||||
setAllowedViewports?(resolutions: readonly ViewportResolution[]): void
|
||||
}
|
||||
|
||||
export interface PluginDescriptorSnapshot {
|
||||
|
|
@ -67,6 +95,8 @@ export interface PluginHostOptions {
|
|||
readonly plugins: readonly D2WebPlugin[]
|
||||
readonly session: PluginSessionView
|
||||
readonly toolbar?: PluginHostToolbarPort | null | undefined
|
||||
readonly settingsStore?: SettingsStore | undefined
|
||||
readonly enforceClientDefaults?: boolean | undefined
|
||||
readonly configStore?: PluginConfigStore | undefined
|
||||
readonly storageOptions?: PluginConfigStoreOptions | undefined
|
||||
readonly autoStart?: boolean | undefined
|
||||
|
|
@ -88,9 +118,16 @@ const KEBAB_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|||
export class PluginHost {
|
||||
private readonly session: PluginSessionView
|
||||
private readonly toolbar: PluginHostToolbarPort | null
|
||||
private readonly settingsStore: SettingsStore
|
||||
private readonly enforceClientDefaults: boolean
|
||||
private readonly configStore: PluginConfigStore
|
||||
private readonly records = new Map<string, InternalPluginRecord>()
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private readonly automapClaims = new Set<string>()
|
||||
private readonly lightingClaims = new Set<string>()
|
||||
private readonly viewportClaims = new Set<string>()
|
||||
private fallbackAllowedViewports: readonly ViewportResolution[] =
|
||||
NATIVE_113C_VIEWPORT_RESOLUTIONS
|
||||
private readonly unsubConfig: () => void
|
||||
private ready = false
|
||||
private destroyed = false
|
||||
|
|
@ -100,6 +137,8 @@ export class PluginHost {
|
|||
validatePluginRegistry(options.plugins)
|
||||
this.session = options.session
|
||||
this.toolbar = options.toolbar ?? null
|
||||
this.settingsStore = options.settingsStore ?? getGlobalSettingsStore()
|
||||
this.enforceClientDefaults = options.enforceClientDefaults ?? false
|
||||
this.configStore =
|
||||
options.configStore ??
|
||||
new PluginConfigStore(options.plugins, options.storageOptions)
|
||||
|
|
@ -171,6 +210,9 @@ export class PluginHost {
|
|||
}
|
||||
}
|
||||
this.startPromise = Promise.all(tasks).then(() => {
|
||||
if (this.enforceClientDefaults) {
|
||||
this.reconcileUnclaimedClientCapabilities()
|
||||
}
|
||||
this.ready = true
|
||||
this.syncToolbarState()
|
||||
this.notifyListeners()
|
||||
|
|
@ -360,6 +402,9 @@ export class PluginHost {
|
|||
}
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
if (this.enforceClientDefaults) {
|
||||
this.reconcileUnclaimedClientCapabilities()
|
||||
}
|
||||
this.syncToolbarState()
|
||||
this.notifyListeners()
|
||||
}
|
||||
|
|
@ -401,6 +446,247 @@ export class PluginHost {
|
|||
return next
|
||||
}
|
||||
|
||||
private createClientCapabilities(
|
||||
record: InternalPluginRecord,
|
||||
isDisposed: () => boolean,
|
||||
): PluginClientCapabilities {
|
||||
const pluginId = record.plugin.id
|
||||
|
||||
const claimAutomap = (): void => {
|
||||
if (isDisposed() || this.automapClaims.has(pluginId)) return
|
||||
this.automapClaims.add(pluginId)
|
||||
const release = (): void => {
|
||||
record.disposers.delete(release)
|
||||
this.automapClaims.delete(pluginId)
|
||||
if (this.automapClaims.size === 0 && this.settingsStore.get().automapReveal !== 'off') {
|
||||
this.settingsStore.set({ automapReveal: 'off' })
|
||||
}
|
||||
}
|
||||
record.disposers.add(release)
|
||||
}
|
||||
|
||||
const claimLighting = (): void => {
|
||||
if (isDisposed() || this.lightingClaims.has(pluginId)) return
|
||||
this.lightingClaims.add(pluginId)
|
||||
const release = (): void => {
|
||||
record.disposers.delete(release)
|
||||
this.lightingClaims.delete(pluginId)
|
||||
if (this.lightingClaims.size === 0 && this.settingsStore.get().lightingPreset !== 'auto') {
|
||||
this.settingsStore.set({ lightingPreset: 'auto' })
|
||||
}
|
||||
}
|
||||
record.disposers.add(release)
|
||||
}
|
||||
|
||||
const claimViewport = (): void => {
|
||||
if (isDisposed() || this.viewportClaims.has(pluginId)) return
|
||||
this.viewportClaims.add(pluginId)
|
||||
const release = (): void => {
|
||||
record.disposers.delete(release)
|
||||
this.viewportClaims.delete(pluginId)
|
||||
if (this.viewportClaims.size === 0) {
|
||||
this.revertAllowedViewportsToNative()
|
||||
}
|
||||
}
|
||||
record.disposers.add(release)
|
||||
}
|
||||
|
||||
return {
|
||||
getAutomapReveal: (): AutomapRevealMode => this.settingsStore.get().automapReveal,
|
||||
setAutomapReveal: (mode: AutomapRevealMode): void => {
|
||||
if (
|
||||
typeof mode !== 'string' ||
|
||||
!(VALID_AUTOMAP_REVEAL_MODES as readonly string[]).includes(mode)
|
||||
) {
|
||||
throw new Error(
|
||||
`[PluginHost] Invalid automapReveal mode "${String(mode)}" from plugin "${pluginId}"`,
|
||||
)
|
||||
}
|
||||
if (isDisposed()) return
|
||||
claimAutomap()
|
||||
if (this.settingsStore.get().automapReveal !== mode) {
|
||||
this.settingsStore.set({ automapReveal: mode })
|
||||
}
|
||||
this.syncToolbarState()
|
||||
},
|
||||
subscribeAutomapReveal: (fn: (mode: AutomapRevealMode) => void): (() => void) => {
|
||||
if (isDisposed()) return () => {}
|
||||
claimAutomap()
|
||||
const unsub = this.settingsStore.subscribe((next, prev, changed) => {
|
||||
if (!changed.includes('automapReveal') || next.automapReveal === prev.automapReveal) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
fn(next.automapReveal)
|
||||
} catch (err) {
|
||||
this.recordPluginError(record, 'automapReveal 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
|
||||
},
|
||||
|
||||
getLightingPreset: (): ClientLightingPreset => this.settingsStore.get().lightingPreset,
|
||||
setLightingPreset: (preset: ClientLightingPreset): void => {
|
||||
if (
|
||||
typeof preset !== 'string' ||
|
||||
!(VALID_LIGHTING_PRESETS as readonly string[]).includes(preset)
|
||||
) {
|
||||
throw new Error(
|
||||
`[PluginHost] Invalid lightingPreset "${String(preset)}" from plugin "${pluginId}"`,
|
||||
)
|
||||
}
|
||||
if (isDisposed()) return
|
||||
claimLighting()
|
||||
if (this.settingsStore.get().lightingPreset !== preset) {
|
||||
this.settingsStore.set({ lightingPreset: preset })
|
||||
}
|
||||
this.syncToolbarState()
|
||||
},
|
||||
subscribeLightingPreset: (fn: (preset: ClientLightingPreset) => void): (() => void) => {
|
||||
if (isDisposed()) return () => {}
|
||||
claimLighting()
|
||||
const unsub = this.settingsStore.subscribe((next, prev, changed) => {
|
||||
if (!changed.includes('lightingPreset') || next.lightingPreset === prev.lightingPreset) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
fn(next.lightingPreset)
|
||||
} catch (err) {
|
||||
this.recordPluginError(record, 'lightingPreset 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
|
||||
},
|
||||
|
||||
getViewport: (): ViewportResolution => this.settingsStore.get().viewport,
|
||||
setViewport: (viewport: ViewportResolution): void => {
|
||||
if (
|
||||
typeof viewport !== 'string' ||
|
||||
!(VALID_VIEWPORT_RESOLUTIONS as readonly string[]).includes(viewport)
|
||||
) {
|
||||
throw new Error(
|
||||
`[PluginHost] Invalid viewport "${String(viewport)}" from plugin "${pluginId}"`,
|
||||
)
|
||||
}
|
||||
if (isDisposed()) return
|
||||
claimViewport()
|
||||
if (this.settingsStore.get().viewport !== viewport) {
|
||||
this.settingsStore.set({ viewport })
|
||||
}
|
||||
this.syncToolbarState()
|
||||
},
|
||||
subscribeViewport: (fn: (viewport: ViewportResolution) => void): (() => void) => {
|
||||
if (isDisposed()) return () => {}
|
||||
claimViewport()
|
||||
const unsub = this.settingsStore.subscribe((next, prev, changed) => {
|
||||
if (!changed.includes('viewport') || next.viewport === prev.viewport) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
fn(next.viewport)
|
||||
} catch (err) {
|
||||
this.recordPluginError(record, 'viewport 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
|
||||
},
|
||||
getAllowedViewports: (): readonly ViewportResolution[] => {
|
||||
if (this.toolbar?.getAllowedViewports) {
|
||||
return this.toolbar.getAllowedViewports()
|
||||
}
|
||||
return this.fallbackAllowedViewports
|
||||
},
|
||||
setAllowedViewports: (resolutions: readonly ViewportResolution[]): void => {
|
||||
if (!Array.isArray(resolutions) || resolutions.length === 0) {
|
||||
throw new Error(
|
||||
`[PluginHost] setAllowedViewports on plugin "${pluginId}" requires a non-empty array`,
|
||||
)
|
||||
}
|
||||
const unique: ViewportResolution[] = []
|
||||
for (const r of resolutions) {
|
||||
if (
|
||||
typeof r !== 'string' ||
|
||||
!(VALID_VIEWPORT_RESOLUTIONS as readonly string[]).includes(r)
|
||||
) {
|
||||
throw new Error(
|
||||
`[PluginHost] Invalid viewport resolution "${String(r)}" in setAllowedViewports on plugin "${pluginId}"`,
|
||||
)
|
||||
}
|
||||
const vp = r as ViewportResolution
|
||||
if (!unique.includes(vp)) {
|
||||
unique.push(vp)
|
||||
}
|
||||
}
|
||||
if (isDisposed()) return
|
||||
claimViewport()
|
||||
this.fallbackAllowedViewports = unique
|
||||
if (this.toolbar?.setAllowedViewports) {
|
||||
this.toolbar.setAllowedViewports(unique)
|
||||
} else if (!unique.includes(this.settingsStore.get().viewport)) {
|
||||
const fallbackVp: ViewportResolution = unique.includes('800x600')
|
||||
? '800x600'
|
||||
: unique[0]!
|
||||
this.settingsStore.set({ viewport: fallbackVp })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private reconcileUnclaimedClientCapabilities(): void {
|
||||
if (this.automapClaims.size === 0 && this.settingsStore.get().automapReveal !== 'off') {
|
||||
this.settingsStore.set({ automapReveal: 'off' })
|
||||
}
|
||||
if (this.lightingClaims.size === 0 && this.settingsStore.get().lightingPreset !== 'auto') {
|
||||
this.settingsStore.set({ lightingPreset: 'auto' })
|
||||
}
|
||||
if (this.viewportClaims.size === 0) {
|
||||
this.revertAllowedViewportsToNative()
|
||||
}
|
||||
}
|
||||
|
||||
private revertAllowedViewportsToNative(): void {
|
||||
this.fallbackAllowedViewports = NATIVE_113C_VIEWPORT_RESOLUTIONS
|
||||
if (this.toolbar?.setAllowedViewports) {
|
||||
this.toolbar.setAllowedViewports(NATIVE_113C_VIEWPORT_RESOLUTIONS)
|
||||
} else if (
|
||||
!(NATIVE_113C_VIEWPORT_RESOLUTIONS as readonly string[]).includes(
|
||||
this.settingsStore.get().viewport,
|
||||
)
|
||||
) {
|
||||
this.settingsStore.set({ viewport: '800x600' })
|
||||
}
|
||||
}
|
||||
|
||||
private async activateRecord(record: InternalPluginRecord): Promise<void> {
|
||||
if (record.active) {
|
||||
return
|
||||
|
|
@ -433,6 +719,10 @@ export class PluginHost {
|
|||
get: (key: string): boolean | string => {
|
||||
return this.configStore.getSetting(pluginId, key)
|
||||
},
|
||||
set: (key: string, value: boolean | string): void => {
|
||||
if (contextDisposed) return
|
||||
this.configStore.setSetting(pluginId, key, value)
|
||||
},
|
||||
subscribe: (fn: (key: string) => void): (() => void) => {
|
||||
if (contextDisposed) {
|
||||
return () => {}
|
||||
|
|
@ -533,7 +823,77 @@ export class PluginHost {
|
|||
record.disposers.add(dispose)
|
||||
return dispose
|
||||
},
|
||||
addSelect: (spec: PluginToolbarSelectSpec): (() => void) => {
|
||||
if (!spec || typeof spec.id !== 'string' || !KEBAB_ID_RE.test(spec.id)) {
|
||||
throw new Error(
|
||||
`[PluginHost] Toolbar select id "${String(spec?.id)}" on plugin "${pluginId}" must be kebab-case`,
|
||||
)
|
||||
}
|
||||
if (!isValidLocalizedText(spec.label)) {
|
||||
throw new Error(
|
||||
`[PluginHost] Toolbar select "${spec.id}" on plugin "${pluginId}" must have localized label`,
|
||||
)
|
||||
}
|
||||
if (!Array.isArray(spec.options) || spec.options.length === 0) {
|
||||
throw new Error(
|
||||
`[PluginHost] Toolbar select "${spec.id}" on plugin "${pluginId}" must have non-empty options`,
|
||||
)
|
||||
}
|
||||
for (const opt of spec.options) {
|
||||
if (
|
||||
!opt ||
|
||||
typeof opt.value !== 'string' ||
|
||||
opt.value.trim().length === 0 ||
|
||||
!isValidLocalizedText(opt.label)
|
||||
) {
|
||||
throw new Error(
|
||||
`[PluginHost] Toolbar select "${spec.id}" on plugin "${pluginId}" has an invalid option`,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (typeof spec.getValue !== 'function' || typeof spec.onChange !== 'function') {
|
||||
throw new Error(
|
||||
`[PluginHost] Toolbar select "${spec.id}" on plugin "${pluginId}" must provide getValue and onChange`,
|
||||
)
|
||||
}
|
||||
if (contextDisposed) {
|
||||
return () => {}
|
||||
}
|
||||
const domId = `d2-toolbar-plugin-${pluginId}-${spec.id}`
|
||||
const selectDomId =
|
||||
typeof spec.selectDomId === 'string' && spec.selectDomId.trim().length > 0
|
||||
? spec.selectDomId.trim()
|
||||
: `${domId}-select`
|
||||
const removeDom =
|
||||
this.toolbar?.addContribution({
|
||||
domId,
|
||||
selectDomId,
|
||||
kind: 'select',
|
||||
label: spec.label,
|
||||
options: spec.options,
|
||||
getValue: () => spec.getValue(),
|
||||
onChange: (value: string) => {
|
||||
try {
|
||||
spec.onChange(value)
|
||||
} catch (err) {
|
||||
this.recordPluginError(record, `toolbar select "${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
|
||||
},
|
||||
},
|
||||
client: this.createClientCapabilities(record, () => contextDisposed),
|
||||
lang: {
|
||||
get: (): GameLang => getGameLang(),
|
||||
subscribe: (fn: (lang: GameLang) => void): (() => void) => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* - `RemoteEntityInterpolator` (remote monster/NPC/player smooth motion)
|
||||
* - `ClientWorldSceneSource` (feeding `WorldRenderer` across `800×600` and `1024×768`)
|
||||
* - `HudManager` + `HudModel` + `CommandMapper` + `OutboundRateLimiter`
|
||||
* - `SettingsStore`, `SoundService`, and `PacketInspector` (with deterministic `.d2cap` replay)
|
||||
* - `SettingsStore`, `SoundService`, and deterministic `.d2cap` capture replay
|
||||
* - Graceful disconnect / error recovery without uncaught promise rejections or page crashes.
|
||||
*
|
||||
* Strict import boundary: imports only from `src/netproto/index.ts` (never `bncs/mcp/d2gs/crypto`).
|
||||
|
|
@ -22,6 +22,8 @@ import {
|
|||
createD2OnlineFlow,
|
||||
createWsBridgeResolver,
|
||||
decodeD2gsS2cPacket,
|
||||
hexToBytes,
|
||||
parseD2CapJsonl,
|
||||
unitTypeFromId,
|
||||
unitTypeToId,
|
||||
type CharClassId,
|
||||
|
|
@ -29,6 +31,7 @@ import {
|
|||
type CharSummary,
|
||||
type ClientCommand,
|
||||
type CloseReason,
|
||||
type D2CapRecord,
|
||||
type D2OnlineConfig,
|
||||
type D2OnlineFlow,
|
||||
type D2OnlineSessionKind,
|
||||
|
|
@ -40,6 +43,8 @@ import {
|
|||
type GameListEntry,
|
||||
type GameServerAdapter,
|
||||
type ItemDataTables,
|
||||
type PacketConn,
|
||||
type PacketDirection,
|
||||
type RealmSummary,
|
||||
type ServerEvent,
|
||||
type UnitType,
|
||||
|
|
@ -56,7 +61,6 @@ import {
|
|||
ViewportCamera,
|
||||
type CameraLike,
|
||||
} from '../input/command-mapper.ts'
|
||||
import { PacketInspector, type ReplayedD2CapFrame } from '../inspector/packet-inspector.ts'
|
||||
import type { LevelView } from '../map/level-view.ts'
|
||||
import type { MapService } from '../map/map-service.ts'
|
||||
import { RemoteEntityInterpolator } from '../motion/interpolate.ts'
|
||||
|
|
@ -109,6 +113,16 @@ export interface OnlineSessionLobbySnapshot {
|
|||
readonly pingMs: number
|
||||
}
|
||||
|
||||
export interface ReplayedD2CapFrame {
|
||||
readonly seq: number
|
||||
readonly timestampMs: number
|
||||
readonly conn: PacketConn
|
||||
readonly dir: PacketDirection
|
||||
readonly opcode: number
|
||||
readonly raw: Uint8Array
|
||||
readonly record: D2CapRecord
|
||||
}
|
||||
|
||||
export interface OnlineSessionOptions {
|
||||
readonly world?: ClientWorld | undefined
|
||||
readonly hudManager?: HudManager | undefined
|
||||
|
|
@ -120,7 +134,6 @@ export interface OnlineSessionOptions {
|
|||
readonly rateLimiter?: OutboundRateLimiter | undefined
|
||||
readonly settingsStore?: SettingsStore | undefined
|
||||
readonly soundService?: SoundService | undefined
|
||||
readonly packetInspector?: PacketInspector | undefined
|
||||
readonly resolver?: EndpointResolver | undefined
|
||||
readonly flowFactory?: ((config: D2OnlineConfig) => D2OnlineFlow) | undefined
|
||||
readonly itemTables?: ItemDataTables | undefined
|
||||
|
|
@ -232,7 +245,6 @@ export class OnlineSession {
|
|||
readonly settingsStore: SettingsStore
|
||||
readonly soundService: SoundService
|
||||
readonly packetTap: InMemoryPacketTap
|
||||
readonly packetInspector: PacketInspector
|
||||
readonly hudModel: HudModel
|
||||
readonly commandMapper: CommandMapper
|
||||
readonly sceneSource: ClientWorldSceneSource
|
||||
|
|
@ -244,10 +256,9 @@ export class OnlineSession {
|
|||
private readonly defaultHost: string
|
||||
private readonly defaultBncsPort: number
|
||||
private readonly replayS2cFramer = new D2gsS2cFramer(false)
|
||||
private lastDecodedReplayRecord: {
|
||||
readonly record: ReplayedD2CapFrame['record']
|
||||
readonly events: readonly ServerEvent[]
|
||||
} | null = null
|
||||
private replayQueue: D2CapRecord[] = []
|
||||
private replayCursor = 0
|
||||
private replaySeq = 0
|
||||
|
||||
private flow: D2OnlineFlow | null = null
|
||||
private gameAdapter: GameServerAdapter | null = null
|
||||
|
|
@ -348,22 +359,6 @@ export class OnlineSession {
|
|||
this.itemTables = options.itemTables ?? getCanonicalItemDataTables()
|
||||
|
||||
this.packetTap = new InMemoryPacketTap(4000)
|
||||
this.packetInspector =
|
||||
options.packetInspector ??
|
||||
new PacketInspector({
|
||||
store: this.settingsStore,
|
||||
tap: this.packetTap,
|
||||
decodeReplayEntry: (conn, dir, rawBytes, record) => {
|
||||
const events = this.extractEventsFromReplayRecord(conn, dir, rawBytes, record)
|
||||
this.lastDecodedReplayRecord = { record, events }
|
||||
if (events.length === 1) return events[0]
|
||||
if (events.length > 1) return { events }
|
||||
return undefined
|
||||
},
|
||||
onReplayRecord: (frame: ReplayedD2CapFrame, rawBytes: Uint8Array) => {
|
||||
this.handleReplayedPacketFrame(frame, rawBytes)
|
||||
},
|
||||
})
|
||||
|
||||
this.hudModel = new HudModel({
|
||||
world: this.world,
|
||||
|
|
@ -2157,22 +2152,56 @@ export class OnlineSession {
|
|||
// ─── Deterministic `.d2cap` Capture Replay (F20) ─────────────
|
||||
|
||||
/**
|
||||
* Load a `.d2cap` JSONL capture into `PacketInspector` and transition to `'replay'` phase.
|
||||
* Load a `.d2cap` JSONL capture into the session replay queue and transition to `'replay'` phase.
|
||||
*/
|
||||
loadD2CapReplay(jsonlText: string): number {
|
||||
this.replayS2cFramer.reset(false)
|
||||
this.lastDecodedReplayRecord = null
|
||||
const records = this.packetInspector.loadD2CapJsonl(jsonlText)
|
||||
const parsed = parseD2CapJsonl(jsonlText)
|
||||
this.replayQueue = [...parsed.records]
|
||||
this.replayCursor = 0
|
||||
this.disconnectReasonValue = null
|
||||
this.setPhase('replay')
|
||||
return records.length
|
||||
return this.replayQueue.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 packet from the loaded `.d2cap` capture, decoding `game` `s2c` packets into `ServerEvent`s.
|
||||
*/
|
||||
stepD2CapReplay(): ReplayedD2CapFrame | null {
|
||||
return this.packetInspector.stepReplay()
|
||||
if (this.replayCursor >= this.replayQueue.length) {
|
||||
return null
|
||||
}
|
||||
const record = this.replayQueue[this.replayCursor]!
|
||||
this.replayCursor += 1
|
||||
const rawBytes = hexToBytes(record.hex)
|
||||
const opcode =
|
||||
record.packetId ??
|
||||
(record.conn === 'bnet' && rawBytes.length >= 2 && rawBytes[0] === 0xff
|
||||
? rawBytes[1]!
|
||||
: record.conn === 'realm' && rawBytes.length >= 3
|
||||
? rawBytes[2]!
|
||||
: rawBytes[0] ?? 0)
|
||||
const frame: ReplayedD2CapFrame = {
|
||||
seq: ++this.replaySeq,
|
||||
timestampMs: record.t,
|
||||
conn: record.conn,
|
||||
dir: record.dir,
|
||||
opcode,
|
||||
raw: rawBytes,
|
||||
record,
|
||||
}
|
||||
this.packetTap.record({
|
||||
t: record.t,
|
||||
conn: record.conn,
|
||||
dir: record.dir,
|
||||
packetId: opcode,
|
||||
raw: rawBytes,
|
||||
})
|
||||
const events = this.extractEventsFromReplayRecord(record.conn, record.dir, rawBytes, record)
|
||||
for (const ev of events) {
|
||||
this.handleServerEvent(ev)
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2180,7 +2209,11 @@ export class OnlineSession {
|
|||
*/
|
||||
replayAllD2Cap(): number {
|
||||
this.setPhase('replay')
|
||||
return this.packetInspector.replayAll()
|
||||
let count = 0
|
||||
while (this.stepD2CapReplay() !== null) {
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private extractEventsFromReplayRecord(
|
||||
|
|
@ -2203,17 +2236,6 @@ export class OnlineSession {
|
|||
return [decodeCapturedGameS2cPacket(rawBytes, this.itemTables)]
|
||||
}
|
||||
|
||||
private handleReplayedPacketFrame(frame: ReplayedD2CapFrame, rawBytes: Uint8Array): void {
|
||||
const events =
|
||||
this.lastDecodedReplayRecord?.record === frame.record
|
||||
? this.lastDecodedReplayRecord.events
|
||||
: this.extractEventsFromReplayRecord(frame.conn, frame.dir, rawBytes, frame.record)
|
||||
this.lastDecodedReplayRecord = null
|
||||
for (const ev of events) {
|
||||
this.handleServerEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal Error & Lifecycle Helpers ──────────────────────
|
||||
|
||||
resolveWsBridgeBaseUrl(serverHost?: string): string {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ export interface ClientSettings {
|
|||
readonly lightingPreset: ClientLightingPreset
|
||||
readonly automapReveal: AutomapRevealMode
|
||||
readonly viewport: ViewportResolution
|
||||
readonly inspectorOpen: boolean
|
||||
readonly audioMuted: boolean
|
||||
readonly masterVolume: number // 0.0 .. 1.0
|
||||
}
|
||||
|
|
@ -59,7 +58,6 @@ export function createDefaultClientSettings(defaultLang?: GameLang): ClientSetti
|
|||
lightingPreset: 'auto',
|
||||
automapReveal: 'off',
|
||||
viewport: '800x600',
|
||||
inspectorOpen: false,
|
||||
audioMuted: false,
|
||||
masterVolume: 0.8,
|
||||
}
|
||||
|
|
@ -72,7 +70,6 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = {
|
|||
lightingPreset: 'auto',
|
||||
automapReveal: 'off',
|
||||
viewport: '800x600',
|
||||
inspectorOpen: false,
|
||||
audioMuted: false,
|
||||
masterVolume: 0.8,
|
||||
}
|
||||
|
|
@ -359,9 +356,6 @@ export function sanitizeClientSettings(
|
|||
? normalizeViewportResolution(rec.viewport)
|
||||
: defaults.viewport
|
||||
|
||||
const inspectorOpen =
|
||||
typeof rec.inspectorOpen === 'boolean' ? rec.inspectorOpen : defaults.inspectorOpen
|
||||
|
||||
const audioMuted =
|
||||
typeof rec.audioMuted === 'boolean' ? rec.audioMuted : defaults.audioMuted
|
||||
|
||||
|
|
@ -374,7 +368,6 @@ export function sanitizeClientSettings(
|
|||
lightingPreset,
|
||||
automapReveal,
|
||||
viewport,
|
||||
inspectorOpen,
|
||||
audioMuted,
|
||||
masterVolume,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ const COMPARE_KEYS: readonly SettingsKey[] = [
|
|||
'lightingPreset',
|
||||
'automapReveal',
|
||||
'viewport',
|
||||
'inspectorOpen',
|
||||
'audioMuted',
|
||||
'masterVolume',
|
||||
] as const
|
||||
|
|
|
|||
|
|
@ -8,11 +8,6 @@ import {
|
|||
import { resolveLocalizedText } from '../plugin-host/api.ts'
|
||||
import type { ToolbarContributionSpec } from '../plugin-host/plugin-host.ts'
|
||||
import {
|
||||
CLIENT_LIGHTING_PRESET_ORDER,
|
||||
LIGHTING_PRESETS,
|
||||
} from '../scene/lighting-presets.ts'
|
||||
import {
|
||||
VALID_AUTOMAP_REVEAL_MODES,
|
||||
VALID_VIEWPORT_RESOLUTIONS,
|
||||
validateServerHost,
|
||||
type AutomapRevealMode,
|
||||
|
|
@ -27,6 +22,7 @@ import {
|
|||
formatCacheBytesMb,
|
||||
} from '../sw/register.ts'
|
||||
import {
|
||||
NATIVE_113C_VIEWPORT_RESOLUTIONS,
|
||||
formatViewportLabel,
|
||||
getViewportProfile,
|
||||
type ViewportProfile,
|
||||
|
|
@ -61,7 +57,6 @@ export interface ToolbarCallbacks {
|
|||
readonly onLightingChange?: ((preset: ClientLightingPreset) => void) | undefined
|
||||
readonly onAutomapRevealChange?: ((mode: AutomapRevealMode) => void) | undefined
|
||||
readonly onViewportChange?: ((profile: ViewportProfile) => void) | undefined
|
||||
readonly onInspectorToggle?: ((open: boolean) => void) | undefined
|
||||
readonly onPluginsToggle?: (() => void) | undefined
|
||||
readonly onClearCache?: (() => void | Promise<void>) | undefined
|
||||
}
|
||||
|
|
@ -70,6 +65,7 @@ export interface ToolbarOptions {
|
|||
readonly store?: SettingsStore | undefined
|
||||
readonly container?: HTMLElement | null | undefined
|
||||
readonly pageProtocol?: string | undefined
|
||||
readonly allowedViewports?: readonly ViewportResolution[] | undefined
|
||||
readonly confirmReconnect?: ((message: string) => boolean) | undefined
|
||||
readonly callbacks?: ToolbarCallbacks | undefined
|
||||
}
|
||||
|
|
@ -78,6 +74,7 @@ export interface ToolbarSnapshot {
|
|||
readonly connection: ToolbarConnectionState
|
||||
readonly settings: ClientSettings
|
||||
readonly viewportProfile: ViewportProfile
|
||||
readonly allowedViewports: readonly ViewportResolution[]
|
||||
readonly statusLabel: string
|
||||
readonly rttLabel: string
|
||||
readonly diagnosticBannerText: string | null
|
||||
|
|
@ -85,13 +82,18 @@ export interface ToolbarSnapshot {
|
|||
readonly serverInputValid: boolean
|
||||
}
|
||||
|
||||
interface ContributionEntry {
|
||||
readonly spec: ToolbarContributionSpec
|
||||
readonly el: HTMLElement | null
|
||||
readonly labelEl: HTMLLabelElement | null
|
||||
readonly selectEl: HTMLSelectElement | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`)
|
||||
* 1. Connection Status + RTT (`ms`) + Diagnostics (5-per-IP quota / WSS TLS error) + 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`)
|
||||
* 5. Viewport Size Selector (`800x600 | 1024x768 | 1068x600 | 1280x720`) + Asset Cache Clear button
|
||||
* 3. Viewport Size Selector (`#d2-toolbar-viewport-select`, native 1.13c `640x480 | 800x600` by default, extensible via `extended-viewports` plugin) + Asset Cache Clear button
|
||||
*/
|
||||
export class Toolbar {
|
||||
private readonly store: SettingsStore
|
||||
|
|
@ -103,6 +105,7 @@ export class Toolbar {
|
|||
rttMs: null,
|
||||
diagnosticKind: 'none',
|
||||
}
|
||||
private allowedViewports: readonly ViewportResolution[]
|
||||
private serverInputValue: string
|
||||
private serverInputValid = true
|
||||
private cacheUsageBytes = 0
|
||||
|
|
@ -110,10 +113,7 @@ export class Toolbar {
|
|||
private pluginsReady = false
|
||||
private pluginsHasWarning = false
|
||||
private pluginsMenuOpen = false
|
||||
private readonly contributions = new Map<
|
||||
string,
|
||||
{ spec: ToolbarContributionSpec; el: HTMLElement | null }
|
||||
>()
|
||||
private readonly contributions = new Map<string, ContributionEntry>()
|
||||
private unsubscribeStore: (() => void) | null = null
|
||||
private unsubscribeLang: (() => void) | null = null
|
||||
|
||||
|
|
@ -124,13 +124,8 @@ export class Toolbar {
|
|||
private rttTextEl: HTMLElement | null = null
|
||||
private langBtnZhEl: HTMLButtonElement | null = null
|
||||
private langBtnEnEl: HTMLButtonElement | null = null
|
||||
private lightingLabelEl: HTMLElement | null = null
|
||||
private lightingSelectEl: HTMLSelectElement | null = null
|
||||
private automapLabelEl: HTMLElement | null = null
|
||||
private automapSelectEl: HTMLSelectElement | null = null
|
||||
private viewportLabelEl: HTMLElement | null = null
|
||||
private viewportSelectEl: HTMLSelectElement | null = null
|
||||
private inspectorBtnEl: HTMLButtonElement | null = null
|
||||
private pluginsBtnEl: HTMLButtonElement | null = null
|
||||
private pluginSlotEl: HTMLElement | null = null
|
||||
private cacheClearBtnEl: HTMLButtonElement | null = null
|
||||
|
|
@ -141,9 +136,16 @@ export class Toolbar {
|
|||
this.pageProtocol =
|
||||
options?.pageProtocol ??
|
||||
(typeof window !== 'undefined' && window.location ? window.location.protocol : 'https:')
|
||||
this.allowedViewports =
|
||||
options?.allowedViewports && options.allowedViewports.length > 0
|
||||
? [...options.allowedViewports]
|
||||
: [...NATIVE_113C_VIEWPORT_RESOLUTIONS]
|
||||
this.confirmReconnectFn =
|
||||
options?.confirmReconnect ??
|
||||
((msg: string) => (typeof window !== 'undefined' && typeof window.confirm === 'function' ? window.confirm(msg) : true))
|
||||
((msg: string) =>
|
||||
typeof window !== 'undefined' && typeof window.confirm === 'function'
|
||||
? window.confirm(msg)
|
||||
: true)
|
||||
this.callbacks = options?.callbacks ?? {}
|
||||
this.serverInputValue = this.store.get().serverHost
|
||||
|
||||
|
|
@ -168,9 +170,6 @@ export class Toolbar {
|
|||
if (changedKeys.includes('viewport') && next.viewport !== prev.viewport) {
|
||||
this.callbacks.onViewportChange?.(getViewportProfile(next.viewport))
|
||||
}
|
||||
if (changedKeys.includes('inspectorOpen') && next.inspectorOpen !== prev.inspectorOpen) {
|
||||
this.callbacks.onInspectorToggle?.(next.inspectorOpen)
|
||||
}
|
||||
this.syncDom()
|
||||
})
|
||||
|
||||
|
|
@ -196,11 +195,15 @@ export class Toolbar {
|
|||
return p === 'connecting' || p === 'bncs' || p === 'mcp' || p === 'd2gs'
|
||||
}
|
||||
|
||||
setConnectionState(next: Partial<ToolbarConnectionState> & { readonly phase: ToolbarConnectionPhase }): void {
|
||||
setConnectionState(
|
||||
next: Partial<ToolbarConnectionState> & { readonly phase: ToolbarConnectionPhase },
|
||||
): void {
|
||||
this.connection = {
|
||||
phase: next.phase,
|
||||
rttMs: next.rttMs !== undefined ? next.rttMs : this.connection.rttMs,
|
||||
diagnosticKind: next.diagnosticKind ?? (next.phase === 'error' ? this.connection.diagnosticKind ?? 'custom' : 'none'),
|
||||
diagnosticKind:
|
||||
next.diagnosticKind ??
|
||||
(next.phase === 'error' ? this.connection.diagnosticKind ?? 'custom' : 'none'),
|
||||
diagnosticMessage: next.diagnosticMessage,
|
||||
}
|
||||
this.syncDom()
|
||||
|
|
@ -319,30 +322,45 @@ export class Toolbar {
|
|||
this.syncDom()
|
||||
}
|
||||
|
||||
getAllowedViewports(): readonly ViewportResolution[] {
|
||||
return this.allowedViewports
|
||||
}
|
||||
|
||||
setAllowedViewports(resolutions: readonly ViewportResolution[]): void {
|
||||
const valid = resolutions.filter((r): r is ViewportResolution =>
|
||||
(VALID_VIEWPORT_RESOLUTIONS as readonly string[]).includes(r),
|
||||
)
|
||||
this.allowedViewports =
|
||||
valid.length > 0 ? valid : [...NATIVE_113C_VIEWPORT_RESOLUTIONS]
|
||||
const current = this.store.get().viewport
|
||||
if (!this.allowedViewports.includes(current)) {
|
||||
const fallback: ViewportResolution = this.allowedViewports.includes('800x600')
|
||||
? '800x600'
|
||||
: this.allowedViewports[0]!
|
||||
this.store.set({ viewport: fallback })
|
||||
}
|
||||
this.syncDom()
|
||||
}
|
||||
|
||||
setViewport(viewport: ViewportResolution): ViewportProfile {
|
||||
this.store.set({ viewport })
|
||||
const target: ViewportResolution = this.allowedViewports.includes(viewport)
|
||||
? viewport
|
||||
: this.allowedViewports.includes('800x600')
|
||||
? '800x600'
|
||||
: this.allowedViewports[0]!
|
||||
this.store.set({ viewport: target })
|
||||
this.syncDom()
|
||||
return getViewportProfile(viewport)
|
||||
return getViewportProfile(target)
|
||||
}
|
||||
|
||||
/** Advance to the next resolution in `VALID_VIEWPORT_RESOLUTIONS` order, wrapping back to the first. */
|
||||
/** Advance to the next resolution in `allowedViewports` order, wrapping back to the first. */
|
||||
toggleViewport(): ViewportProfile {
|
||||
const order = VALID_VIEWPORT_RESOLUTIONS
|
||||
const next = order[(order.indexOf(this.store.get().viewport) + 1) % order.length]
|
||||
const order = this.allowedViewports
|
||||
const currentIdx = order.indexOf(this.store.get().viewport)
|
||||
const next = order[(currentIdx + 1) % order.length]!
|
||||
return this.setViewport(next)
|
||||
}
|
||||
|
||||
setInspectorOpen(open: boolean): void {
|
||||
this.store.set({ inspectorOpen: open })
|
||||
this.syncDom()
|
||||
}
|
||||
|
||||
toggleInspector(): boolean {
|
||||
const next = !this.store.get().inspectorOpen
|
||||
this.setInspectorOpen(next)
|
||||
return next
|
||||
}
|
||||
|
||||
setPluginsButtonState(state: {
|
||||
readonly ready: boolean
|
||||
readonly hasWarning: boolean
|
||||
|
|
@ -364,6 +382,9 @@ export class Toolbar {
|
|||
}
|
||||
|
||||
let el: HTMLElement | null = null
|
||||
let labelEl: HTMLLabelElement | null = null
|
||||
let selectEl: HTMLSelectElement | null = null
|
||||
|
||||
if (typeof document !== 'undefined' && this.pluginSlotEl) {
|
||||
const lang = this.store.get().lang || getGameLang()
|
||||
const label = resolveLocalizedText(spec.label, lang)
|
||||
|
|
@ -377,7 +398,7 @@ export class Toolbar {
|
|||
link.textContent = label
|
||||
link.title = label
|
||||
el = link
|
||||
} else {
|
||||
} else if (spec.kind === 'button') {
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.id = spec.domId
|
||||
|
|
@ -388,11 +409,40 @@ export class Toolbar {
|
|||
spec.onClick()
|
||||
})
|
||||
el = btn
|
||||
} else {
|
||||
const group = document.createElement('div')
|
||||
group.id = spec.domId
|
||||
group.className = 'd2-toolbar__group'
|
||||
|
||||
const lbl = document.createElement('label')
|
||||
lbl.className = 'd2-toolbar__label'
|
||||
lbl.htmlFor = spec.selectDomId
|
||||
lbl.textContent = label
|
||||
|
||||
const sel = document.createElement('select')
|
||||
sel.id = spec.selectDomId
|
||||
sel.className = 'd2-toolbar__select'
|
||||
for (const optSpec of spec.options) {
|
||||
const opt = document.createElement('option')
|
||||
opt.value = optSpec.value
|
||||
opt.textContent = resolveLocalizedText(optSpec.label, lang)
|
||||
sel.appendChild(opt)
|
||||
}
|
||||
sel.value = spec.getValue()
|
||||
sel.addEventListener('change', () => {
|
||||
spec.onChange(sel.value)
|
||||
})
|
||||
|
||||
group.appendChild(lbl)
|
||||
group.appendChild(sel)
|
||||
el = group
|
||||
labelEl = lbl
|
||||
selectEl = sel
|
||||
}
|
||||
this.pluginSlotEl.appendChild(el)
|
||||
}
|
||||
|
||||
const entry = { spec, el }
|
||||
const entry: ContributionEntry = { spec, el, labelEl, selectEl }
|
||||
this.contributions.set(spec.domId, entry)
|
||||
|
||||
return () => {
|
||||
|
|
@ -447,6 +497,7 @@ export class Toolbar {
|
|||
connection: this.connection,
|
||||
settings,
|
||||
viewportProfile: getViewportProfile(settings.viewport),
|
||||
allowedViewports: this.allowedViewports,
|
||||
statusLabel,
|
||||
rttLabel,
|
||||
diagnosticBannerText,
|
||||
|
|
@ -622,7 +673,7 @@ a.d2-toolbar__btn {
|
|||
root.id = 'd2-toolbar'
|
||||
root.className = 'd2-toolbar'
|
||||
|
||||
// Group 1: Connection Status + RTT + Packet Inspector button + Plugins button + Plugin Contribution Slot
|
||||
// Group 1: Connection Status + RTT + Plugins button + Plugin Contribution Slot
|
||||
const statusGroup = doc.createElement('div')
|
||||
statusGroup.className = 'd2-toolbar__group'
|
||||
|
||||
|
|
@ -640,14 +691,6 @@ a.d2-toolbar__btn {
|
|||
statusBadge.appendChild(statusText)
|
||||
statusBadge.appendChild(rttText)
|
||||
|
||||
const inspectorBtn = doc.createElement('button')
|
||||
inspectorBtn.type = 'button'
|
||||
inspectorBtn.id = 'd2-toolbar-inspector-btn'
|
||||
inspectorBtn.className = 'd2-toolbar__btn'
|
||||
inspectorBtn.addEventListener('click', () => {
|
||||
this.toggleInspector()
|
||||
})
|
||||
|
||||
const pluginsBtn = doc.createElement('button')
|
||||
pluginsBtn.type = 'button'
|
||||
pluginsBtn.id = 'd2-toolbar-plugins-btn'
|
||||
|
|
@ -663,7 +706,6 @@ a.d2-toolbar__btn {
|
|||
pluginSlot.className = 'd2-toolbar__group'
|
||||
|
||||
statusGroup.appendChild(statusBadge)
|
||||
statusGroup.appendChild(inspectorBtn)
|
||||
statusGroup.appendChild(pluginsBtn)
|
||||
statusGroup.appendChild(pluginSlot)
|
||||
|
||||
|
|
@ -687,37 +729,7 @@ a.d2-toolbar__btn {
|
|||
langGroup.appendChild(langBtnZh)
|
||||
langGroup.appendChild(langBtnEn)
|
||||
|
||||
// Group 3: Lighting Selector
|
||||
const lightingGroup = doc.createElement('div')
|
||||
lightingGroup.className = 'd2-toolbar__group'
|
||||
const lightingLabel = doc.createElement('label')
|
||||
lightingLabel.className = 'd2-toolbar__label'
|
||||
lightingLabel.htmlFor = 'd2-toolbar-lighting-select'
|
||||
const lightingSelect = doc.createElement('select')
|
||||
lightingSelect.id = 'd2-toolbar-lighting-select'
|
||||
lightingSelect.className = 'd2-toolbar__select'
|
||||
lightingSelect.addEventListener('change', () => {
|
||||
this.setLightingPreset(lightingSelect.value as ClientLightingPreset)
|
||||
})
|
||||
lightingGroup.appendChild(lightingLabel)
|
||||
lightingGroup.appendChild(lightingSelect)
|
||||
|
||||
// Group 4: Automap Reveal Selector (Off / Level / Act)
|
||||
const automapGroup = doc.createElement('div')
|
||||
automapGroup.className = 'd2-toolbar__group'
|
||||
const automapLabel = doc.createElement('label')
|
||||
automapLabel.className = 'd2-toolbar__label'
|
||||
automapLabel.htmlFor = 'd2-toolbar-automap-select'
|
||||
const automapSelect = doc.createElement('select')
|
||||
automapSelect.id = 'd2-toolbar-automap-select'
|
||||
automapSelect.className = 'd2-toolbar__select'
|
||||
automapSelect.addEventListener('change', () => {
|
||||
this.setAutomapReveal(automapSelect.value as AutomapRevealMode)
|
||||
})
|
||||
automapGroup.appendChild(automapLabel)
|
||||
automapGroup.appendChild(automapSelect)
|
||||
|
||||
// Group 5: Viewport Size Selector (every VALID_VIEWPORT_RESOLUTIONS entry) + Asset Cache Clear button
|
||||
// Group 3: Viewport Size Selector + Asset Cache Clear button
|
||||
const viewportGroup = doc.createElement('div')
|
||||
viewportGroup.className = 'd2-toolbar__group'
|
||||
const viewportLabel = doc.createElement('label')
|
||||
|
|
@ -726,12 +738,6 @@ a.d2-toolbar__btn {
|
|||
const viewportSelect = doc.createElement('select')
|
||||
viewportSelect.id = 'd2-toolbar-viewport-select'
|
||||
viewportSelect.className = 'd2-toolbar__select'
|
||||
for (const vp of VALID_VIEWPORT_RESOLUTIONS) {
|
||||
const opt = doc.createElement('option')
|
||||
opt.value = vp
|
||||
opt.textContent = formatViewportLabel(vp)
|
||||
viewportSelect.appendChild(opt)
|
||||
}
|
||||
viewportSelect.addEventListener('change', () => {
|
||||
this.setViewport(viewportSelect.value as ViewportResolution)
|
||||
})
|
||||
|
|
@ -755,8 +761,6 @@ a.d2-toolbar__btn {
|
|||
|
||||
root.appendChild(statusGroup)
|
||||
root.appendChild(langGroup)
|
||||
root.appendChild(lightingGroup)
|
||||
root.appendChild(automapGroup)
|
||||
root.appendChild(viewportGroup)
|
||||
root.appendChild(diagBanner)
|
||||
|
||||
|
|
@ -770,13 +774,8 @@ a.d2-toolbar__btn {
|
|||
this.rttTextEl = rttText
|
||||
this.langBtnZhEl = langBtnZh
|
||||
this.langBtnEnEl = langBtnEn
|
||||
this.lightingLabelEl = lightingLabel
|
||||
this.lightingSelectEl = lightingSelect
|
||||
this.automapLabelEl = automapLabel
|
||||
this.automapSelectEl = automapSelect
|
||||
this.viewportLabelEl = viewportLabel
|
||||
this.viewportSelectEl = viewportSelect
|
||||
this.inspectorBtnEl = inspectorBtn
|
||||
this.pluginsBtnEl = pluginsBtn
|
||||
this.pluginSlotEl = pluginSlot
|
||||
this.cacheClearBtnEl = cacheClearBtn
|
||||
|
|
@ -799,12 +798,6 @@ a.d2-toolbar__btn {
|
|||
if (this.rttTextEl) {
|
||||
this.rttTextEl.textContent = snap.rttLabel
|
||||
}
|
||||
if (this.inspectorBtnEl) {
|
||||
this.inspectorBtnEl.textContent = tToolbar('toolbar.inspector.toggle', lang)
|
||||
this.inspectorBtnEl.className = snap.settings.inspectorOpen
|
||||
? 'd2-toolbar__btn d2-toolbar__btn--active'
|
||||
: 'd2-toolbar__btn'
|
||||
}
|
||||
if (this.pluginsBtnEl) {
|
||||
const baseLabel = tToolbar('toolbar.plugins.open', lang)
|
||||
this.pluginsBtnEl.disabled = !this.pluginsReady
|
||||
|
|
@ -818,8 +811,38 @@ a.d2-toolbar__btn {
|
|||
this.pluginsBtnEl.className = cls
|
||||
}
|
||||
for (const entry of this.contributions.values()) {
|
||||
if (entry.el) {
|
||||
const label = resolveLocalizedText(entry.spec.label, lang)
|
||||
if (!entry.el) continue
|
||||
const spec = entry.spec
|
||||
const label = resolveLocalizedText(spec.label, lang)
|
||||
if (spec.kind === 'select') {
|
||||
if (entry.labelEl) {
|
||||
entry.labelEl.textContent = label
|
||||
}
|
||||
if (entry.selectEl && typeof document !== 'undefined') {
|
||||
const currentOpts = Array.from(entry.selectEl.options)
|
||||
if (
|
||||
currentOpts.length !== spec.options.length ||
|
||||
currentOpts.some((o, idx) => o.value !== spec.options[idx]?.value)
|
||||
) {
|
||||
entry.selectEl.innerHTML = ''
|
||||
for (const optSpec of spec.options) {
|
||||
const opt = document.createElement('option')
|
||||
opt.value = optSpec.value
|
||||
opt.textContent = resolveLocalizedText(optSpec.label, lang)
|
||||
entry.selectEl.appendChild(opt)
|
||||
}
|
||||
} else {
|
||||
for (let idx = 0; idx < spec.options.length; idx += 1) {
|
||||
const optSpec = spec.options[idx]!
|
||||
const optEl = currentOpts[idx]
|
||||
if (optEl) {
|
||||
optEl.textContent = resolveLocalizedText(optSpec.label, lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.selectEl.value = spec.getValue()
|
||||
}
|
||||
} else {
|
||||
entry.el.textContent = label
|
||||
entry.el.title = label
|
||||
}
|
||||
|
|
@ -832,37 +855,23 @@ a.d2-toolbar__btn {
|
|||
this.langBtnEnEl.className =
|
||||
lang === 'en' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn'
|
||||
}
|
||||
if (this.lightingLabelEl) {
|
||||
this.lightingLabelEl.textContent = tToolbar('toolbar.lighting.label', lang)
|
||||
}
|
||||
if (this.lightingSelectEl && typeof document !== 'undefined') {
|
||||
this.lightingSelectEl.innerHTML = ''
|
||||
for (const presetId of CLIENT_LIGHTING_PRESET_ORDER) {
|
||||
const cfg = LIGHTING_PRESETS[presetId]
|
||||
const opt = document.createElement('option')
|
||||
opt.value = presetId
|
||||
opt.textContent = lang === 'en' ? cfg.labelEn : cfg.labelZh
|
||||
this.lightingSelectEl.appendChild(opt)
|
||||
}
|
||||
this.lightingSelectEl.value = snap.settings.lightingPreset
|
||||
}
|
||||
if (this.automapLabelEl) {
|
||||
this.automapLabelEl.textContent = tToolbar('toolbar.automap.label', lang)
|
||||
}
|
||||
if (this.automapSelectEl && typeof document !== 'undefined') {
|
||||
this.automapSelectEl.innerHTML = ''
|
||||
for (const mode of VALID_AUTOMAP_REVEAL_MODES) {
|
||||
const opt = document.createElement('option')
|
||||
opt.value = mode
|
||||
opt.textContent = tToolbar(`toolbar.automap.${mode}` as const, lang)
|
||||
this.automapSelectEl.appendChild(opt)
|
||||
}
|
||||
this.automapSelectEl.value = snap.settings.automapReveal
|
||||
}
|
||||
if (this.viewportLabelEl) {
|
||||
this.viewportLabelEl.textContent = tToolbar('toolbar.viewport.label', lang)
|
||||
}
|
||||
if (this.viewportSelectEl) {
|
||||
if (this.viewportSelectEl && typeof document !== 'undefined') {
|
||||
const existingValues = Array.from(this.viewportSelectEl.options).map((o) => o.value)
|
||||
const needsRebuild =
|
||||
existingValues.length !== this.allowedViewports.length ||
|
||||
existingValues.some((v, idx) => v !== this.allowedViewports[idx])
|
||||
if (needsRebuild) {
|
||||
this.viewportSelectEl.innerHTML = ''
|
||||
for (const vp of this.allowedViewports) {
|
||||
const opt = document.createElement('option')
|
||||
opt.value = vp
|
||||
opt.textContent = formatViewportLabel(vp)
|
||||
this.viewportSelectEl.appendChild(opt)
|
||||
}
|
||||
}
|
||||
this.viewportSelectEl.value = snap.settings.viewport
|
||||
}
|
||||
if (this.cacheClearBtnEl) {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,17 @@ export const VIEWPORT_1068x600: ViewportProfile = createViewportProfile('1068x60
|
|||
/** 16:9 720p: bar at (240, 120), panels at y = 60. */
|
||||
export const VIEWPORT_1280x720: ViewportProfile = createViewportProfile('1280x720', 1280, 720)
|
||||
|
||||
export const NATIVE_113C_VIEWPORT_RESOLUTIONS: readonly ViewportResolution[] = [
|
||||
'640x480',
|
||||
'800x600',
|
||||
] as const
|
||||
|
||||
export const EXTENDED_VIEWPORT_RESOLUTIONS: readonly ViewportResolution[] = [
|
||||
'1024x768',
|
||||
'1068x600',
|
||||
'1280x720',
|
||||
] as const
|
||||
|
||||
export const VIEWPORT_PROFILES: Readonly<Record<ViewportResolution, ViewportProfile>> = {
|
||||
'640x480': VIEWPORT_640x480,
|
||||
'800x600': VIEWPORT_800x600,
|
||||
|
|
|
|||
|
|
@ -978,7 +978,7 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
|||
expect(session.world.areaId).toBe(40)
|
||||
expect(session.predictor.getSnapshot().logicalX).toBe(620)
|
||||
expect(session.predictor.getSnapshot().logicalY).toBe(640)
|
||||
expect(session.packetInspector.getAllRows().length).toBeGreaterThanOrEqual(2)
|
||||
expect(session.packetTap.entries.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// 4. Graceful disconnect recovery
|
||||
const disconnectReasons: string[] = []
|
||||
|
|
@ -1023,10 +1023,7 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
|||
expect(session.world.containers.belt.size).toBeGreaterThanOrEqual(4)
|
||||
expect(session.hudManager.inventory.gridItems.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const decodedRows = session.packetInspector
|
||||
.getAllRows()
|
||||
.filter((r) => r.decoded !== undefined)
|
||||
expect(decodedRows.length).toBeGreaterThan(0)
|
||||
expect(session.packetTap.entries.length).toBe(recordCount)
|
||||
|
||||
session.destroy()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,6 @@ import {
|
|||
type AudioBackendLike,
|
||||
} from '../../src/client/audio/sound-service.ts'
|
||||
import { AutomapView } from '../../src/client/automap/automap-view.ts'
|
||||
import {
|
||||
formatHexDump,
|
||||
PacketInspector,
|
||||
} from '../../src/client/inspector/packet-inspector.ts'
|
||||
import { MapService } from '../../src/client/map/map-service.ts'
|
||||
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
|
||||
import {
|
||||
|
|
@ -23,6 +19,7 @@ import {
|
|||
parseClientSettingsJson,
|
||||
sanitizeClientSettings,
|
||||
SETTINGS_STORAGE_KEY,
|
||||
VALID_VIEWPORT_RESOLUTIONS,
|
||||
validateServerHost,
|
||||
} from '../../src/client/settings/client-settings.ts'
|
||||
import {
|
||||
|
|
@ -61,6 +58,7 @@ import {
|
|||
asSubTile,
|
||||
asUnitId,
|
||||
InMemoryPacketTap,
|
||||
parseD2CapJsonl,
|
||||
} from '../../src/netproto/index.ts'
|
||||
import {
|
||||
applyLightingInputs,
|
||||
|
|
@ -113,7 +111,6 @@ describe('ClientSettings & SettingsStore (F14)', () => {
|
|||
lightingPreset: 'non_existent_preset',
|
||||
automapReveal: 'invalid_mode',
|
||||
viewport: '1920x1080',
|
||||
inspectorOpen: 'yes',
|
||||
audioMuted: 123,
|
||||
masterVolume: 5.5,
|
||||
extraHackField: 'should_be_stripped',
|
||||
|
|
@ -135,7 +132,6 @@ describe('ClientSettings & SettingsStore (F14)', () => {
|
|||
lightingPreset: 'auto',
|
||||
automapReveal: 'off',
|
||||
viewport: '800x600',
|
||||
inspectorOpen: false,
|
||||
audioMuted: false,
|
||||
masterVolume: 1.0,
|
||||
})
|
||||
|
|
@ -182,7 +178,7 @@ describe('ClientSettings & SettingsStore (F14)', () => {
|
|||
})
|
||||
|
||||
describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => {
|
||||
it('supports all 6 controls, rejects bare IP on HTTPS, and prompts confirmation when changing host mid-session', () => {
|
||||
it('supports core controls, rejects bare IP on HTTPS, and prompts confirmation when changing host mid-session', () => {
|
||||
const storage = new MemoryStorage()
|
||||
const store = new SettingsStore({
|
||||
storage,
|
||||
|
|
@ -194,19 +190,18 @@ describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => {
|
|||
const lightingSpy = vi.fn()
|
||||
const automapSpy = vi.fn()
|
||||
const viewportSpy = vi.fn()
|
||||
const inspectorSpy = vi.fn()
|
||||
let confirmResponse = false
|
||||
|
||||
const toolbar = new Toolbar({
|
||||
store,
|
||||
pageProtocol: 'https:',
|
||||
allowedViewports: VALID_VIEWPORT_RESOLUTIONS,
|
||||
confirmReconnect: () => confirmResponse,
|
||||
callbacks: {
|
||||
onServerHostReconnect: reconnectSpy,
|
||||
onLightingChange: lightingSpy,
|
||||
onAutomapRevealChange: automapSpy,
|
||||
onViewportChange: viewportSpy,
|
||||
onInspectorToggle: inspectorSpy,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -275,11 +270,6 @@ describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => {
|
|||
expect(store.get().viewport).toBe('1024x768')
|
||||
expect(viewportSpy).toHaveBeenCalledWith(VIEWPORT_1024x768)
|
||||
|
||||
// Packet Inspector Toggle
|
||||
expect(toolbar.toggleInspector()).toBe(true)
|
||||
expect(store.get().inspectorOpen).toBe(true)
|
||||
expect(inspectorSpy).toHaveBeenCalledWith(true)
|
||||
|
||||
// Plugins button i18n
|
||||
expect(tToolbar('toolbar.plugins.open', 'zh')).toBe('插件')
|
||||
expect(tToolbar('toolbar.plugins.open', 'en')).toBe('Plugins')
|
||||
|
|
@ -712,7 +702,7 @@ describe('HttpRangeMpqSource, OpfsMpqManager & SoundService (F19)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('OutboundRateLimiter & PacketInspector / .d2cap Replay (F20)', () => {
|
||||
describe('OutboundRateLimiter & PacketTap / .d2cap Capture (F20)', () => {
|
||||
it('throttles rapid movement packets, coalesces trailing move target, and never drops Ping heartbeats', () => {
|
||||
const limiter = new OutboundRateLimiter({
|
||||
moveMinIntervalMs: 120,
|
||||
|
|
@ -747,20 +737,8 @@ describe('OutboundRateLimiter & PacketInspector / .d2cap Replay (F20)', () => {
|
|||
expect(limiter.tryAcquire({ type: 'Ping' }, 1131).allowed).toBe(true)
|
||||
})
|
||||
|
||||
it('captures PacketTap traffic, filters by phase/direction/opcode, exports .d2cap JSONL, and replays deterministically', () => {
|
||||
const store = new SettingsStore({
|
||||
storage: new MemoryStorage(),
|
||||
defaultLang: 'en',
|
||||
syncGlobalLang: false,
|
||||
})
|
||||
it('captures PacketTap traffic, exports .d2cap JSONL, and parses records deterministically', () => {
|
||||
const tap = new InMemoryPacketTap()
|
||||
const replayed: number[] = []
|
||||
|
||||
const inspector = new PacketInspector({
|
||||
store,
|
||||
tap,
|
||||
onReplayRecord: frame => replayed.push(frame.opcode),
|
||||
})
|
||||
|
||||
tap.record({
|
||||
t: 10,
|
||||
|
|
@ -784,37 +762,15 @@ describe('OutboundRateLimiter & PacketInspector / .d2cap Replay (F20)', () => {
|
|||
raw: new Uint8Array([0x03, 0x64, 0x00, 0x64, 0x00]),
|
||||
})
|
||||
|
||||
expect(inspector.getAllRows()).toHaveLength(3)
|
||||
expect(formatHexDump(new Uint8Array([0x15, 0x41, 0x42]))).toContain('15 41 42')
|
||||
|
||||
// Filter by d2gs + s2c
|
||||
inspector.setFilter({ phase: 'd2gs', direction: 's2c' })
|
||||
const s2cRows = inspector.getFilteredRows()
|
||||
expect(s2cRows).toHaveLength(1)
|
||||
expect(s2cRows[0]!.opcode).toBe(0x15)
|
||||
|
||||
// Filter by hex opcode string
|
||||
inspector.setFilter({ phase: 'all', direction: 'all', opcode: '0x03' })
|
||||
expect(inspector.getFilteredRows()).toHaveLength(1)
|
||||
|
||||
// Export to .d2cap JSONL and replay into a fresh inspector
|
||||
const jsonl = inspector.exportD2CapJsonl()
|
||||
expect(jsonl.split('\n')).toHaveLength(3)
|
||||
|
||||
inspector.clear()
|
||||
inspector.setFilter({ phase: 'all', direction: 'all', opcode: null })
|
||||
const loaded = inspector.loadD2CapJsonl(jsonl)
|
||||
expect(loaded).toHaveLength(3)
|
||||
|
||||
const step1 = inspector.stepReplay()
|
||||
expect(step1?.opcode).toBe(0x50)
|
||||
expect(replayed).toEqual([0x50])
|
||||
|
||||
const remaining = inspector.replayAll()
|
||||
expect(remaining).toBe(2)
|
||||
expect(replayed).toEqual([0x50, 0x15, 0x03])
|
||||
|
||||
inspector.destroy()
|
||||
expect(tap.entries).toHaveLength(3)
|
||||
const jsonl = tap.toD2CapJsonl({
|
||||
format: 'd2cap-v1',
|
||||
createdAt: '2026-10-03T00:00:00Z',
|
||||
server: '127.0.0.1',
|
||||
})
|
||||
const parsed = parseD2CapJsonl(jsonl)
|
||||
expect(parsed.records).toHaveLength(3)
|
||||
expect(parsed.records.map((r) => r.packetId)).toEqual([0x50, 0x15, 0x03])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ import {
|
|||
DEFAULT_CLIENT_SETTINGS,
|
||||
DEFAULT_SERVER_HOST,
|
||||
deriveRealmWsEndpoints,
|
||||
VALID_VIEWPORT_RESOLUTIONS,
|
||||
validateServerHost,
|
||||
} from '../../src/client/settings/client-settings.ts'
|
||||
import { SettingsStore, type StorageLike } from '../../src/client/settings/settings-store.ts'
|
||||
|
|
@ -144,14 +145,11 @@ import {
|
|||
InMemoryChunkCache,
|
||||
OpfsMpqManager,
|
||||
} from '../../src/client/assets/opfs-mpq.ts'
|
||||
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
||||
import {
|
||||
classifyClientCommand,
|
||||
OutboundRateLimiter,
|
||||
} from '../../src/client/session/rate-limiter.ts'
|
||||
import {
|
||||
formatHexDump,
|
||||
PacketInspector,
|
||||
} from '../../src/client/inspector/packet-inspector.ts'
|
||||
import {
|
||||
D2S_MAGIC,
|
||||
D2S_QUEST_OFFSET,
|
||||
|
|
@ -1430,13 +1428,13 @@ describe('Tier 1 — F16: Top Control Toolbar & i18n (en/zh)', () => {
|
|||
toolbar.destroy()
|
||||
})
|
||||
|
||||
it('F16.2: Toolbar toggles viewport between 800x600 and 1024x768', () => {
|
||||
it('F16.2: Toolbar toggles viewport between 800x600 and 1024x768 when extended viewports are allowed', () => {
|
||||
const store = new SettingsStore({
|
||||
storage: new MemoryStorage(),
|
||||
defaultLang: 'en',
|
||||
syncGlobalLang: false,
|
||||
})
|
||||
const toolbar = new Toolbar({ store })
|
||||
const toolbar = new Toolbar({ store, allowedViewports: VALID_VIEWPORT_RESOLUTIONS })
|
||||
expect(store.get().viewport).toBe('800x600')
|
||||
const next = toolbar.toggleViewport()
|
||||
expect(next).toEqual(VIEWPORT_1024x768)
|
||||
|
|
@ -1792,10 +1790,8 @@ describe('Tier 1 — F20: Outbound Rate Limiter, ProtocolSession & Packet Inspec
|
|||
expect(limiter.tryAcquire({ type: 'Ping' }, 1055).allowed).toBe(true)
|
||||
})
|
||||
|
||||
it('F20.4: PacketInspector captures PacketTap entries, filters by phase/direction/opcode, and formats hex dumps', () => {
|
||||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||||
it('F20.4: InMemoryPacketTap captures PacketTap entries and serializes/parses .d2cap JSONL records', () => {
|
||||
const tap = new InMemoryPacketTap()
|
||||
const inspector = new PacketInspector({ store, tap })
|
||||
|
||||
tap.record({
|
||||
t: 10,
|
||||
|
|
@ -1812,36 +1808,32 @@ describe('Tier 1 — F20: Outbound Rate Limiter, ProtocolSession & Packet Inspec
|
|||
raw: new Uint8Array([0x15, 0x00, 0x01, 0x00, 0x00, 0x00]),
|
||||
})
|
||||
|
||||
expect(inspector.getAllRows()).toHaveLength(2)
|
||||
expect(formatHexDump(new Uint8Array([0x15, 0x41, 0x42]))).toContain('15 41 42')
|
||||
|
||||
inspector.setFilter({ phase: 'd2gs', direction: 's2c' })
|
||||
expect(inspector.getFilteredRows()).toHaveLength(1)
|
||||
expect(inspector.getFilteredRows()[0]!.opcode).toBe(0x15)
|
||||
inspector.destroy()
|
||||
expect(tap.entries).toHaveLength(2)
|
||||
const jsonl = tap.toD2CapJsonl({ format: 'd2cap-v1', createdAt: '2026-01-01T00:00:00Z', server: '127.0.0.1' })
|
||||
const parsed = parseD2CapJsonl(jsonl)
|
||||
expect(parsed.records).toHaveLength(2)
|
||||
const gameS2c = parsed.records.filter((r) => r.conn === 'game' && r.dir === 's2c')
|
||||
expect(gameS2c).toHaveLength(1)
|
||||
expect(gameS2c[0]!.packetId).toBe(0x15)
|
||||
})
|
||||
|
||||
it('F20.5: PacketInspector exports and replays .d2cap JSONL traces via stepReplay and replayAll', () => {
|
||||
it('F20.5: OnlineSession loads and replays .d2cap JSONL traces via stepD2CapReplay and replayAllD2Cap', () => {
|
||||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||||
const tap = new InMemoryPacketTap()
|
||||
const replayed: number[] = []
|
||||
const inspector = new PacketInspector({
|
||||
store,
|
||||
tap,
|
||||
onReplayRecord: (frame) => replayed.push(frame.opcode),
|
||||
})
|
||||
const session = new OnlineSession({ settingsStore: store, itemTables })
|
||||
const replayedEvents: string[] = []
|
||||
session.onServerEvent((ev) => replayedEvents.push(ev.type))
|
||||
|
||||
tap.record({ t: 5, conn: 'game', dir: 's2c', packetId: 0x00, raw: new Uint8Array([0x00]) })
|
||||
tap.record({ t: 15, conn: 'game', dir: 's2c', packetId: 0x02, raw: new Uint8Array([0x02]) })
|
||||
const jsonl = inspector.exportD2CapJsonl()
|
||||
tap.record({ t: 15, conn: 'game', dir: 's2c', packetId: 0x04, raw: new Uint8Array([0x04]) })
|
||||
const jsonl = tap.toD2CapJsonl({ format: 'd2cap-v1', createdAt: '2026-01-01T00:00:00Z', server: '127.0.0.1' })
|
||||
|
||||
inspector.clear()
|
||||
const loaded = inspector.loadD2CapJsonl(jsonl)
|
||||
expect(loaded).toHaveLength(2)
|
||||
const loadedCount = session.loadD2CapReplay(jsonl)
|
||||
expect(loadedCount).toBe(2)
|
||||
|
||||
expect(inspector.stepReplay()?.opcode).toBe(0x00)
|
||||
expect(inspector.replayAll()).toBe(1)
|
||||
expect(replayed).toEqual([0x00, 0x02])
|
||||
inspector.destroy()
|
||||
expect(session.stepD2CapReplay()?.opcode).toBe(0x00)
|
||||
expect(session.replayAllD2Cap()).toBe(1)
|
||||
expect(replayedEvents).toEqual(['GameLoading', 'LoadDone'])
|
||||
session.destroy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -107,8 +107,8 @@ import {
|
|||
OpfsMpqManager,
|
||||
} from '../../src/client/assets/opfs-mpq.ts'
|
||||
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
|
||||
import { formatHexDump, PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
|
||||
import { asSubTile } from '../../src/netproto/index.ts'
|
||||
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
||||
import { asSubTile, bytesToHex } from '../../src/netproto/index.ts'
|
||||
|
||||
class MemoryStorage implements StorageLike {
|
||||
readonly data = new Map<string, string>()
|
||||
|
|
@ -1079,15 +1079,17 @@ describe('Tier 2 — Negative, Boundary, Malformed & Corner Cases (F1–F20)', (
|
|||
toolbar.destroy()
|
||||
})
|
||||
|
||||
it('F16.B4: Toolbar.toggleInspector flips inspectorOpen boolean state cleanly', () => {
|
||||
it('F16.B4: Toolbar.setAllowedViewports clamps non-1.13c viewport back to 800x600 when restricted', () => {
|
||||
const store = new SettingsStore({
|
||||
storage: new MemoryStorage(),
|
||||
defaultLang: 'en',
|
||||
syncGlobalLang: false,
|
||||
})
|
||||
store.update({ viewport: '1024x768' })
|
||||
const toolbar = new Toolbar({ store })
|
||||
expect(toolbar.toggleInspector()).toBe(true)
|
||||
expect(toolbar.toggleInspector()).toBe(false)
|
||||
toolbar.setAllowedViewports(['640x480', '800x600'])
|
||||
expect(store.get().viewport).toBe('800x600')
|
||||
expect(toolbar.getAllowedViewports()).toEqual(['640x480', '800x600'])
|
||||
toolbar.destroy()
|
||||
})
|
||||
|
||||
|
|
@ -1303,23 +1305,24 @@ describe('Tier 2 — Negative, Boundary, Malformed & Corner Cases (F1–F20)', (
|
|||
expect(third.reason).toBe('token_bucket_empty')
|
||||
})
|
||||
|
||||
it('F20.B3: PacketInspector respects maxRows ring buffer limit and stepReplay returns null when empty', () => {
|
||||
it('F20.B3: InMemoryPacketTap respects maxEntries ring buffer limit and OnlineSession.stepD2CapReplay returns null when empty', () => {
|
||||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||||
const tap = new InMemoryPacketTap()
|
||||
const inspector = new PacketInspector({ store, tap, maxRows: 2 })
|
||||
const tap = new InMemoryPacketTap(2)
|
||||
const session = new OnlineSession({ settingsStore: store })
|
||||
tap.record({ t: 1, conn: 'game', dir: 's2c', packetId: 0x00, raw: new Uint8Array([0x00]) })
|
||||
tap.record({ t: 2, conn: 'game', dir: 's2c', packetId: 0x02, raw: new Uint8Array([0x02]) })
|
||||
tap.record({ t: 3, conn: 'game', dir: 's2c', packetId: 0x04, raw: new Uint8Array([0x04]) })
|
||||
expect(inspector.getAllRows()).toHaveLength(2)
|
||||
expect(inspector.getAllRows()[0]?.opcode).toBe(0x02)
|
||||
expect(inspector.stepReplay()).toBeNull()
|
||||
inspector.destroy()
|
||||
expect(tap.entries).toHaveLength(2)
|
||||
expect(tap.entries[0]?.packetId).toBe(0x02)
|
||||
expect(session.stepD2CapReplay()).toBeNull()
|
||||
session.destroy()
|
||||
})
|
||||
|
||||
it('F20.B4: formatHexDump handles empty 0-byte buffer and multi-line (>16 byte) buffers', () => {
|
||||
expect(formatHexDump(new Uint8Array(0))).toBeDefined()
|
||||
const dump = formatHexDump(new Uint8Array(32).fill(0x41))
|
||||
expect(dump.split('\n').length).toBeGreaterThanOrEqual(2)
|
||||
it('F20.B4: bytesToHex and hexToBytes handle empty 0-byte buffer and multi-line (>16 byte) buffers', () => {
|
||||
expect(bytesToHex(new Uint8Array(0))).toBe('')
|
||||
const hex = bytesToHex(new Uint8Array(32).fill(0x41))
|
||||
expect(hex.length).toBe(64)
|
||||
expect(hexToBytes(hex)).toHaveLength(32)
|
||||
})
|
||||
|
||||
it('F20.B5: parseD2CapJsonl ignores blank and comment lines and throws on malformed JSON line', () => {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ import {
|
|||
} from '../../tools/d2s-unlock.ts'
|
||||
import {
|
||||
deriveRealmWsEndpoints,
|
||||
VALID_VIEWPORT_RESOLUTIONS,
|
||||
validateServerHost,
|
||||
} from '../../src/client/settings/client-settings.ts'
|
||||
import {
|
||||
|
|
@ -126,8 +127,8 @@ import {
|
|||
InMemoryChunkCache,
|
||||
OpfsMpqManager,
|
||||
} from '../../src/client/assets/opfs-mpq.ts'
|
||||
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
||||
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
|
||||
import { PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
|
||||
import { LightGrid } from '../../src/common/drlg/light-grid.ts'
|
||||
|
||||
class MemoryStorage implements StorageLike {
|
||||
|
|
@ -747,7 +748,7 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
|
|||
it('X16 (F15 × F16): SettingsStore and Toolbar synchronize viewport, lighting, automapReveal, and language', () => {
|
||||
const storage = new MemoryStorage()
|
||||
const store = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false })
|
||||
const toolbar = new Toolbar({ store })
|
||||
const toolbar = new Toolbar({ store, allowedViewports: VALID_VIEWPORT_RESOLUTIONS })
|
||||
|
||||
toolbar.toggleViewport()
|
||||
toolbar.setLightingPreset('fullbright')
|
||||
|
|
@ -795,7 +796,7 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
|
|||
|
||||
it('X19 (F16 × F17): Toolbar viewport and lighting toggles drive ClientWorldSceneSource and LightGrid rendering', () => {
|
||||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||||
const toolbar = new Toolbar({ store })
|
||||
const toolbar = new Toolbar({ store, allowedViewports: VALID_VIEWPORT_RESOLUTIONS })
|
||||
const automap = new AutomapView({ initialMode: 'level' })
|
||||
const levelView = automap.setContext(0x12345678, 0, 1)
|
||||
|
||||
|
|
@ -944,19 +945,10 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
|
|||
expect(new ByteReader(sentFrames[1]!.subarray(1)).u16LE()).toBe(5110)
|
||||
})
|
||||
|
||||
it('X23 (F2 × F20): InMemoryPacketTap + PacketInspector filter, export .d2cap JSONL, and replay into ClientWorld', () => {
|
||||
it('X23 (F2 × F20): InMemoryPacketTap + OnlineSession serialize .d2cap JSONL and replay into ClientWorld', () => {
|
||||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||||
const tap = new InMemoryPacketTap()
|
||||
const world = new ClientWorld()
|
||||
const inspector = new PacketInspector({
|
||||
store,
|
||||
tap,
|
||||
onReplayRecord: (frame, rawBytes) => {
|
||||
if (frame.phase === 'd2gs' && frame.dir === 's2c') {
|
||||
world.apply(decodeD2gsS2cPacket(rawBytes, { tables: itemTables }))
|
||||
}
|
||||
},
|
||||
})
|
||||
const session = new OnlineSession({ settingsStore: store, itemTables })
|
||||
|
||||
const w03 = new ByteWriter()
|
||||
w03.u8(0x03)
|
||||
|
|
@ -967,33 +959,26 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
|
|||
tap.record({ t: 10, conn: 'game', dir: 's2c', packetId: 0x03, raw: w03.toUint8Array() })
|
||||
tap.record({ t: 20, conn: 'game', dir: 's2c', packetId: 0x1d, raw: new Uint8Array([0x1d, 0, 77]) })
|
||||
|
||||
const jsonl = inspector.exportD2CapJsonl()
|
||||
inspector.clear()
|
||||
inspector.loadD2CapJsonl(jsonl)
|
||||
inspector.replayAll()
|
||||
const jsonl = tap.toD2CapJsonl({ format: 'd2cap-v1', createdAt: '2026-01-01T00:00:00Z', server: '127.0.0.1' })
|
||||
session.loadD2CapReplay(jsonl)
|
||||
session.replayAllD2Cap()
|
||||
|
||||
expect(world.mapSeed).toBe(0x13572468)
|
||||
expect(world.self.stats.get(0)).toBe(77)
|
||||
inspector.destroy()
|
||||
expect(session.world.mapSeed).toBe(0x13572468)
|
||||
expect(session.world.self.stats.get(0)).toBe(77)
|
||||
session.destroy()
|
||||
})
|
||||
|
||||
it('X24 (F16 × F20): Toolbar toggleInspector and reportDiagnostic synchronize with SettingsStore and PacketInspector', () => {
|
||||
it('X24 (F16 × F20): Toolbar reportDiagnostic and plugin error indicator synchronize with SettingsStore', () => {
|
||||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||||
const tap = new InMemoryPacketTap()
|
||||
const toolbar = new Toolbar({ store })
|
||||
const inspector = new PacketInspector({ store, tap })
|
||||
|
||||
expect(inspector.isOpen()).toBe(false)
|
||||
toolbar.setInspectorOpen(true)
|
||||
expect(inspector.isOpen()).toBe(true)
|
||||
|
||||
toolbar.setPluginsButtonState({ ready: true, hasWarning: true })
|
||||
toolbar.reportDiagnostic('custom', 'Unknown S2C 0xFE')
|
||||
const snap = toolbar.getSnapshot()
|
||||
expect(snap.connection.phase).toBe('error')
|
||||
expect(snap.diagnosticBannerText).toContain('0xFE')
|
||||
expect(validateServerHost(snap.settings.serverHost, 'https:').valid).toBe(true)
|
||||
|
||||
inspector.destroy()
|
||||
toolbar.destroy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import {
|
|||
} from '../../tools/netproto-replay.ts'
|
||||
import {
|
||||
deriveRealmWsEndpoints,
|
||||
VALID_VIEWPORT_RESOLUTIONS,
|
||||
validateServerHost,
|
||||
} from '../../src/client/settings/client-settings.ts'
|
||||
import {
|
||||
|
|
@ -108,7 +109,6 @@ import {
|
|||
OpfsMpqManager,
|
||||
} from '../../src/client/assets/opfs-mpq.ts'
|
||||
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
|
||||
import { PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
|
||||
|
||||
// ─── Shared Test Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -575,7 +575,7 @@ describe('Tier 4 — End-to-End Real-World Application Scenarios (S1–S12)', ()
|
|||
defaultLang: 'en',
|
||||
syncGlobalLang: true,
|
||||
})
|
||||
const toolbar = new Toolbar({ store })
|
||||
const toolbar = new Toolbar({ store, allowedViewports: VALID_VIEWPORT_RESOLUTIONS })
|
||||
const automap = new AutomapView({ initialMode: 'off' })
|
||||
const levelView = automap.setContext(0x12345678, 0, 1)
|
||||
|
||||
|
|
@ -706,10 +706,8 @@ describe('Tier 4 — End-to-End Real-World Application Scenarios (S1–S12)', ()
|
|||
sound.destroy()
|
||||
})
|
||||
|
||||
it('S11: Anti-flood OutboundRateLimiter + InMemoryPacketTap + PacketInspector JSONL export & strict .d2cap replay round-trip', () => {
|
||||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||||
it('S11: Anti-flood OutboundRateLimiter + InMemoryPacketTap JSONL export & strict .d2cap replay round-trip', () => {
|
||||
const tap = new InMemoryPacketTap()
|
||||
const inspector = new PacketInspector({ store, tap })
|
||||
const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120 })
|
||||
|
||||
// Rapid movement burst coalesces while preserving the latest destination
|
||||
|
|
@ -738,15 +736,14 @@ describe('Tier 4 — End-to-End Real-World Application Scenarios (S1–S12)', ()
|
|||
tap.record({ t: 1210, conn: 'game', dir: 's2c', packetId: 0x03, raw: w03.toUint8Array() })
|
||||
tap.record({ t: 1220, conn: 'game', dir: 's2c', packetId: 0x1d, raw: new Uint8Array([0x1d, 0, 99]) })
|
||||
|
||||
expect(inspector.getAllRows().length).toBe(4)
|
||||
expect(tap.entries.length).toBe(4)
|
||||
|
||||
const jsonl = inspector.exportD2CapJsonl()
|
||||
const jsonl = tap.toD2CapJsonl({ format: 'd2cap-v1', createdAt: '2026-01-01T00:00:00Z', server: '127.0.0.1' })
|
||||
const replayed = replayD2CapText(jsonl, { strict: true })
|
||||
expect(replayed.protocolErrors).toBe(0)
|
||||
expect(replayed.unknownPackets).toBe(0)
|
||||
expect(replayed.world.mapSeed).toBe(0x11223344)
|
||||
expect(replayed.world.self.stats.get(0)).toBe(99)
|
||||
inspector.destroy()
|
||||
})
|
||||
|
||||
it('S12: Server host reconfiguration, WssBnet concurrency guard, malformed packet isolation, and clean reconnect lifecycle', async () => {
|
||||
|
|
|
|||
|
|
@ -119,7 +119,6 @@ import {
|
|||
} from '../../src/client/audio/sound-service.ts'
|
||||
import { AutomapView, getActForArea } from '../../src/client/automap/automap-view.ts'
|
||||
import { CommandMapper, ViewportCamera } from '../../src/client/input/command-mapper.ts'
|
||||
import { PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
|
||||
import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts'
|
||||
import {
|
||||
findPathLocal,
|
||||
|
|
@ -2114,7 +2113,6 @@ describe('Tier 5 Adversarial Coverage — F11, F16–F20 Rate Limiter, OnlineSes
|
|||
automapReveal: 'maphack',
|
||||
audioMuted: 'yes',
|
||||
masterVolume: 9.5,
|
||||
inspectorOpen: 123,
|
||||
}),
|
||||
)
|
||||
const store2 = new SettingsStore({ storage: rawStorage, storageKey: 'd2.client.settings.v1' })
|
||||
|
|
@ -2122,7 +2120,6 @@ describe('Tier 5 Adversarial Coverage — F11, F16–F20 Rate Limiter, OnlineSes
|
|||
expect(store2.get().automapReveal).toBe('off')
|
||||
expect(store2.get().audioMuted).toBe(false)
|
||||
expect(store2.get().masterVolume).toBe(1.0)
|
||||
expect(store2.get().inspectorOpen).toBe(false)
|
||||
|
||||
// 3. HTTPS bare-IP WSS cert validation (`validateServerHost` & `deriveRealmWsEndpoints`)
|
||||
const bareIpHttps = validateServerHost('101.37.117.183', 'https:')
|
||||
|
|
@ -2405,10 +2402,8 @@ describe('Tier 5 Adversarial Coverage — F11, F16–F20 Rate Limiter, OnlineSes
|
|||
expect(source.getViewportProfile().height).toBe(768)
|
||||
|
||||
const tap = new InMemoryPacketTap(100)
|
||||
const inspector = new PacketInspector({ tap })
|
||||
tap.record({ t: 1, conn: 'game', dir: 's2c', raw: new Uint8Array([0x04]), packetId: 0x04 })
|
||||
expect(inspector.getAllRows()).toHaveLength(1)
|
||||
inspector.destroy()
|
||||
expect(tap.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('adv_31_hud_model_reactive_sync_and_item_to_ui_conversion_edge_cases', () => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { readdirSync, statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { automapRevealPlugin } from '../../plugins/automap-reveal/index.ts'
|
||||
import { extendedViewportsPlugin } from '../../plugins/extended-viewports/index.ts'
|
||||
import { BUILTIN_PLUGINS } from '../../plugins/index.ts'
|
||||
import {
|
||||
createKillStatsPlugin,
|
||||
killStatsPlugin,
|
||||
} from '../../plugins/kill-stats/index.ts'
|
||||
import { lightingPresetsPlugin } from '../../plugins/lighting-presets/index.ts'
|
||||
import {
|
||||
validatePluginRegistry,
|
||||
type PluginActionSpec,
|
||||
|
|
@ -22,6 +25,8 @@ import {
|
|||
type OnlineSessionLobbySnapshot,
|
||||
type OnlineSessionPhase,
|
||||
} from '../../src/client/session/online-session.ts'
|
||||
import { SettingsStore } from '../../src/client/settings/settings-store.ts'
|
||||
import { Toolbar } from '../../src/client/toolbar/toolbar.ts'
|
||||
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
||||
import type { ServerEvent } from '../../src/netproto/index.ts'
|
||||
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts'
|
||||
|
|
@ -39,7 +44,7 @@ class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem
|
|||
}
|
||||
}
|
||||
|
||||
describe('BUILTIN_PLUGINS Registry & killStatsPlugin Lifecycle', () => {
|
||||
describe('BUILTIN_PLUGINS Registry & Built-In Plugin Lifecycles', () => {
|
||||
it('validates BUILTIN_PLUGINS and ensures every plugins/<id>/ directory is registered', () => {
|
||||
expect(() => validatePluginRegistry(BUILTIN_PLUGINS)).not.toThrow()
|
||||
|
||||
|
|
@ -50,6 +55,9 @@ describe('BUILTIN_PLUGINS Registry & killStatsPlugin Lifecycle', () => {
|
|||
const registeredIds = BUILTIN_PLUGINS.map((p) => p.id).sort()
|
||||
expect(registeredIds).toEqual(dirIds)
|
||||
expect(BUILTIN_PLUGINS).toContain(killStatsPlugin)
|
||||
expect(BUILTIN_PLUGINS).toContain(automapRevealPlugin)
|
||||
expect(BUILTIN_PLUGINS).toContain(lightingPresetsPlugin)
|
||||
expect(BUILTIN_PLUGINS).toContain(extendedViewportsPlugin)
|
||||
})
|
||||
|
||||
it('manages #d2-toolbar-plugin-kill-stats-open shortcut, reports mid-game warn/recording/ready statuses, and flushes pending kills on deactivate', async () => {
|
||||
|
|
@ -240,5 +248,134 @@ describe('BUILTIN_PLUGINS Registry & killStatsPlugin Lifecycle', () => {
|
|||
|
||||
await host.destroy()
|
||||
})
|
||||
|
||||
it('automap-reveal (#714), lighting-presets (#715), and extended-viewports (#716) synchronize with SettingsStore/Toolbar and enforce 1.13c fallbacks when disabled', async () => {
|
||||
const settingsStore = new SettingsStore({
|
||||
storage: new MemoryStorage(),
|
||||
defaultLang: 'en',
|
||||
syncGlobalLang: false,
|
||||
})
|
||||
const toolbar = new Toolbar({ store: settingsStore })
|
||||
const session = new OnlineSession({ settingsStore })
|
||||
|
||||
const plugins = [
|
||||
automapRevealPlugin,
|
||||
lightingPresetsPlugin,
|
||||
extendedViewportsPlugin,
|
||||
] as const
|
||||
const configStore = new PluginConfigStore(plugins, {
|
||||
storage: new MemoryStorage(),
|
||||
})
|
||||
|
||||
const registeredSelects = new Map<string, { getValue(): string; onChange(val: string): void }>()
|
||||
const toolbarPort: PluginHostToolbarPort = {
|
||||
setPluginsButtonState() {},
|
||||
addContribution(spec) {
|
||||
const dispose = toolbar.addContribution(spec)
|
||||
if (spec.kind === 'select') {
|
||||
registeredSelects.set(spec.selectDomId, {
|
||||
getValue: spec.getValue,
|
||||
onChange: spec.onChange,
|
||||
})
|
||||
return () => {
|
||||
dispose()
|
||||
registeredSelects.delete(spec.selectDomId)
|
||||
}
|
||||
}
|
||||
return dispose
|
||||
},
|
||||
getAllowedViewports: () => toolbar.getAllowedViewports(),
|
||||
setAllowedViewports: (vps) => toolbar.setAllowedViewports(vps),
|
||||
}
|
||||
|
||||
const host = new PluginHost({
|
||||
plugins,
|
||||
configStore,
|
||||
session,
|
||||
toolbar: toolbarPort,
|
||||
settingsStore,
|
||||
enforceClientDefaults: true,
|
||||
autoStart: false,
|
||||
})
|
||||
|
||||
await host.start()
|
||||
|
||||
// 1. All 3 plugins start enabled by default:
|
||||
// - #d2-toolbar-automap-select and #d2-toolbar-lighting-select are registered
|
||||
// - extended-viewports unlocks all 5 viewport profiles (640x480..1280x720)
|
||||
expect(registeredSelects.has('d2-toolbar-automap-select')).toBe(true)
|
||||
expect(registeredSelects.has('d2-toolbar-lighting-select')).toBe(true)
|
||||
expect(toolbar.getAllowedViewports()).toEqual([
|
||||
'640x480',
|
||||
'800x600',
|
||||
'1024x768',
|
||||
'1068x600',
|
||||
'1280x720',
|
||||
])
|
||||
|
||||
// 2. Automap Reveal (#714): two-way sync + showToolbarShortcut toggle + 1.13c fallback ('off') on disable
|
||||
host.setSetting('automap-reveal', 'mode', 'act')
|
||||
expect(settingsStore.get().automapReveal).toBe('act')
|
||||
expect(registeredSelects.get('d2-toolbar-automap-select')?.getValue()).toBe('act')
|
||||
|
||||
registeredSelects.get('d2-toolbar-automap-select')?.onChange('level')
|
||||
expect(settingsStore.get().automapReveal).toBe('level')
|
||||
expect(host.getSetting('automap-reveal', 'mode')).toBe('level')
|
||||
|
||||
host.setSetting('automap-reveal', 'showToolbarShortcut', false)
|
||||
expect(registeredSelects.has('d2-toolbar-automap-select')).toBe(false)
|
||||
host.setSetting('automap-reveal', 'showToolbarShortcut', true)
|
||||
expect(registeredSelects.has('d2-toolbar-automap-select')).toBe(true)
|
||||
|
||||
await host.setEnabled('automap-reveal', false)
|
||||
expect(registeredSelects.has('d2-toolbar-automap-select')).toBe(false)
|
||||
expect(settingsStore.get().automapReveal).toBe('off')
|
||||
|
||||
await host.setEnabled('automap-reveal', true)
|
||||
expect(settingsStore.get().automapReveal).toBe('level')
|
||||
|
||||
// 3. Lighting Presets (#715): two-way sync + showToolbarShortcut toggle + 1.13c fallback ('auto') on disable
|
||||
host.setSetting('lighting-presets', 'preset', 'fullbright')
|
||||
expect(settingsStore.get().lightingPreset).toBe('fullbright')
|
||||
expect(registeredSelects.get('d2-toolbar-lighting-select')?.getValue()).toBe('fullbright')
|
||||
|
||||
registeredSelects.get('d2-toolbar-lighting-select')?.onChange('torch')
|
||||
expect(settingsStore.get().lightingPreset).toBe('torch')
|
||||
expect(host.getSetting('lighting-presets', 'preset')).toBe('torch')
|
||||
|
||||
host.setSetting('lighting-presets', 'showToolbarShortcut', false)
|
||||
expect(registeredSelects.has('d2-toolbar-lighting-select')).toBe(false)
|
||||
host.setSetting('lighting-presets', 'showToolbarShortcut', true)
|
||||
expect(registeredSelects.has('d2-toolbar-lighting-select')).toBe(true)
|
||||
|
||||
await host.setEnabled('lighting-presets', false)
|
||||
expect(registeredSelects.has('d2-toolbar-lighting-select')).toBe(false)
|
||||
expect(settingsStore.get().lightingPreset).toBe('auto')
|
||||
|
||||
await host.setEnabled('lighting-presets', true)
|
||||
expect(settingsStore.get().lightingPreset).toBe('torch')
|
||||
|
||||
// 4. Extended Viewports (#716): select 1280x720 while enabled -> disable plugin -> clamps back to 800x600 & ['640x480', '800x600']
|
||||
toolbar.setViewport('1280x720')
|
||||
expect(settingsStore.get().viewport).toBe('1280x720')
|
||||
|
||||
await host.setEnabled('extended-viewports', false)
|
||||
expect(toolbar.getAllowedViewports()).toEqual(['640x480', '800x600'])
|
||||
expect(settingsStore.get().viewport).toBe('800x600')
|
||||
|
||||
await host.setEnabled('extended-viewports', true)
|
||||
expect(toolbar.getAllowedViewports()).toEqual([
|
||||
'640x480',
|
||||
'800x600',
|
||||
'1024x768',
|
||||
'1068x600',
|
||||
'1280x720',
|
||||
])
|
||||
|
||||
await host.destroy()
|
||||
session.destroy()
|
||||
toolbar.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -355,7 +355,6 @@ async function main(): Promise<void> {
|
|||
hasToolbar: Boolean(document.getElementById('d2-toolbar-container')?.children.length),
|
||||
hasCanvas: canvas instanceof HTMLCanvasElement,
|
||||
hasHudCanvas: hudCanvas instanceof HTMLCanvasElement,
|
||||
hasInspector: Boolean(document.getElementById('d2-inspector-container')),
|
||||
hasLobbyOverlay: Boolean(document.getElementById('bnet-shell')),
|
||||
hasBnetCanvas: bnetCanvas instanceof HTMLCanvasElement,
|
||||
hasBootProgressBar: Boolean(bootOverlay && bootBar && bootPercentEl && bootFileEl),
|
||||
|
|
@ -375,7 +374,6 @@ async function main(): Promise<void> {
|
|||
!gate1.hasToolbar ||
|
||||
!gate1.hasCanvas ||
|
||||
!gate1.hasHudCanvas ||
|
||||
!gate1.hasInspector ||
|
||||
!gate1.hasLobbyOverlay ||
|
||||
!gate1.hasBnetCanvas ||
|
||||
!gate1.hasBootProgressBar ||
|
||||
|
|
@ -910,29 +908,19 @@ async function main(): Promise<void> {
|
|||
throw new Error(`Gate 6 failed: ${JSON.stringify(automapCheck)}`)
|
||||
}
|
||||
|
||||
// ─── Gate 7: Packet Inspector & All 4 `.d2cap` Capture Fixtures Replay ─
|
||||
// ─── Gate 7: PacketTap Live Capture & All 4 `.d2cap` Capture Fixtures Replay ─
|
||||
const gate7 = await cdp.evaluate<{
|
||||
isOpen: boolean
|
||||
liveCapturedRows: number
|
||||
filteredGameRows: number
|
||||
selectedDetailNonEmpty: boolean
|
||||
fixtureReplays: Record<string, { totalRecords: number; unknownS2cCount: number }>
|
||||
missingArtValid: boolean
|
||||
missingArtCount: number
|
||||
missingArtEntries: unknown[]
|
||||
}>(`(async () => {
|
||||
const app = window.__d2Play;
|
||||
app.inspector.setOpen(true);
|
||||
const liveCapturedRows = app.inspector.getAllRows().length;
|
||||
app.inspector.setFilter({ conn: 'game' });
|
||||
const filteredGameRows = app.inspector.getFilteredRows().length;
|
||||
const firstRow = app.inspector.getFilteredRows()[0] ?? app.inspector.getAllRows()[0];
|
||||
let selectedDetailNonEmpty = false;
|
||||
if (firstRow) {
|
||||
const selected = app.inspector.selectRow(firstRow.seq);
|
||||
selectedDetailNonEmpty = Boolean(selected && selected.hexDump.length > 0);
|
||||
}
|
||||
app.inspector.setFilter({ conn: 'all' });
|
||||
const entries = app.session.packetTap.entries;
|
||||
const liveCapturedRows = entries.length;
|
||||
const filteredGameRows = entries.filter((e) => e.conn === 'game').length;
|
||||
|
||||
const fixtures = ${JSON.stringify(captureFixtures)};
|
||||
const fixtureReplays = {};
|
||||
|
|
@ -955,21 +943,18 @@ async function main(): Promise<void> {
|
|||
}
|
||||
const missingArt = window.__d2MissingArt;
|
||||
return {
|
||||
isOpen: app.inspector.isOpen(),
|
||||
liveCapturedRows,
|
||||
filteredGameRows,
|
||||
selectedDetailNonEmpty,
|
||||
fixtureReplays,
|
||||
missingArtValid: Array.isArray(missingArt) && missingArt.length === 0,
|
||||
missingArtCount: Array.isArray(missingArt) ? missingArt.length : -1,
|
||||
missingArtEntries: Array.isArray(missingArt) ? missingArt : [],
|
||||
};
|
||||
})()`)
|
||||
console.log('[verify-play] Gate 7 (Packet Inspector & 4 .d2cap Fixtures Replay):', gate7)
|
||||
console.log('[verify-play] Gate 7 (PacketTap & 4 .d2cap Fixtures Replay):', gate7)
|
||||
if (
|
||||
!gate7.isOpen ||
|
||||
gate7.liveCapturedRows === 0 ||
|
||||
!gate7.selectedDetailNonEmpty ||
|
||||
gate7.filteredGameRows === 0 ||
|
||||
Object.values(gate7.fixtureReplays).some((r) => r.totalRecords === 0 || r.unknownS2cCount > 0) ||
|
||||
!gate7.missingArtValid
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -293,28 +293,43 @@ async function run(): Promise<void> {
|
|||
assert(playReady, 'play.html __d2PlayApp failed to reach ready=true')
|
||||
await playCdp.evaluate('window.__d2PlayApp.plugins.whenStarted()')
|
||||
|
||||
// 1. Open plugin modal menu via #d2-toolbar-plugins-btn
|
||||
// 1. Open plugin modal menu via #d2-toolbar-plugins-btn & verify all 4 built-in plugins + PacketInspector removal
|
||||
const openStateZh = await playCdp.evaluate<{
|
||||
btnExists: boolean
|
||||
btnDisabled: boolean
|
||||
btnText: string
|
||||
modalOpen: boolean
|
||||
cardExists: boolean
|
||||
cardIds: string[]
|
||||
shortcutExists: boolean
|
||||
automapSelectExists: boolean
|
||||
lightingSelectExists: boolean
|
||||
viewportOptionCount: number
|
||||
hasInspectorBtn: boolean
|
||||
hasInspectorContainer: boolean
|
||||
titleText: string
|
||||
}>(`(() => {
|
||||
const btn = document.getElementById('d2-toolbar-plugins-btn');
|
||||
btn?.click();
|
||||
const overlay = document.querySelector('[data-d2-modal-overlay="plugins"]');
|
||||
const card = document.querySelector('[data-plugin-id="kill-stats"]');
|
||||
const cardIds = Array.from(document.querySelectorAll('[data-plugin-id]')).map(
|
||||
(el) => el.getAttribute('data-plugin-id') ?? '',
|
||||
);
|
||||
const shortcut = document.getElementById('d2-toolbar-plugin-kill-stats-open');
|
||||
const automapSelect = document.getElementById('d2-toolbar-automap-select');
|
||||
const lightingSelect = document.getElementById('d2-toolbar-lighting-select');
|
||||
const viewportSelect = document.getElementById('d2-toolbar-viewport-select');
|
||||
return {
|
||||
btnExists: Boolean(btn),
|
||||
btnDisabled: Boolean(btn?.disabled),
|
||||
btnText: btn?.textContent?.trim() ?? '',
|
||||
modalOpen: Boolean(overlay && !overlay.hasAttribute('hidden')),
|
||||
cardExists: Boolean(card),
|
||||
cardIds,
|
||||
shortcutExists: Boolean(shortcut),
|
||||
automapSelectExists: Boolean(automapSelect),
|
||||
lightingSelectExists: Boolean(lightingSelect),
|
||||
viewportOptionCount: viewportSelect ? viewportSelect.querySelectorAll('option').length : 0,
|
||||
hasInspectorBtn: Boolean(document.getElementById('d2-toolbar-inspector-btn')),
|
||||
hasInspectorContainer: Boolean(document.getElementById('d2-inspector-container')),
|
||||
titleText: overlay?.querySelector('.d2-plugin-menu__title')?.textContent?.trim() ?? '',
|
||||
};
|
||||
})()`)
|
||||
|
|
@ -322,10 +337,79 @@ async function run(): Promise<void> {
|
|||
assert(openStateZh.btnExists, '#d2-toolbar-plugins-btn must exist')
|
||||
assert(!openStateZh.btnDisabled, '#d2-toolbar-plugins-btn must be enabled after PluginHost starts')
|
||||
assert(openStateZh.modalOpen, 'Plugin modal overlay must be open after clicking #d2-toolbar-plugins-btn')
|
||||
assert(openStateZh.cardExists, 'Plugin card [data-plugin-id="kill-stats"] must exist')
|
||||
assert(
|
||||
JSON.stringify(openStateZh.cardIds) ===
|
||||
JSON.stringify(['kill-stats', 'automap-reveal', 'lighting-presets', 'extended-viewports']),
|
||||
`Expected all 4 plugin cards, got ${JSON.stringify(openStateZh.cardIds)}`,
|
||||
)
|
||||
assert(openStateZh.shortcutExists, '#d2-toolbar-plugin-kill-stats-open must exist initially')
|
||||
assert(openStateZh.automapSelectExists, '#d2-toolbar-automap-select must exist initially via automap-reveal plugin')
|
||||
assert(openStateZh.lightingSelectExists, '#d2-toolbar-lighting-select must exist initially via lighting-presets plugin')
|
||||
assert(openStateZh.viewportOptionCount === 5, `Expected 5 viewport options when extended-viewports is enabled, got ${openStateZh.viewportOptionCount}`)
|
||||
assert(!openStateZh.hasInspectorBtn, '#d2-toolbar-inspector-btn must be removed (#717)')
|
||||
assert(!openStateZh.hasInspectorContainer, '#d2-inspector-container must be removed (#717)')
|
||||
assert(openStateZh.titleText === '插件管理', `Expected Chinese title "插件管理", got "${openStateZh.titleText}"`)
|
||||
|
||||
// Verify hot-disabling automap-reveal (#714), lighting-presets (#715), and extended-viewports (#716) enforces 1.13c defaults
|
||||
const fallbackCheck = await playCdp.evaluate<{
|
||||
automapWhileDisabled: string
|
||||
automapSelectWhileDisabled: boolean
|
||||
lightingWhileDisabled: string
|
||||
lightingSelectWhileDisabled: boolean
|
||||
viewportWhileDisabled: string
|
||||
viewportOptionsWhileDisabled: string[]
|
||||
automapRestored: string
|
||||
lightingRestored: string
|
||||
viewportOptionsRestoredCount: number
|
||||
}>(`(async () => {
|
||||
const app = window.__d2PlayApp;
|
||||
await app.plugins.setSetting('automap-reveal', 'mode', 'act');
|
||||
await app.plugins.setSetting('lighting-presets', 'preset', 'fullbright');
|
||||
app.toolbar.setViewport('1280x720');
|
||||
|
||||
await app.plugins.setEnabled('automap-reveal', false);
|
||||
await app.plugins.setEnabled('lighting-presets', false);
|
||||
await app.plugins.setEnabled('extended-viewports', false);
|
||||
|
||||
const automapWhileDisabled = app.settings.get().automapReveal;
|
||||
const automapSelectWhileDisabled = Boolean(document.getElementById('d2-toolbar-automap-select'));
|
||||
const lightingWhileDisabled = app.settings.get().lightingPreset;
|
||||
const lightingSelectWhileDisabled = Boolean(document.getElementById('d2-toolbar-lighting-select'));
|
||||
const viewportWhileDisabled = app.settings.get().viewport;
|
||||
const vpSelect = document.getElementById('d2-toolbar-viewport-select');
|
||||
const viewportOptionsWhileDisabled = vpSelect
|
||||
? Array.from(vpSelect.querySelectorAll('option')).map((o) => o.value)
|
||||
: [];
|
||||
|
||||
await app.plugins.setEnabled('automap-reveal', true);
|
||||
await app.plugins.setEnabled('lighting-presets', true);
|
||||
await app.plugins.setEnabled('extended-viewports', true);
|
||||
await app.plugins.setSetting('automap-reveal', 'mode', 'off');
|
||||
await app.plugins.setSetting('lighting-presets', 'preset', 'auto');
|
||||
|
||||
return {
|
||||
automapWhileDisabled,
|
||||
automapSelectWhileDisabled,
|
||||
lightingWhileDisabled,
|
||||
lightingSelectWhileDisabled,
|
||||
viewportWhileDisabled,
|
||||
viewportOptionsWhileDisabled,
|
||||
automapRestored: app.settings.get().automapReveal,
|
||||
lightingRestored: app.settings.get().lightingPreset,
|
||||
viewportOptionsRestoredCount: vpSelect ? vpSelect.querySelectorAll('option').length : 0,
|
||||
};
|
||||
})()`)
|
||||
assert(fallbackCheck.automapWhileDisabled === 'off', `Expected automapReveal='off' when automap-reveal disabled, got ${fallbackCheck.automapWhileDisabled}`)
|
||||
assert(!fallbackCheck.automapSelectWhileDisabled, 'Expected #d2-toolbar-automap-select removed when automap-reveal disabled')
|
||||
assert(fallbackCheck.lightingWhileDisabled === 'auto', `Expected lightingPreset='auto' when lighting-presets disabled, got ${fallbackCheck.lightingWhileDisabled}`)
|
||||
assert(!fallbackCheck.lightingSelectWhileDisabled, 'Expected #d2-toolbar-lighting-select removed when lighting-presets disabled')
|
||||
assert(fallbackCheck.viewportWhileDisabled === '800x600', `Expected viewport clamped to 800x600 when extended-viewports disabled, got ${fallbackCheck.viewportWhileDisabled}`)
|
||||
assert(
|
||||
JSON.stringify(fallbackCheck.viewportOptionsWhileDisabled) === JSON.stringify(['640x480', '800x600']),
|
||||
`Expected only native 1.13c viewports when extended-viewports disabled, got ${JSON.stringify(fallbackCheck.viewportOptionsWhileDisabled)}`,
|
||||
)
|
||||
assert(fallbackCheck.viewportOptionsRestoredCount === 5, 'Expected 5 viewport options restored when extended-viewports re-enabled')
|
||||
|
||||
const shot1 = join(outDir, '01-plugin-menu-open-zh.png')
|
||||
const shot1Bytes = await playCdp.captureScreenshot(shot1)
|
||||
console.log(`[verify-plugin-menu] Captured ${shot1} (${shot1Bytes} bytes) — Plugin menu open (ZH)`)
|
||||
|
|
@ -491,6 +575,7 @@ async function run(): Promise<void> {
|
|||
console.log(`[verify-plugin-menu] Captured ${shot4} (${shot4Bytes} bytes) — Disabled notice visible on stats page`)
|
||||
|
||||
// Reload play.html while kill-stats is disabled and verify disabled state persists across reload
|
||||
await playCdp.evaluate(`delete window.__d2PlayApp`)
|
||||
await playCdp.send('Page.reload')
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await sleep(150)
|
||||
|
|
@ -663,7 +748,10 @@ async function run(): Promise<void> {
|
|||
|
||||
// 7. Inject corrupt JSON into d2web.plugins.v1, reload play.html, verify ⚠ badge + #d2-plugin-menu-alert + preserved raw value,
|
||||
// then click #d2-plugin-menu-reset and verify ⚠ clears, alert hides, and valid JSON is persisted.
|
||||
await playCdp.evaluate(`localStorage.setItem('d2web.plugins.v1', '{corrupt-json')`)
|
||||
await playCdp.evaluate(`(() => {
|
||||
localStorage.setItem('d2web.plugins.v1', '{corrupt-json');
|
||||
delete window.__d2PlayApp;
|
||||
})()`)
|
||||
await playCdp.send('Page.reload')
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await sleep(150)
|
||||
|
|
|
|||
Loading…
Reference in New Issue