Add per-account/character monster kill statistics page (stats.html)
This commit is contained in:
parent
0897bf6114
commit
8f48c4b1ec
|
|
@ -19,7 +19,7 @@
|
||||||
| :--- | :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
| [`src/common`](./src/common) | `D2Common.dll` `Storm.dll` `D2Lang.dll` | 纯 TypeScript (`ES2022`),零 DOM / 零 Node.js 依赖 | MPQ 与原生二进制格式解析器 (DC6/DCC/DT1/DS1/COF/PL2/TBL)、`D2DataRegistry` 数据表中心、1.13c DRLG 随机地图生成算法、64 位 `D2Rng` 与烘焙校验契约 (`pack-contract`) | [src/common/README.md](./src/common/README.md) |
|
| [`src/common`](./src/common) | `D2Common.dll` `Storm.dll` `D2Lang.dll` | 纯 TypeScript (`ES2022`),零 DOM / 零 Node.js 依赖 | MPQ 与原生二进制格式解析器 (DC6/DCC/DT1/DS1/COF/PL2/TBL)、`D2DataRegistry` 数据表中心、1.13c DRLG 随机地图生成算法、64 位 `D2Rng` 与烘焙校验契约 (`pack-contract`) | [src/common/README.md](./src/common/README.md) |
|
||||||
| [`src/netproto`](./src/netproto) | `D2Net.dll` `Bnclient.dll` `D2MCPClient.dll` `Fog.dll` | 纯协议栈模块,零外部模块依赖,唯一入口 `src/netproto/index.ts` | BNCS (`6112`) / MCP (`6113`) / D2GS (`4000`) 二进制协议编解码、XSHA-1 与 CheckRevision 认证、Fog.dll Huffman 解压、167 个 S2C 操作码解析、物品比特流与 WebSocket/TCP 传输层抽象 | [src/netproto/README.md](./src/netproto/README.md) |
|
| [`src/netproto`](./src/netproto) | `D2Net.dll` `Bnclient.dll` `D2MCPClient.dll` `Fog.dll` | 纯协议栈模块,零外部模块依赖,唯一入口 `src/netproto/index.ts` | BNCS (`6112`) / MCP (`6113`) / D2GS (`4000`) 二进制协议编解码、XSHA-1 与 CheckRevision 认证、Fog.dll Huffman 解压、167 个 S2C 操作码解析、物品比特流与 WebSocket/TCP 传输层抽象 | [src/netproto/README.md](./src/netproto/README.md) |
|
||||||
| [`src/client`](./src/client) | `D2Client.dll` `D2Gfx.dll` `D2Win.dll` `D2Launch.dll` | 纯浏览器客户端 (`DOM` + `WebGL2`),零 Node / 零 `server` / 零 `baker` 依赖 | 唯一页面入口 `play.html`、战网登录/选人/大厅前端界面、`OnlineSession` 与 `ClientWorld` 状态镜像、种子驱动的实时 DRLG 地图重建、移动预测与内插、WebGL2 渲染器、全套经典 HUD 面板与抓包检查器 | [src/client/README.md](./src/client/README.md) |
|
| [`src/client`](./src/client) | `D2Client.dll` `D2Gfx.dll` `D2Win.dll` `D2Launch.dll` | 纯浏览器客户端 (`DOM` + `WebGL2`),零 Node / 零 `server` / 零 `baker` 依赖 | 双页面入口 `play.html`(游戏主入口)与 `stats.html`(独立怪物击杀统计页)、战网登录/选人/大厅前端界面、`OnlineSession` 与 `ClientWorld` 状态镜像、实时怪物击杀追踪与 IndexedDB G-counter 统计 (`stats/`)、种子驱动的实时 DRLG 地图重建、移动预测与内插、WebGL2 渲染器、全套经典 HUD 面板与抓包检查器 | [src/client/README.md](./src/client/README.md) |
|
||||||
| [`src/server`](./src/server) | `D2Game.dll` | 纯确定性模拟 (`ES2022` + `WebWorker`),禁 DOM / 禁 Node / 禁非确定性随机与时钟 | 25fps (40ms/tick) 权威游戏循环、1.13c 战斗伤害与抗性管线、7 职业 221 个技能执行器、投射物与光环引擎、怪物 AI 与房间流式激活、TC 掉落管线与 `.d2s` 存档读写 | [src/server/README.md](./src/server/README.md) |
|
| [`src/server`](./src/server) | `D2Game.dll` | 纯确定性模拟 (`ES2022` + `WebWorker`),禁 DOM / 禁 Node / 禁非确定性随机与时钟 | 25fps (40ms/tick) 权威游戏循环、1.13c 战斗伤害与抗性管线、7 职业 221 个技能执行器、投射物与光环引擎、怪物 AI 与房间流式激活、TC 掉落管线与 `.d2s` 存档读写 | [src/server/README.md](./src/server/README.md) |
|
||||||
| [`src/baker`](./src/baker) | 离线资源构建管线 | 纯 Node.js CLI 环境 (`types: ["node"]`),禁 DOM / 禁 `client` / 禁 `server` | 从原版 1.13c MPQ 归档中提取并烘焙瓦片图集 (`tiles`)、角色/怪物/NPC 实体 (`entities`)、投射物 (`missiles`)、覆盖层 (`overlays`)、HUD/前端 UI、动画表 (`animdata`) 与 SHA-256 资产清单 (`asset-manifest.json`) | [src/baker/README.md](./src/baker/README.md) |
|
| [`src/baker`](./src/baker) | 离线资源构建管线 | 纯 Node.js CLI 环境 (`types: ["node"]`),禁 DOM / 禁 `client` / 禁 `server` | 从原版 1.13c MPQ 归档中提取并烘焙瓦片图集 (`tiles`)、角色/怪物/NPC 实体 (`entities`)、投射物 (`missiles`)、覆盖层 (`overlays`)、HUD/前端 UI、动画表 (`animdata`) 与 SHA-256 资产清单 (`asset-manifest.json`) | [src/baker/README.md](./src/baker/README.md) |
|
||||||
| [`tools/`](./tools) | 开发者与 QA 工具集 | Node.js / Playwright CLI | 无头协议测试机器人 (`d2-bot.ts`)、`.d2cap` 抓包离线重放器 (`netproto-replay.ts`)、`.d2s` 存档全解锁工具 (`d2s-unlock.ts`)、全谱系 136 关卡浏览器审计 (`audit-levels-browser.ts`) 与 DRLG 差分对比工具 | [tools/README.md](./tools/README.md) |
|
| [`tools/`](./tools) | 开发者与 QA 工具集 | Node.js / Playwright CLI | 无头协议测试机器人 (`d2-bot.ts`)、`.d2cap` 抓包离线重放器 (`netproto-replay.ts`)、`.d2s` 存档全解锁工具 (`d2s-unlock.ts`)、全谱系 136 关卡浏览器审计 (`audit-levels-browser.ts`) 与 DRLG 差分对比工具 | [tools/README.md](./tools/README.md) |
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@
|
||||||
|
|
||||||
## 1. 模块职责与架构边界
|
## 1. 模块职责与架构边界
|
||||||
|
|
||||||
- **唯一 HTML 入口**:[`play.html`](../../play.html) $\rightarrow$ [`src/client/main.ts`](./main.ts)。
|
- **双 HTML 页面入口**:
|
||||||
|
- [`play.html`](../../play.html) $\rightarrow$ [`src/client/main.ts`](./main.ts)(在线游戏主客户端)。
|
||||||
|
- [`stats.html`](../../stats.html) $\rightarrow$ [`src/client/stats/stats-main.ts`](./stats/stats-main.ts)(本地 IndexedDB 按账号/角色隔离的怪物击杀统计页,支持跨标签页 BroadcastChannel 实时刷新与 G-counter JSON 导出/导入合并)。
|
||||||
- **TypeScript 工程约束(`tsconfig.client.json`)**:
|
- **TypeScript 工程约束(`tsconfig.client.json`)**:
|
||||||
- `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。
|
- `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。
|
||||||
- **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。
|
- **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。
|
||||||
|
|
@ -28,7 +30,8 @@ src/client/
|
||||||
├── ui/ # 游戏内 HUD 渲染与交互 (HudManager, 底部状态栏, 小地图 Automap, 7 大面板, D2 点阵字体渲染)
|
├── ui/ # 游戏内 HUD 渲染与交互 (HudManager, 底部状态栏, 小地图 Automap, 7 大面板, D2 点阵字体渲染)
|
||||||
├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑)
|
├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑)
|
||||||
├── frontend/ # 战网前置界面 (FrontendRenderer:Gateway 选择、账号登录/注册、8 角色选择、建房/加房大厅)
|
├── frontend/ # 战网前置界面 (FrontendRenderer:Gateway 选择、账号登录/注册、8 角色选择、建房/加房大厅)
|
||||||
├── toolbar/ # 顶部 32px 窄条工具栏 (FPS/Ping、网络抓包开关、设置弹窗、全屏与音量控制)
|
├── toolbar/ # 顶部 32px 窄条工具栏 (FPS/Ping、网络抓包开关、击杀统计入口、设置弹窗、全屏与音量控制)
|
||||||
|
├── stats/ # 按账号/角色隔离的怪物击杀统计 (KillTracker、IndexedDB G-counter 存储与导入导出、stats.html 视图)
|
||||||
├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理 WS Bridge 地址与显示偏好)
|
├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理 WS Bridge 地址与显示偏好)
|
||||||
├── inspector/ # 实时网络封包检查器 (PacketInspector) 与 .d2cap 录制/回放面板
|
├── inspector/ # 实时网络封包检查器 (PacketInspector) 与 .d2cap 录制/回放面板
|
||||||
├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存
|
├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,12 @@ export function detectInitialLang(): GameLang {
|
||||||
if (fromPath !== null) {
|
if (fromPath !== null) {
|
||||||
return fromPath
|
return fromPath
|
||||||
}
|
}
|
||||||
if (path === 'play.html' || path.endsWith('/play.html')) {
|
if (
|
||||||
|
path === 'play.html' ||
|
||||||
|
path.endsWith('/play.html') ||
|
||||||
|
path === 'stats.html' ||
|
||||||
|
path.endsWith('/stats.html')
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage?.getItem('d2web.settings.v1')
|
const raw = window.localStorage?.getItem('d2web.settings.v1')
|
||||||
if (raw) {
|
if (raw) {
|
||||||
|
|
@ -274,6 +279,7 @@ export type ToolbarI18nKey =
|
||||||
| 'toolbar.automap.act'
|
| 'toolbar.automap.act'
|
||||||
| 'toolbar.viewport.label'
|
| 'toolbar.viewport.label'
|
||||||
| 'toolbar.inspector.toggle'
|
| 'toolbar.inspector.toggle'
|
||||||
|
| 'toolbar.killStats.open'
|
||||||
| 'toolbar.cache.clear'
|
| 'toolbar.cache.clear'
|
||||||
| 'toolbar.cache.cleared'
|
| 'toolbar.cache.cleared'
|
||||||
| 'toolbar.diag.quotaExceeded'
|
| 'toolbar.diag.quotaExceeded'
|
||||||
|
|
@ -302,6 +308,7 @@ export const TOOLBAR_I18N: Readonly<Record<ToolbarI18nKey, { readonly zh: string
|
||||||
'toolbar.automap.act': { zh: '全幕', en: 'Act' },
|
'toolbar.automap.act': { zh: '全幕', en: 'Act' },
|
||||||
'toolbar.viewport.label': { zh: '分辨率', en: 'Viewport' },
|
'toolbar.viewport.label': { zh: '分辨率', en: 'Viewport' },
|
||||||
'toolbar.inspector.toggle': { zh: '抓包面板', en: 'Packets' },
|
'toolbar.inspector.toggle': { zh: '抓包面板', en: 'Packets' },
|
||||||
|
'toolbar.killStats.open': { zh: '击杀统计', en: 'Kill Stats' },
|
||||||
'toolbar.cache.clear': { zh: '清除缓存', en: 'Clear Cache' },
|
'toolbar.cache.clear': { zh: '清除缓存', en: 'Clear Cache' },
|
||||||
'toolbar.cache.cleared': { zh: '已清缓存', en: 'Cleared' },
|
'toolbar.cache.cleared': { zh: '已清缓存', en: 'Cleared' },
|
||||||
'toolbar.diag.quotaExceeded': {
|
'toolbar.diag.quotaExceeded': {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import { PacketInspector } from './inspector/packet-inspector.ts'
|
||||||
import { MapService } from './map/map-service.ts'
|
import { MapService } from './map/map-service.ts'
|
||||||
import { OnlineSession, SessionLostError } from './session/online-session.ts'
|
import { OnlineSession, SessionLostError } from './session/online-session.ts'
|
||||||
import { getGlobalSettingsStore, type SettingsStore } from './settings/settings-store.ts'
|
import { getGlobalSettingsStore, type SettingsStore } from './settings/settings-store.ts'
|
||||||
|
import { KillTracker } from './stats/kill-tracker.ts'
|
||||||
import {
|
import {
|
||||||
getAssetCacheStatus,
|
getAssetCacheStatus,
|
||||||
registerAssetCache,
|
registerAssetCache,
|
||||||
|
|
@ -92,6 +93,7 @@ export interface PlayAppController {
|
||||||
readonly settings: SettingsStore
|
readonly settings: SettingsStore
|
||||||
readonly settingsStore: SettingsStore
|
readonly settingsStore: SettingsStore
|
||||||
readonly session: OnlineSession
|
readonly session: OnlineSession
|
||||||
|
readonly killStats: KillTracker
|
||||||
readonly toolbar: Toolbar
|
readonly toolbar: Toolbar
|
||||||
readonly inspector: PacketInspector
|
readonly inspector: PacketInspector
|
||||||
readonly hudManager: HudManager
|
readonly hudManager: HudManager
|
||||||
|
|
@ -382,6 +384,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
sessionRef = session
|
sessionRef = session
|
||||||
|
const killStats = new KillTracker({ session })
|
||||||
|
|
||||||
// Mount PacketInspector inside #d2-inspector-container if available
|
// Mount PacketInspector inside #d2-inspector-container if available
|
||||||
const inspector = session.packetInspector
|
const inspector = session.packetInspector
|
||||||
|
|
@ -1015,6 +1018,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
settings: settingsStore,
|
settings: settingsStore,
|
||||||
settingsStore,
|
settingsStore,
|
||||||
session,
|
session,
|
||||||
|
killStats,
|
||||||
toolbar,
|
toolbar,
|
||||||
inspector,
|
inspector,
|
||||||
hudManager: session.hudManager,
|
hudManager: session.hudManager,
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,7 @@ export class OnlineSession {
|
||||||
|
|
||||||
private phaseValue: OnlineSessionPhase = 'idle'
|
private phaseValue: OnlineSessionPhase = 'idle'
|
||||||
private usernameValue = ''
|
private usernameValue = ''
|
||||||
|
private loginHostValue: string | null = null
|
||||||
private realmsValue: readonly RealmSummary[] = []
|
private realmsValue: readonly RealmSummary[] = []
|
||||||
private selectedRealmValue: RealmSummary | null = null
|
private selectedRealmValue: RealmSummary | null = null
|
||||||
private charactersValue: readonly CharSummary[] = []
|
private charactersValue: readonly CharSummary[] = []
|
||||||
|
|
@ -457,6 +458,10 @@ export class OnlineSession {
|
||||||
return this.phaseValue
|
return this.phaseValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get loginHost(): string | null {
|
||||||
|
return this.loginHostValue
|
||||||
|
}
|
||||||
|
|
||||||
get selectedChar(): string | null {
|
get selectedChar(): string | null {
|
||||||
return this.selectedCharValue
|
return this.selectedCharValue
|
||||||
}
|
}
|
||||||
|
|
@ -583,6 +588,7 @@ export class OnlineSession {
|
||||||
this.lostFlow = null
|
this.lostFlow = null
|
||||||
this.disconnectReasonValue = null
|
this.disconnectReasonValue = null
|
||||||
this.usernameValue = params.username
|
this.usernameValue = params.username
|
||||||
|
this.loginHostValue = (params.host ?? this.defaultHost).trim() || this.defaultHost
|
||||||
this.setPhase('connecting')
|
this.setPhase('connecting')
|
||||||
|
|
||||||
const resolver = params.wsBridgeBaseUrl
|
const resolver = params.wsBridgeBaseUrl
|
||||||
|
|
@ -591,7 +597,7 @@ export class OnlineSession {
|
||||||
|
|
||||||
const flow = this.flowFactory({
|
const flow = this.flowFactory({
|
||||||
resolver,
|
resolver,
|
||||||
bnetHost: params.host ?? this.defaultHost,
|
bnetHost: this.loginHostValue,
|
||||||
bnetPort: params.bncsPort ?? this.defaultBncsPort,
|
bnetPort: params.bncsPort ?? this.defaultBncsPort,
|
||||||
itemTables: this.itemTables,
|
itemTables: this.itemTables,
|
||||||
tap: this.packetTap,
|
tap: this.packetTap,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,841 @@
|
||||||
|
/**
|
||||||
|
* Pure data model, key normalization, G-counter merge, export builder, and strict import
|
||||||
|
* validation for per-account, per-character monster kill statistics.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
isCountableBossClass,
|
||||||
|
isCountableKillClass,
|
||||||
|
} from '../../common/units/monster-kill-classes.ts'
|
||||||
|
import type { CharClassId } from '../../netproto/index.ts'
|
||||||
|
|
||||||
|
export const KILL_STATS_FORMAT = 'd2web-kill-stats' as const
|
||||||
|
export const KILL_STATS_VERSION = 1 as const
|
||||||
|
export const KILL_STATS_DB_NAME = 'd2web-kill-stats' as const
|
||||||
|
export const KILL_STATS_DB_VERSION = 1 as const
|
||||||
|
export const KILL_STATS_CHANNEL_NAME = 'd2web-kill-stats' as const
|
||||||
|
export const SUPERUNIQUE_COUNT = 66 as const
|
||||||
|
|
||||||
|
export type KillDifficulty = 0 | 1 | 2
|
||||||
|
export type KillTallyKind = 'monster' | 'boss' | 'superunique'
|
||||||
|
|
||||||
|
export interface KillCharacterProfile {
|
||||||
|
readonly charKey: string
|
||||||
|
readonly host: string
|
||||||
|
readonly realm: string
|
||||||
|
readonly account: string
|
||||||
|
readonly charName: string
|
||||||
|
readonly charClass: CharClassId
|
||||||
|
readonly level: number
|
||||||
|
readonly hardcore: boolean
|
||||||
|
readonly expansion: boolean
|
||||||
|
readonly ladder: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillCharacterRecord extends KillCharacterProfile {
|
||||||
|
readonly firstSeenAt: number
|
||||||
|
readonly lastSeenAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillTallyDelta {
|
||||||
|
readonly charKey: string
|
||||||
|
readonly kind: KillTallyKind
|
||||||
|
readonly difficulty: KillDifficulty
|
||||||
|
readonly subject: number
|
||||||
|
readonly count: number
|
||||||
|
readonly soloCount: number
|
||||||
|
readonly firstAt: number
|
||||||
|
readonly lastAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillTallyRecord extends KillTallyDelta {
|
||||||
|
readonly id: string
|
||||||
|
readonly replicaId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsSnapshot {
|
||||||
|
readonly replicaId: string
|
||||||
|
readonly characters: readonly KillCharacterRecord[]
|
||||||
|
readonly tallies: readonly KillTallyRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsExportPayload {
|
||||||
|
readonly format: typeof KILL_STATS_FORMAT
|
||||||
|
readonly version: typeof KILL_STATS_VERSION
|
||||||
|
readonly exportedAt: number
|
||||||
|
readonly replicaId: string
|
||||||
|
readonly characters: readonly KillCharacterRecord[]
|
||||||
|
readonly tallies: readonly KillTallyRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsImportSummary {
|
||||||
|
readonly addedCharacters: number
|
||||||
|
readonly updatedCharacters: number
|
||||||
|
readonly addedTallies: number
|
||||||
|
readonly updatedTallies: number
|
||||||
|
readonly unchangedTallies: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsBroadcastMessage {
|
||||||
|
readonly type: 'updated'
|
||||||
|
readonly at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KillStatsImportError extends Error {
|
||||||
|
readonly path: string
|
||||||
|
|
||||||
|
constructor(path: string, message: string) {
|
||||||
|
super(`${path}: ${message}`)
|
||||||
|
this.name = 'KillStatsImportError'
|
||||||
|
this.path = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
export function isValidUuid(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && UUID_REGEX.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a RFC 4122 v4 UUID using `crypto.getRandomValues` so it works in insecure `http://`
|
||||||
|
* contexts where `crypto.randomUUID` is unavailable.
|
||||||
|
*/
|
||||||
|
export function generateReplicaId(
|
||||||
|
getRandomValues?: (array: Uint8Array) => Uint8Array,
|
||||||
|
): string {
|
||||||
|
const bytes = new Uint8Array(16)
|
||||||
|
if (getRandomValues) {
|
||||||
|
getRandomValues(bytes)
|
||||||
|
} else if (
|
||||||
|
typeof globalThis.crypto !== 'undefined' &&
|
||||||
|
typeof globalThis.crypto.getRandomValues === 'function'
|
||||||
|
) {
|
||||||
|
globalThis.crypto.getRandomValues(bytes)
|
||||||
|
} else {
|
||||||
|
throw new Error('crypto.getRandomValues is required to generate replicaId')
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes[6] = (bytes[6]! & 0x0f) | 0x40
|
||||||
|
bytes[8] = (bytes[8]! & 0x3f) | 0x80
|
||||||
|
|
||||||
|
const hex: string[] = []
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
hex.push(bytes[i]!.toString(16).padStart(2, '0'))
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
`${hex[0]}${hex[1]}${hex[2]}${hex[3]}-` +
|
||||||
|
`${hex[4]}${hex[5]}-` +
|
||||||
|
`${hex[6]}${hex[7]}-` +
|
||||||
|
`${hex[8]}${hex[9]}-` +
|
||||||
|
`${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes canonical character key:
|
||||||
|
* `JSON.stringify([host, realm, account, charName].map(s => s.trim().toLowerCase()))`
|
||||||
|
*/
|
||||||
|
export function computeCharKey(
|
||||||
|
host: string,
|
||||||
|
realm: string,
|
||||||
|
account: string,
|
||||||
|
charName: string,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify([host, realm, account, charName].map((s) => s.trim().toLowerCase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes canonical per-replica G-counter tally key:
|
||||||
|
* `JSON.stringify([replicaId, charKey, kind, difficulty, subject])`
|
||||||
|
*/
|
||||||
|
export function computeTallyId(
|
||||||
|
replicaId: string,
|
||||||
|
charKey: string,
|
||||||
|
kind: KillTallyKind,
|
||||||
|
difficulty: KillDifficulty,
|
||||||
|
subject: number,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify([replicaId, charKey, kind, difficulty, subject])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes replica-independent delta aggregation key:
|
||||||
|
* `JSON.stringify([charKey, kind, difficulty, subject])`
|
||||||
|
*/
|
||||||
|
export function computeDeltaKey(
|
||||||
|
charKey: string,
|
||||||
|
kind: KillTallyKind,
|
||||||
|
difficulty: KillDifficulty,
|
||||||
|
subject: number,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify([charKey, kind, difficulty, subject])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* G-counter merge of two tally records with the same `id`:
|
||||||
|
* - `count`: `Math.max(a.count, b.count)`
|
||||||
|
* - `soloCount`: `Math.max(a.soloCount, b.soloCount)`
|
||||||
|
* - `firstAt`: `Math.min(a.firstAt, b.firstAt)`
|
||||||
|
* - `lastAt`: `Math.max(a.lastAt, b.lastAt)`
|
||||||
|
*/
|
||||||
|
export function mergeTallyRecords(a: KillTallyRecord, b: KillTallyRecord): KillTallyRecord {
|
||||||
|
if (a.id !== b.id) {
|
||||||
|
throw new Error(`Cannot merge tally records with different ids: ${a.id} vs ${b.id}`)
|
||||||
|
}
|
||||||
|
const count = Math.max(a.count, b.count)
|
||||||
|
const soloCount = Math.min(count, Math.max(a.soloCount, b.soloCount))
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
replicaId: a.replicaId,
|
||||||
|
charKey: a.charKey,
|
||||||
|
kind: a.kind,
|
||||||
|
difficulty: a.difficulty,
|
||||||
|
subject: a.subject,
|
||||||
|
count,
|
||||||
|
soloCount,
|
||||||
|
firstAt: Math.min(a.firstAt, b.firstAt),
|
||||||
|
lastAt: Math.max(a.lastAt, b.lastAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function characterTieBreakKey(c: KillCharacterRecord): string {
|
||||||
|
return JSON.stringify([
|
||||||
|
c.level,
|
||||||
|
c.charClass,
|
||||||
|
c.hardcore ? 1 : 0,
|
||||||
|
c.expansion ? 1 : 0,
|
||||||
|
c.ladder ? 1 : 0,
|
||||||
|
c.host,
|
||||||
|
c.realm,
|
||||||
|
c.account,
|
||||||
|
c.charName,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-writer-wins (LWW) merge of two character records with the same `charKey` by `lastSeenAt`
|
||||||
|
* (with deterministic tie-breaking on equal `lastSeenAt` and `Math.min` on `firstSeenAt`).
|
||||||
|
*/
|
||||||
|
export function mergeCharacterRecords(
|
||||||
|
a: KillCharacterRecord,
|
||||||
|
b: KillCharacterRecord,
|
||||||
|
): KillCharacterRecord {
|
||||||
|
if (a.charKey !== b.charKey) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot merge character records with different charKeys: ${a.charKey} vs ${b.charKey}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let winner: KillCharacterRecord
|
||||||
|
if (b.lastSeenAt > a.lastSeenAt) {
|
||||||
|
winner = b
|
||||||
|
} else if (a.lastSeenAt > b.lastSeenAt) {
|
||||||
|
winner = a
|
||||||
|
} else {
|
||||||
|
winner = characterTieBreakKey(b) > characterTieBreakKey(a) ? b : a
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
charKey: winner.charKey,
|
||||||
|
host: winner.host,
|
||||||
|
realm: winner.realm,
|
||||||
|
account: winner.account,
|
||||||
|
charName: winner.charName,
|
||||||
|
charClass: winner.charClass,
|
||||||
|
level: winner.level,
|
||||||
|
hardcore: winner.hardcore,
|
||||||
|
expansion: winner.expansion,
|
||||||
|
ladder: winner.ladder,
|
||||||
|
firstSeenAt: Math.min(a.firstSeenAt, b.firstSeenAt),
|
||||||
|
lastSeenAt: Math.max(a.lastSeenAt, b.lastSeenAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertCharacterRecord(
|
||||||
|
existing: KillCharacterRecord | undefined,
|
||||||
|
profile: KillCharacterProfile,
|
||||||
|
at: number,
|
||||||
|
): KillCharacterRecord {
|
||||||
|
const incoming: KillCharacterRecord = {
|
||||||
|
...profile,
|
||||||
|
firstSeenAt: at,
|
||||||
|
lastSeenAt: at,
|
||||||
|
}
|
||||||
|
if (!existing) return incoming
|
||||||
|
if (at >= existing.lastSeenAt) {
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
firstSeenAt: Math.min(existing.firstSeenAt, at),
|
||||||
|
lastSeenAt: Math.max(existing.lastSeenAt, at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergeCharacterRecords(existing, incoming)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDeltaToTallyRecord(
|
||||||
|
existing: KillTallyRecord | undefined,
|
||||||
|
replicaId: string,
|
||||||
|
delta: KillTallyDelta,
|
||||||
|
): KillTallyRecord {
|
||||||
|
const id = computeTallyId(
|
||||||
|
replicaId,
|
||||||
|
delta.charKey,
|
||||||
|
delta.kind,
|
||||||
|
delta.difficulty,
|
||||||
|
delta.subject,
|
||||||
|
)
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
replicaId,
|
||||||
|
charKey: delta.charKey,
|
||||||
|
kind: delta.kind,
|
||||||
|
difficulty: delta.difficulty,
|
||||||
|
subject: delta.subject,
|
||||||
|
count: delta.count,
|
||||||
|
soloCount: delta.soloCount,
|
||||||
|
firstAt: delta.firstAt,
|
||||||
|
lastAt: delta.lastAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const count = existing.count + delta.count
|
||||||
|
const soloCount = Math.min(count, existing.soloCount + delta.soloCount)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
replicaId,
|
||||||
|
charKey: delta.charKey,
|
||||||
|
kind: delta.kind,
|
||||||
|
difficulty: delta.difficulty,
|
||||||
|
subject: delta.subject,
|
||||||
|
count,
|
||||||
|
soloCount,
|
||||||
|
firstAt: Math.min(existing.firstAt, delta.firstAt),
|
||||||
|
lastAt: Math.max(existing.lastAt, delta.lastAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure state transition applying a batch of in-game kill deltas for `character` onto `state`.
|
||||||
|
*/
|
||||||
|
export function applyDeltasToState(
|
||||||
|
state: KillStatsSnapshot,
|
||||||
|
character: KillCharacterProfile,
|
||||||
|
deltas: readonly KillTallyDelta[],
|
||||||
|
at?: number,
|
||||||
|
): KillStatsSnapshot {
|
||||||
|
const charMap = new Map<string, KillCharacterRecord>()
|
||||||
|
for (const c of state.characters) {
|
||||||
|
charMap.set(c.charKey, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
let latestDeltaAt = at ?? 0
|
||||||
|
for (const d of deltas) {
|
||||||
|
if (d.lastAt > latestDeltaAt) latestDeltaAt = d.lastAt
|
||||||
|
}
|
||||||
|
const effectiveAt =
|
||||||
|
at !== undefined ? at : latestDeltaAt > 0 ? latestDeltaAt : 0
|
||||||
|
|
||||||
|
charMap.set(
|
||||||
|
character.charKey,
|
||||||
|
upsertCharacterRecord(charMap.get(character.charKey), character, effectiveAt),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tallyMap = new Map<string, KillTallyRecord>()
|
||||||
|
for (const t of state.tallies) {
|
||||||
|
tallyMap.set(t.id, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const delta of deltas) {
|
||||||
|
if (delta.count <= 0) continue
|
||||||
|
const id = computeTallyId(
|
||||||
|
state.replicaId,
|
||||||
|
delta.charKey,
|
||||||
|
delta.kind,
|
||||||
|
delta.difficulty,
|
||||||
|
delta.subject,
|
||||||
|
)
|
||||||
|
const next = applyDeltaToTallyRecord(tallyMap.get(id), state.replicaId, delta)
|
||||||
|
tallyMap.set(id, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
replicaId: state.replicaId,
|
||||||
|
characters: [...charMap.values()].sort((a, b) =>
|
||||||
|
a.charKey < b.charKey ? -1 : a.charKey > b.charKey ? 1 : 0,
|
||||||
|
),
|
||||||
|
tallies: [...tallyMap.values()].sort((a, b) =>
|
||||||
|
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a deterministically sorted export payload (`characters` by `charKey`, `tallies` by `id`).
|
||||||
|
*/
|
||||||
|
export function buildExportPayload(
|
||||||
|
snapshot: KillStatsSnapshot,
|
||||||
|
exportedAt: number,
|
||||||
|
): KillStatsExportPayload {
|
||||||
|
const characters = [...snapshot.characters].sort((a, b) =>
|
||||||
|
a.charKey < b.charKey ? -1 : a.charKey > b.charKey ? 1 : 0,
|
||||||
|
)
|
||||||
|
const tallies = [...snapshot.tallies].sort((a, b) =>
|
||||||
|
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
format: KILL_STATS_FORMAT,
|
||||||
|
version: KILL_STATS_VERSION,
|
||||||
|
exportedAt,
|
||||||
|
replicaId: snapshot.replicaId,
|
||||||
|
characters,
|
||||||
|
tallies,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecordObject(val: unknown): val is Record<string, unknown> {
|
||||||
|
return typeof val === 'object' && val !== null && !Array.isArray(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidCharClass(val: unknown): val is CharClassId {
|
||||||
|
return (
|
||||||
|
typeof val === 'number' &&
|
||||||
|
Number.isInteger(val) &&
|
||||||
|
val >= 0 &&
|
||||||
|
val <= 6
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidDifficulty(val: unknown): val is KillDifficulty {
|
||||||
|
return val === 0 || val === 1 || val === 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidTallyKind(val: unknown): val is KillTallyKind {
|
||||||
|
return val === 'monster' || val === 'boss' || val === 'superunique'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strictly validates an entire import payload before any state mutation occurs.
|
||||||
|
* Throws `KillStatsImportError` with the exact offending field path on any violation.
|
||||||
|
*/
|
||||||
|
export function validateImportPayload(rawInput: unknown): KillStatsExportPayload {
|
||||||
|
let raw: unknown = rawInput
|
||||||
|
if (typeof rawInput === 'string') {
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(rawInput)
|
||||||
|
} catch {
|
||||||
|
throw new KillStatsImportError('$', 'Invalid JSON syntax')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecordObject(raw)) {
|
||||||
|
throw new KillStatsImportError('$', 'Import payload must be a JSON object')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.format !== KILL_STATS_FORMAT) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
'format',
|
||||||
|
`Expected format "${KILL_STATS_FORMAT}", got ${JSON.stringify(raw.format)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.version !== KILL_STATS_VERSION) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
'version',
|
||||||
|
`Expected version ${KILL_STATS_VERSION}, got ${JSON.stringify(raw.version)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof raw.exportedAt !== 'number' ||
|
||||||
|
!Number.isFinite(raw.exportedAt) ||
|
||||||
|
raw.exportedAt < 0
|
||||||
|
) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
'exportedAt',
|
||||||
|
'exportedAt must be a non-negative finite number',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidUuid(raw.replicaId)) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
'replicaId',
|
||||||
|
'replicaId must be a valid UUID string',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(raw.characters)) {
|
||||||
|
throw new KillStatsImportError('characters', 'characters must be an array')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(raw.tallies)) {
|
||||||
|
throw new KillStatsImportError('tallies', 'tallies must be an array')
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenCharKeys = new Set<string>()
|
||||||
|
const validatedCharacters: KillCharacterRecord[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < raw.characters.length; i++) {
|
||||||
|
const c = raw.characters[i]
|
||||||
|
const basePath = `characters[${i}]`
|
||||||
|
if (!isRecordObject(c)) {
|
||||||
|
throw new KillStatsImportError(basePath, 'Character entry must be an object')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof c.host !== 'string' || c.host.trim().length === 0) {
|
||||||
|
throw new KillStatsImportError(`${basePath}.host`, 'host must be a non-empty string')
|
||||||
|
}
|
||||||
|
if (typeof c.realm !== 'string' || c.realm.trim().length === 0) {
|
||||||
|
throw new KillStatsImportError(`${basePath}.realm`, 'realm must be a non-empty string')
|
||||||
|
}
|
||||||
|
if (typeof c.account !== 'string' || c.account.trim().length === 0) {
|
||||||
|
throw new KillStatsImportError(`${basePath}.account`, 'account must be a non-empty string')
|
||||||
|
}
|
||||||
|
if (typeof c.charName !== 'string' || c.charName.trim().length === 0) {
|
||||||
|
throw new KillStatsImportError(`${basePath}.charName`, 'charName must be a non-empty string')
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedCharKey = computeCharKey(c.host, c.realm, c.account, c.charName)
|
||||||
|
if (typeof c.charKey !== 'string' || c.charKey !== expectedCharKey) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.charKey`,
|
||||||
|
`charKey mismatch: expected ${expectedCharKey}, got ${JSON.stringify(c.charKey)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (seenCharKeys.has(c.charKey)) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.charKey`,
|
||||||
|
`Duplicate charKey ${c.charKey}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
seenCharKeys.add(c.charKey)
|
||||||
|
|
||||||
|
if (!isValidCharClass(c.charClass)) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.charClass`,
|
||||||
|
'charClass must be an integer in 0..6',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof c.level !== 'number' ||
|
||||||
|
!Number.isInteger(c.level) ||
|
||||||
|
c.level < 1 ||
|
||||||
|
c.level > 99
|
||||||
|
) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.level`,
|
||||||
|
'level must be an integer in 1..99',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof c.hardcore !== 'boolean') {
|
||||||
|
throw new KillStatsImportError(`${basePath}.hardcore`, 'hardcore must be a boolean')
|
||||||
|
}
|
||||||
|
if (typeof c.expansion !== 'boolean') {
|
||||||
|
throw new KillStatsImportError(`${basePath}.expansion`, 'expansion must be a boolean')
|
||||||
|
}
|
||||||
|
if (typeof c.ladder !== 'boolean') {
|
||||||
|
throw new KillStatsImportError(`${basePath}.ladder`, 'ladder must be a boolean')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof c.firstSeenAt !== 'number' ||
|
||||||
|
!Number.isFinite(c.firstSeenAt) ||
|
||||||
|
c.firstSeenAt < 0
|
||||||
|
) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.firstSeenAt`,
|
||||||
|
'firstSeenAt must be a non-negative finite number',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof c.lastSeenAt !== 'number' ||
|
||||||
|
!Number.isFinite(c.lastSeenAt) ||
|
||||||
|
c.lastSeenAt < 0
|
||||||
|
) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.lastSeenAt`,
|
||||||
|
'lastSeenAt must be a non-negative finite number',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (c.firstSeenAt > c.lastSeenAt) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.firstSeenAt`,
|
||||||
|
'firstSeenAt cannot be greater than lastSeenAt',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
validatedCharacters.push({
|
||||||
|
charKey: c.charKey,
|
||||||
|
host: c.host.trim(),
|
||||||
|
realm: c.realm.trim(),
|
||||||
|
account: c.account.trim(),
|
||||||
|
charName: c.charName.trim(),
|
||||||
|
charClass: c.charClass,
|
||||||
|
level: c.level,
|
||||||
|
hardcore: c.hardcore,
|
||||||
|
expansion: c.expansion,
|
||||||
|
ladder: c.ladder,
|
||||||
|
firstSeenAt: c.firstSeenAt,
|
||||||
|
lastSeenAt: c.lastSeenAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenTallyIds = new Set<string>()
|
||||||
|
const validatedTallies: KillTallyRecord[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < raw.tallies.length; i++) {
|
||||||
|
const t = raw.tallies[i]
|
||||||
|
const basePath = `tallies[${i}]`
|
||||||
|
if (!isRecordObject(t)) {
|
||||||
|
throw new KillStatsImportError(basePath, 'Tally entry must be an object')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidUuid(t.replicaId)) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.replicaId`,
|
||||||
|
'replicaId must be a valid UUID string',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof t.charKey !== 'string' || !seenCharKeys.has(t.charKey)) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.charKey`,
|
||||||
|
`charKey ${JSON.stringify(t.charKey)} does not reference a character in characters[]`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidTallyKind(t.kind)) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.kind`,
|
||||||
|
'kind must be "monster", "boss", or "superunique"',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidDifficulty(t.difficulty)) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.difficulty`,
|
||||||
|
'difficulty must be 0, 1, or 2',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof t.subject !== 'number' || !Number.isInteger(t.subject)) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.subject`,
|
||||||
|
'subject must be an integer',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.kind === 'monster') {
|
||||||
|
if (!isCountableKillClass(t.subject)) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.subject`,
|
||||||
|
`hcIdx ${t.subject} is not a countable monster class`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (t.kind === 'boss') {
|
||||||
|
if (!isCountableBossClass(t.subject)) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.subject`,
|
||||||
|
`hcIdx ${t.subject} is not a countable boss class`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (t.kind === 'superunique') {
|
||||||
|
if (t.subject < 0 || t.subject >= SUPERUNIQUE_COUNT) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.subject`,
|
||||||
|
`superUniqueId ${t.subject} is out of range 0..65`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedId = computeTallyId(
|
||||||
|
t.replicaId,
|
||||||
|
t.charKey,
|
||||||
|
t.kind,
|
||||||
|
t.difficulty,
|
||||||
|
t.subject,
|
||||||
|
)
|
||||||
|
if (typeof t.id !== 'string' || t.id !== expectedId) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.id`,
|
||||||
|
`id mismatch: expected ${expectedId}, got ${JSON.stringify(t.id)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (seenTallyIds.has(t.id)) {
|
||||||
|
throw new KillStatsImportError(`${basePath}.id`, `Duplicate tally id ${t.id}`)
|
||||||
|
}
|
||||||
|
seenTallyIds.add(t.id)
|
||||||
|
|
||||||
|
if (typeof t.count !== 'number' || !Number.isInteger(t.count) || t.count < 1) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.count`,
|
||||||
|
'count must be an integer >= 1',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof t.soloCount !== 'number' ||
|
||||||
|
!Number.isInteger(t.soloCount) ||
|
||||||
|
t.soloCount < 0 ||
|
||||||
|
t.soloCount > t.count
|
||||||
|
) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.soloCount`,
|
||||||
|
'soloCount must be an integer in 0..count',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof t.firstAt !== 'number' ||
|
||||||
|
!Number.isFinite(t.firstAt) ||
|
||||||
|
t.firstAt < 0
|
||||||
|
) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.firstAt`,
|
||||||
|
'firstAt must be a non-negative finite number',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof t.lastAt !== 'number' ||
|
||||||
|
!Number.isFinite(t.lastAt) ||
|
||||||
|
t.lastAt < 0
|
||||||
|
) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.lastAt`,
|
||||||
|
'lastAt must be a non-negative finite number',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (t.firstAt > t.lastAt) {
|
||||||
|
throw new KillStatsImportError(
|
||||||
|
`${basePath}.firstAt`,
|
||||||
|
'firstAt cannot be greater than lastAt',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
validatedTallies.push({
|
||||||
|
id: t.id,
|
||||||
|
replicaId: t.replicaId,
|
||||||
|
charKey: t.charKey,
|
||||||
|
kind: t.kind,
|
||||||
|
difficulty: t.difficulty,
|
||||||
|
subject: t.subject,
|
||||||
|
count: t.count,
|
||||||
|
soloCount: t.soloCount,
|
||||||
|
firstAt: t.firstAt,
|
||||||
|
lastAt: t.lastAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
format: KILL_STATS_FORMAT,
|
||||||
|
version: KILL_STATS_VERSION,
|
||||||
|
exportedAt: raw.exportedAt,
|
||||||
|
replicaId: raw.replicaId,
|
||||||
|
characters: validatedCharacters,
|
||||||
|
tallies: validatedTallies,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function areCharacterRecordsEqual(a: KillCharacterRecord, b: KillCharacterRecord): boolean {
|
||||||
|
return (
|
||||||
|
a.charKey === b.charKey &&
|
||||||
|
a.host === b.host &&
|
||||||
|
a.realm === b.realm &&
|
||||||
|
a.account === b.account &&
|
||||||
|
a.charName === b.charName &&
|
||||||
|
a.charClass === b.charClass &&
|
||||||
|
a.level === b.level &&
|
||||||
|
a.hardcore === b.hardcore &&
|
||||||
|
a.expansion === b.expansion &&
|
||||||
|
a.ladder === b.ladder &&
|
||||||
|
a.firstSeenAt === b.firstSeenAt &&
|
||||||
|
a.lastSeenAt === b.lastSeenAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function areTallyRecordsEqual(a: KillTallyRecord, b: KillTallyRecord): boolean {
|
||||||
|
return (
|
||||||
|
a.id === b.id &&
|
||||||
|
a.count === b.count &&
|
||||||
|
a.soloCount === b.soloCount &&
|
||||||
|
a.firstAt === b.firstAt &&
|
||||||
|
a.lastAt === b.lastAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges a validated `KillStatsExportPayload` into `current` state using G-counter and LWW rules.
|
||||||
|
*/
|
||||||
|
export function mergeImportPayload(
|
||||||
|
current: KillStatsSnapshot,
|
||||||
|
payload: KillStatsExportPayload,
|
||||||
|
): {
|
||||||
|
readonly next: KillStatsSnapshot
|
||||||
|
readonly summary: KillStatsImportSummary
|
||||||
|
} {
|
||||||
|
const charMap = new Map<string, KillCharacterRecord>()
|
||||||
|
for (const c of current.characters) {
|
||||||
|
charMap.set(c.charKey, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
let addedCharacters = 0
|
||||||
|
let updatedCharacters = 0
|
||||||
|
for (const incoming of payload.characters) {
|
||||||
|
const existing = charMap.get(incoming.charKey)
|
||||||
|
if (!existing) {
|
||||||
|
charMap.set(incoming.charKey, incoming)
|
||||||
|
addedCharacters++
|
||||||
|
} else {
|
||||||
|
const merged = mergeCharacterRecords(existing, incoming)
|
||||||
|
charMap.set(incoming.charKey, merged)
|
||||||
|
if (!areCharacterRecordsEqual(existing, merged)) {
|
||||||
|
updatedCharacters++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tallyMap = new Map<string, KillTallyRecord>()
|
||||||
|
for (const t of current.tallies) {
|
||||||
|
tallyMap.set(t.id, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
let addedTallies = 0
|
||||||
|
let updatedTallies = 0
|
||||||
|
let unchangedTallies = 0
|
||||||
|
for (const incoming of payload.tallies) {
|
||||||
|
const existing = tallyMap.get(incoming.id)
|
||||||
|
if (!existing) {
|
||||||
|
tallyMap.set(incoming.id, incoming)
|
||||||
|
addedTallies++
|
||||||
|
} else {
|
||||||
|
const merged = mergeTallyRecords(existing, incoming)
|
||||||
|
tallyMap.set(incoming.id, merged)
|
||||||
|
if (areTallyRecordsEqual(existing, merged)) {
|
||||||
|
unchangedTallies++
|
||||||
|
} else {
|
||||||
|
updatedTallies++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: KillStatsSnapshot = {
|
||||||
|
replicaId: current.replicaId,
|
||||||
|
characters: [...charMap.values()].sort((a, b) =>
|
||||||
|
a.charKey < b.charKey ? -1 : a.charKey > b.charKey ? 1 : 0,
|
||||||
|
),
|
||||||
|
tallies: [...tallyMap.values()].sort((a, b) =>
|
||||||
|
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
next,
|
||||||
|
summary: {
|
||||||
|
addedCharacters,
|
||||||
|
updatedCharacters,
|
||||||
|
addedTallies,
|
||||||
|
updatedTallies,
|
||||||
|
unchangedTallies,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,480 @@
|
||||||
|
/**
|
||||||
|
* IndexedDB persistence repository (`d2web-kill-stats` v1) for monster kill statistics.
|
||||||
|
*
|
||||||
|
* Object stores:
|
||||||
|
* - `meta` (`keyPath: 'key'`) — stores `{ key: 'replicaId', value: string }`
|
||||||
|
* - `characters` (`keyPath: 'charKey'`) — stores `KillCharacterRecord`
|
||||||
|
* - `tallies` (`keyPath: 'id'`, index `'charKey'`) — stores `KillTallyRecord`
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
KILL_STATS_DB_NAME,
|
||||||
|
KILL_STATS_DB_VERSION,
|
||||||
|
applyDeltaToTallyRecord,
|
||||||
|
buildExportPayload,
|
||||||
|
computeTallyId,
|
||||||
|
generateReplicaId,
|
||||||
|
isValidUuid,
|
||||||
|
mergeImportPayload,
|
||||||
|
upsertCharacterRecord,
|
||||||
|
validateImportPayload,
|
||||||
|
type KillCharacterProfile,
|
||||||
|
type KillCharacterRecord,
|
||||||
|
type KillStatsExportPayload,
|
||||||
|
type KillStatsImportSummary,
|
||||||
|
type KillStatsSnapshot,
|
||||||
|
type KillTallyDelta,
|
||||||
|
type KillTallyRecord,
|
||||||
|
} from './kill-stats-model.ts'
|
||||||
|
|
||||||
|
export interface KillStatsBatchInput {
|
||||||
|
readonly character: KillCharacterProfile
|
||||||
|
readonly deltas: readonly KillTallyDelta[]
|
||||||
|
readonly at?: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsRepository {
|
||||||
|
loadAll(): Promise<KillStatsSnapshot>
|
||||||
|
applyBatch(batch: KillStatsBatchInput): Promise<void>
|
||||||
|
applyBatch(
|
||||||
|
character: KillCharacterProfile,
|
||||||
|
deltas: readonly KillTallyDelta[],
|
||||||
|
at?: number,
|
||||||
|
): Promise<void>
|
||||||
|
applyBatch(
|
||||||
|
replicaId: string,
|
||||||
|
character: KillCharacterProfile,
|
||||||
|
deltas: readonly KillTallyDelta[],
|
||||||
|
at?: number,
|
||||||
|
): Promise<void>
|
||||||
|
exportPayload(exportedAt?: number): Promise<KillStatsExportPayload>
|
||||||
|
importPayload(raw: unknown): Promise<KillStatsImportSummary>
|
||||||
|
deleteCharacter(charKey: string): Promise<void>
|
||||||
|
clearAll(): Promise<void>
|
||||||
|
close?(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeApplyBatchArgs(
|
||||||
|
arg0: KillStatsBatchInput | KillCharacterProfile | string,
|
||||||
|
arg1?: readonly KillTallyDelta[] | KillCharacterProfile,
|
||||||
|
arg2?: number | readonly KillTallyDelta[],
|
||||||
|
arg3?: number,
|
||||||
|
): {
|
||||||
|
readonly explicitReplicaId?: string | undefined
|
||||||
|
readonly character: KillCharacterProfile
|
||||||
|
readonly deltas: readonly KillTallyDelta[]
|
||||||
|
readonly at?: number | undefined
|
||||||
|
} {
|
||||||
|
if (typeof arg0 === 'string') {
|
||||||
|
return {
|
||||||
|
explicitReplicaId: arg0,
|
||||||
|
character: arg1 as KillCharacterProfile,
|
||||||
|
deltas: (arg2 as readonly KillTallyDelta[]) ?? [],
|
||||||
|
at: arg3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('character' in arg0 && 'deltas' in arg0) {
|
||||||
|
return {
|
||||||
|
character: arg0.character,
|
||||||
|
deltas: arg0.deltas,
|
||||||
|
at: arg0.at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
character: arg0,
|
||||||
|
deltas: (arg1 as readonly KillTallyDelta[]) ?? [],
|
||||||
|
at: typeof arg2 === 'number' ? arg2 : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexedDbKillStatsRepositoryOptions {
|
||||||
|
readonly dbName?: string | undefined
|
||||||
|
readonly indexedDB?: IDBFactory | null | undefined
|
||||||
|
readonly now?: (() => number) | undefined
|
||||||
|
readonly generateId?: (() => string) | undefined
|
||||||
|
readonly onBlocked?: (() => void) | undefined
|
||||||
|
readonly onVersionChange?: (() => void) | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetaRecord {
|
||||||
|
readonly key: string
|
||||||
|
readonly value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IndexedDbKillStatsRepository implements KillStatsRepository {
|
||||||
|
private readonly dbName: string
|
||||||
|
private readonly idbFactory: IDBFactory | null
|
||||||
|
private readonly now: () => number
|
||||||
|
private readonly generateId: () => string
|
||||||
|
private readonly onBlocked?: (() => void) | undefined
|
||||||
|
private readonly onVersionChange?: (() => void) | undefined
|
||||||
|
private dbPromise: Promise<IDBDatabase> | null = null
|
||||||
|
private activeDb: IDBDatabase | null = null
|
||||||
|
|
||||||
|
constructor(options: IndexedDbKillStatsRepositoryOptions = {}) {
|
||||||
|
this.dbName = options.dbName ?? KILL_STATS_DB_NAME
|
||||||
|
this.idbFactory =
|
||||||
|
options.indexedDB !== undefined
|
||||||
|
? options.indexedDB
|
||||||
|
: typeof globalThis.indexedDB !== 'undefined'
|
||||||
|
? globalThis.indexedDB
|
||||||
|
: null
|
||||||
|
this.now = options.now ?? (() => Date.now())
|
||||||
|
this.generateId = options.generateId ?? (() => generateReplicaId())
|
||||||
|
this.onBlocked = options.onBlocked
|
||||||
|
this.onVersionChange = options.onVersionChange
|
||||||
|
}
|
||||||
|
|
||||||
|
private openDb(): Promise<IDBDatabase> {
|
||||||
|
if (this.dbPromise) return this.dbPromise
|
||||||
|
if (!this.idbFactory) {
|
||||||
|
return Promise.reject(new Error('IndexedDB is not available in this browser environment'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const factory = this.idbFactory
|
||||||
|
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
|
let req: IDBOpenDBRequest
|
||||||
|
try {
|
||||||
|
req = factory.open(this.dbName, KILL_STATS_DB_VERSION)
|
||||||
|
} catch (err) {
|
||||||
|
this.dbPromise = null
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result
|
||||||
|
if (!db.objectStoreNames.contains('meta')) {
|
||||||
|
db.createObjectStore('meta', { keyPath: 'key' })
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('characters')) {
|
||||||
|
db.createObjectStore('characters', { keyPath: 'charKey' })
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('tallies')) {
|
||||||
|
const tallies = db.createObjectStore('tallies', { keyPath: 'id' })
|
||||||
|
tallies.createIndex('charKey', 'charKey', { unique: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.onblocked = () => {
|
||||||
|
this.onBlocked?.()
|
||||||
|
this.dbPromise = null
|
||||||
|
reject(new Error(`IndexedDB "${this.dbName}" open request is blocked by another tab`))
|
||||||
|
}
|
||||||
|
|
||||||
|
req.onerror = () => {
|
||||||
|
this.dbPromise = null
|
||||||
|
reject(req.error ?? new Error(`Failed to open IndexedDB "${this.dbName}"`))
|
||||||
|
}
|
||||||
|
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const db = req.result
|
||||||
|
this.activeDb = db
|
||||||
|
db.onversionchange = () => {
|
||||||
|
db.close()
|
||||||
|
if (this.activeDb === db) {
|
||||||
|
this.activeDb = null
|
||||||
|
}
|
||||||
|
this.dbPromise = null
|
||||||
|
this.onVersionChange?.()
|
||||||
|
}
|
||||||
|
db.onclose = () => {
|
||||||
|
if (this.activeDb === db) {
|
||||||
|
this.activeDb = null
|
||||||
|
}
|
||||||
|
this.dbPromise = null
|
||||||
|
}
|
||||||
|
resolve(db)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.dbPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
if (this.activeDb) {
|
||||||
|
this.activeDb.close()
|
||||||
|
this.activeDb = null
|
||||||
|
}
|
||||||
|
this.dbPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAll(): Promise<KillStatsSnapshot> {
|
||||||
|
const db = await this.openDb()
|
||||||
|
return await new Promise<KillStatsSnapshot>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(['meta', 'characters', 'tallies'], 'readwrite')
|
||||||
|
const metaStore = tx.objectStore('meta')
|
||||||
|
const charStore = tx.objectStore('characters')
|
||||||
|
const talliesStore = tx.objectStore('tallies')
|
||||||
|
|
||||||
|
let replicaId = ''
|
||||||
|
let characters: KillCharacterRecord[] = []
|
||||||
|
let tallies: KillTallyRecord[] = []
|
||||||
|
|
||||||
|
const metaReq = metaStore.get('replicaId')
|
||||||
|
metaReq.onsuccess = () => {
|
||||||
|
const record = metaReq.result as MetaRecord | undefined
|
||||||
|
if (record && isValidUuid(record.value)) {
|
||||||
|
replicaId = record.value
|
||||||
|
} else {
|
||||||
|
replicaId = this.generateId()
|
||||||
|
metaStore.put({ key: 'replicaId', value: replicaId } satisfies MetaRecord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const charsReq = charStore.getAll()
|
||||||
|
charsReq.onsuccess = () => {
|
||||||
|
characters = (charsReq.result as KillCharacterRecord[]) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
const talliesReq = talliesStore.getAll()
|
||||||
|
talliesReq.onsuccess = () => {
|
||||||
|
tallies = (talliesReq.result as KillTallyRecord[]) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
resolve({
|
||||||
|
replicaId,
|
||||||
|
characters: [...characters].sort((a, b) =>
|
||||||
|
a.charKey < b.charKey ? -1 : a.charKey > b.charKey ? 1 : 0,
|
||||||
|
),
|
||||||
|
tallies: [...tallies].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
tx.onerror = () => {
|
||||||
|
reject(tx.error ?? new Error('IndexedDB loadAll transaction failed'))
|
||||||
|
}
|
||||||
|
tx.onabort = () => {
|
||||||
|
reject(tx.error ?? new Error('IndexedDB loadAll transaction aborted'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyBatch(batch: KillStatsBatchInput): Promise<void>
|
||||||
|
async applyBatch(
|
||||||
|
character: KillCharacterProfile,
|
||||||
|
deltas: readonly KillTallyDelta[],
|
||||||
|
at?: number,
|
||||||
|
): Promise<void>
|
||||||
|
async applyBatch(
|
||||||
|
replicaId: string,
|
||||||
|
character: KillCharacterProfile,
|
||||||
|
deltas: readonly KillTallyDelta[],
|
||||||
|
at?: number,
|
||||||
|
): Promise<void>
|
||||||
|
async applyBatch(
|
||||||
|
arg0: KillStatsBatchInput | KillCharacterProfile | string,
|
||||||
|
arg1?: readonly KillTallyDelta[] | KillCharacterProfile,
|
||||||
|
arg2?: number | readonly KillTallyDelta[],
|
||||||
|
arg3?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const { explicitReplicaId, character, deltas, at } = normalizeApplyBatchArgs(
|
||||||
|
arg0,
|
||||||
|
arg1,
|
||||||
|
arg2,
|
||||||
|
arg3,
|
||||||
|
)
|
||||||
|
const db = await this.openDb()
|
||||||
|
const effectiveAt = at ?? this.now()
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(['meta', 'characters', 'tallies'], 'readwrite')
|
||||||
|
const metaStore = tx.objectStore('meta')
|
||||||
|
const charStore = tx.objectStore('characters')
|
||||||
|
const talliesStore = tx.objectStore('tallies')
|
||||||
|
|
||||||
|
const metaReq = metaStore.get('replicaId')
|
||||||
|
metaReq.onsuccess = () => {
|
||||||
|
const record = metaReq.result as MetaRecord | undefined
|
||||||
|
let replicaId: string
|
||||||
|
if (record && isValidUuid(record.value)) {
|
||||||
|
replicaId = record.value
|
||||||
|
} else {
|
||||||
|
replicaId =
|
||||||
|
explicitReplicaId && isValidUuid(explicitReplicaId)
|
||||||
|
? explicitReplicaId
|
||||||
|
: this.generateId()
|
||||||
|
metaStore.put({ key: 'replicaId', value: replicaId } satisfies MetaRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
const charReq = charStore.get(character.charKey)
|
||||||
|
charReq.onsuccess = () => {
|
||||||
|
const existingChar = charReq.result as KillCharacterRecord | undefined
|
||||||
|
let maxDeltaAt = effectiveAt
|
||||||
|
for (const d of deltas) {
|
||||||
|
if (d.lastAt > maxDeltaAt) maxDeltaAt = d.lastAt
|
||||||
|
}
|
||||||
|
const nextChar = upsertCharacterRecord(existingChar, character, maxDeltaAt)
|
||||||
|
charStore.put(nextChar)
|
||||||
|
|
||||||
|
for (const delta of deltas) {
|
||||||
|
if (delta.count <= 0) continue
|
||||||
|
const id = computeTallyId(
|
||||||
|
replicaId,
|
||||||
|
delta.charKey,
|
||||||
|
delta.kind,
|
||||||
|
delta.difficulty,
|
||||||
|
delta.subject,
|
||||||
|
)
|
||||||
|
const tallyReq = talliesStore.get(id)
|
||||||
|
tallyReq.onsuccess = () => {
|
||||||
|
const existingTally = tallyReq.result as KillTallyRecord | undefined
|
||||||
|
const nextTally = applyDeltaToTallyRecord(existingTally, replicaId, delta)
|
||||||
|
talliesStore.put(nextTally)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB applyBatch transaction failed'))
|
||||||
|
tx.onabort = () => reject(tx.error ?? new Error('IndexedDB applyBatch transaction aborted'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportPayload(exportedAt?: number): Promise<KillStatsExportPayload> {
|
||||||
|
const snapshot = await this.loadAll()
|
||||||
|
return buildExportPayload(snapshot, exportedAt ?? this.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
async importPayload(raw: unknown): Promise<KillStatsImportSummary> {
|
||||||
|
// Strictly validate the entire payload before opening any write transaction
|
||||||
|
const validated = validateImportPayload(raw)
|
||||||
|
const db = await this.openDb()
|
||||||
|
|
||||||
|
return await new Promise<KillStatsImportSummary>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(['meta', 'characters', 'tallies'], 'readwrite')
|
||||||
|
const metaStore = tx.objectStore('meta')
|
||||||
|
const charStore = tx.objectStore('characters')
|
||||||
|
const talliesStore = tx.objectStore('tallies')
|
||||||
|
|
||||||
|
let replicaId = ''
|
||||||
|
let existingChars: KillCharacterRecord[] = []
|
||||||
|
let existingTallies: KillTallyRecord[] = []
|
||||||
|
let pendingReads = 3
|
||||||
|
let summaryResult: KillStatsImportSummary = {
|
||||||
|
addedCharacters: 0,
|
||||||
|
updatedCharacters: 0,
|
||||||
|
addedTallies: 0,
|
||||||
|
updatedTallies: 0,
|
||||||
|
unchangedTallies: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const onReadDone = (): void => {
|
||||||
|
pendingReads--
|
||||||
|
if (pendingReads > 0) return
|
||||||
|
|
||||||
|
const current: KillStatsSnapshot = {
|
||||||
|
replicaId,
|
||||||
|
characters: existingChars,
|
||||||
|
tallies: existingTallies,
|
||||||
|
}
|
||||||
|
const { next, summary } = mergeImportPayload(current, validated)
|
||||||
|
summaryResult = summary
|
||||||
|
|
||||||
|
for (const c of next.characters) {
|
||||||
|
charStore.put(c)
|
||||||
|
}
|
||||||
|
for (const t of next.tallies) {
|
||||||
|
talliesStore.put(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaReq = metaStore.get('replicaId')
|
||||||
|
metaReq.onsuccess = () => {
|
||||||
|
const record = metaReq.result as MetaRecord | undefined
|
||||||
|
if (record && isValidUuid(record.value)) {
|
||||||
|
replicaId = record.value
|
||||||
|
} else {
|
||||||
|
replicaId = this.generateId()
|
||||||
|
metaStore.put({ key: 'replicaId', value: replicaId } satisfies MetaRecord)
|
||||||
|
}
|
||||||
|
onReadDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
const charsReq = charStore.getAll()
|
||||||
|
charsReq.onsuccess = () => {
|
||||||
|
existingChars = (charsReq.result as KillCharacterRecord[]) ?? []
|
||||||
|
onReadDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
const talliesReq = talliesStore.getAll()
|
||||||
|
talliesReq.onsuccess = () => {
|
||||||
|
existingTallies = (talliesReq.result as KillTallyRecord[]) ?? []
|
||||||
|
onReadDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.oncomplete = () => resolve(summaryResult)
|
||||||
|
tx.onerror = () =>
|
||||||
|
reject(tx.error ?? new Error('IndexedDB importPayload transaction failed'))
|
||||||
|
tx.onabort = () =>
|
||||||
|
reject(tx.error ?? new Error('IndexedDB importPayload transaction aborted'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private rotateReplicaId(prevReplicaId?: string): string {
|
||||||
|
let next = this.generateId()
|
||||||
|
if (prevReplicaId && next === prevReplicaId) {
|
||||||
|
next = generateReplicaId()
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCharacter(charKey: string): Promise<void> {
|
||||||
|
const db = await this.openDb()
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(['meta', 'characters', 'tallies'], 'readwrite')
|
||||||
|
const metaStore = tx.objectStore('meta')
|
||||||
|
const charStore = tx.objectStore('characters')
|
||||||
|
const talliesStore = tx.objectStore('tallies')
|
||||||
|
|
||||||
|
charStore.delete(charKey)
|
||||||
|
|
||||||
|
const index = talliesStore.index('charKey')
|
||||||
|
const keysReq = index.getAllKeys(IDBKeyRange.only(charKey))
|
||||||
|
keysReq.onsuccess = () => {
|
||||||
|
const keys = keysReq.result ?? []
|
||||||
|
for (const key of keys) {
|
||||||
|
talliesStore.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaReq = metaStore.get('replicaId')
|
||||||
|
metaReq.onsuccess = () => {
|
||||||
|
const prev = (metaReq.result as MetaRecord | undefined)?.value
|
||||||
|
const nextReplicaId = this.rotateReplicaId(prev)
|
||||||
|
metaStore.put({ key: 'replicaId', value: nextReplicaId } satisfies MetaRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () =>
|
||||||
|
reject(tx.error ?? new Error('IndexedDB deleteCharacter transaction failed'))
|
||||||
|
tx.onabort = () =>
|
||||||
|
reject(tx.error ?? new Error('IndexedDB deleteCharacter transaction aborted'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAll(): Promise<void> {
|
||||||
|
const db = await this.openDb()
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(['meta', 'characters', 'tallies'], 'readwrite')
|
||||||
|
const metaStore = tx.objectStore('meta')
|
||||||
|
const charStore = tx.objectStore('characters')
|
||||||
|
const talliesStore = tx.objectStore('tallies')
|
||||||
|
|
||||||
|
charStore.clear()
|
||||||
|
talliesStore.clear()
|
||||||
|
|
||||||
|
const metaReq = metaStore.get('replicaId')
|
||||||
|
metaReq.onsuccess = () => {
|
||||||
|
const prev = (metaReq.result as MetaRecord | undefined)?.value
|
||||||
|
const nextReplicaId = this.rotateReplicaId(prev)
|
||||||
|
metaStore.put({ key: 'replicaId', value: nextReplicaId } satisfies MetaRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB clearAll transaction failed'))
|
||||||
|
tx.onabort = () => reject(tx.error ?? new Error('IndexedDB clearAll transaction aborted'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,667 @@
|
||||||
|
/**
|
||||||
|
* Pure view model for `stats.html`:
|
||||||
|
* - Scope filtering (`all` | `account` | `character`)
|
||||||
|
* - Sidebar account -> character hierarchy with localized class names and badges
|
||||||
|
* - Solo-only filter (`count` vs `soloCount`)
|
||||||
|
* - Difficulty summary cards (`normal`, `nightmare`, `hell`, `total` from `kind === 'monster'` only)
|
||||||
|
* - 86-row Boss & SuperUnique Kill Board (`24` killable `MonStats` `boss=1` rows + `66` `SuperUniques.txt`
|
||||||
|
* rows minus `4` overlapping rows `229:radament`->`SU10`, `250:summoner`->`SU18`,
|
||||||
|
* `365:griswold`->`SU5`, `526:nihlathakboss`->`SU60`, with duplicate-name canonical ID subtitles)
|
||||||
|
* - Monster Type × Difficulty table with column sorting and bilingual name/ID search
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts'
|
||||||
|
import {
|
||||||
|
MONSTATS_MAX_HCIDX,
|
||||||
|
isCountableBossClass,
|
||||||
|
isCountableKillClass,
|
||||||
|
} from '../../common/units/monster-kill-classes.ts'
|
||||||
|
import {
|
||||||
|
getMonsterKindByClassId,
|
||||||
|
getSuperUniqueByIndex,
|
||||||
|
} from '../../common/units/monsters-data.ts'
|
||||||
|
import { CHAR_CLASS_NAMES, type CharClassId } from '../../netproto/index.ts'
|
||||||
|
import type { GameLang } from '../i18n/lang.ts'
|
||||||
|
import {
|
||||||
|
SUPERUNIQUE_COUNT,
|
||||||
|
type KillCharacterRecord,
|
||||||
|
type KillStatsSnapshot,
|
||||||
|
type KillTallyRecord,
|
||||||
|
} from './kill-stats-model.ts'
|
||||||
|
|
||||||
|
export const BOSS_SU_BOARD_ROW_COUNT = 86 as const
|
||||||
|
|
||||||
|
export type KillStatsScope =
|
||||||
|
| { readonly kind: 'all' }
|
||||||
|
| {
|
||||||
|
readonly kind: 'account'
|
||||||
|
readonly host: string
|
||||||
|
readonly realm: string
|
||||||
|
readonly account: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: 'character'
|
||||||
|
readonly charKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonsterSortColumn =
|
||||||
|
| 'hcIdx'
|
||||||
|
| 'id'
|
||||||
|
| 'name'
|
||||||
|
| 'normal'
|
||||||
|
| 'nightmare'
|
||||||
|
| 'hell'
|
||||||
|
| 'total'
|
||||||
|
|
||||||
|
export type SortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
|
export interface MonsterTableSort {
|
||||||
|
readonly column: MonsterSortColumn
|
||||||
|
readonly direction: SortDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsViewOptions {
|
||||||
|
readonly scope?: KillStatsScope | undefined
|
||||||
|
readonly soloOnly?: boolean | undefined
|
||||||
|
readonly showUnkilled?: boolean | undefined
|
||||||
|
readonly lang?: GameLang | undefined
|
||||||
|
readonly monsterSearch?: string | undefined
|
||||||
|
readonly monsterSort?: MonsterTableSort | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsCharacterNode {
|
||||||
|
readonly charKey: string
|
||||||
|
readonly host: string
|
||||||
|
readonly realm: string
|
||||||
|
readonly account: string
|
||||||
|
readonly charName: string
|
||||||
|
readonly charClass: CharClassId
|
||||||
|
readonly className: string
|
||||||
|
readonly level: number
|
||||||
|
readonly hardcore: boolean
|
||||||
|
readonly expansion: boolean
|
||||||
|
readonly ladder: boolean
|
||||||
|
readonly totalKills: number
|
||||||
|
readonly lastSeenAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsAccountNode {
|
||||||
|
readonly accountKey: string
|
||||||
|
readonly host: string
|
||||||
|
readonly realm: string
|
||||||
|
readonly account: string
|
||||||
|
readonly label: string
|
||||||
|
readonly totalKills: number
|
||||||
|
readonly characters: readonly KillStatsCharacterNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsSummaryCards {
|
||||||
|
readonly normal: number
|
||||||
|
readonly nightmare: number
|
||||||
|
readonly hell: number
|
||||||
|
readonly total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsBoardCatalogEntry {
|
||||||
|
readonly key: string
|
||||||
|
readonly rowKind: 'boss' | 'superunique'
|
||||||
|
readonly subject: number
|
||||||
|
readonly canonicalId: string
|
||||||
|
readonly baseMonsterId: string
|
||||||
|
readonly baseHcIdx: number
|
||||||
|
readonly mergedBossHcIdx: number | null
|
||||||
|
readonly nameKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsBoardRow extends KillStatsBoardCatalogEntry {
|
||||||
|
readonly name: string
|
||||||
|
readonly nameZh: string
|
||||||
|
readonly nameEn: string
|
||||||
|
readonly subtitle: string | null
|
||||||
|
readonly normal: number
|
||||||
|
readonly nightmare: number
|
||||||
|
readonly hell: number
|
||||||
|
readonly total: number
|
||||||
|
readonly lastAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsMonsterRow {
|
||||||
|
readonly hcIdx: number
|
||||||
|
readonly id: string
|
||||||
|
readonly nameKey: string
|
||||||
|
readonly name: string
|
||||||
|
readonly nameZh: string
|
||||||
|
readonly nameEn: string
|
||||||
|
readonly normal: number
|
||||||
|
readonly nightmare: number
|
||||||
|
readonly hell: number
|
||||||
|
readonly total: number
|
||||||
|
readonly lastAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillStatsViewModel {
|
||||||
|
readonly scope: KillStatsScope
|
||||||
|
readonly soloOnly: boolean
|
||||||
|
readonly showUnkilled: boolean
|
||||||
|
readonly lang: GameLang
|
||||||
|
readonly monsterSearch: string
|
||||||
|
readonly monsterSort: MonsterTableSort
|
||||||
|
readonly totalAllAccountsKills: number
|
||||||
|
readonly accounts: readonly KillStatsAccountNode[]
|
||||||
|
readonly selectedCharacter: KillStatsCharacterNode | null
|
||||||
|
readonly summary: KillStatsSummaryCards
|
||||||
|
readonly boardRows: readonly KillStatsBoardRow[]
|
||||||
|
readonly totalBoardCatalogRows: number
|
||||||
|
readonly monsterRows: readonly KillStatsMonsterRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeAccountKey(host: string, realm: string, account: string): string {
|
||||||
|
return JSON.stringify([host, realm, account].map((s) => s.trim().toLowerCase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a localized monster / boss / SuperUnique display name from `.tbl` dictionaries.
|
||||||
|
* Trims whitespace and falls back to English or canonical ID when an Act 5 Chinese `.tbl`
|
||||||
|
* entry is space-padded (`" "`).
|
||||||
|
*/
|
||||||
|
export function resolveLocalizedMonsterName(
|
||||||
|
nameKey: string,
|
||||||
|
fallbackId: string,
|
||||||
|
lang: GameLang,
|
||||||
|
): string {
|
||||||
|
const tblLang = lang === 'en' ? 'ENG' : 'CHI'
|
||||||
|
const primary = resolveTblKey(nameKey, tblLang)?.trim()
|
||||||
|
if (primary && primary.length > 0) {
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
const engFallback = resolveTblKey(nameKey, 'ENG')?.trim()
|
||||||
|
if (engFallback && engFallback.length > 0) {
|
||||||
|
return engFallback
|
||||||
|
}
|
||||||
|
const trimmedKey = nameKey.trim()
|
||||||
|
return trimmedKey.length > 0 ? trimmedKey : fallbackId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves localized character class name (`0..6`) via `CHAR_CLASS_NAMES` and `resolveTblKey`.
|
||||||
|
*/
|
||||||
|
export function resolveCharacterClassName(charClass: CharClassId, lang: GameLang): string {
|
||||||
|
const key = CHAR_CLASS_NAMES[charClass] ?? 'Amazon'
|
||||||
|
const tblLang = lang === 'en' ? 'ENG' : 'CHI'
|
||||||
|
const resolved = resolveTblKey(key, tblLang)?.trim()
|
||||||
|
return resolved && resolved.length > 0 ? resolved : key
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedBoardCatalog: readonly KillStatsBoardCatalogEntry[] | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the exact 86-row Boss & SuperUnique catalog strictly from 1.13c data:
|
||||||
|
* - 24 killable `MonStats.txt` rows with `boss === true`
|
||||||
|
* - 66 `SuperUniques.txt` rows (`getSuperUniqueByIndex(0..65)`)
|
||||||
|
* - Minus the 4 overlapping rows where `bossKind.id === su.monsterId`:
|
||||||
|
* - `229:radament` -> `SU10` (`Radament`)
|
||||||
|
* - `250:summoner` -> `SU18` (`The Summoner`)
|
||||||
|
* - `365:griswold` -> `SU5` (`Griswold`)
|
||||||
|
* - `526:nihlathakboss` -> `SU60` (`Nihlathak Boss`)
|
||||||
|
*/
|
||||||
|
export function getBossSuperUniqueBoardCatalog(): readonly KillStatsBoardCatalogEntry[] {
|
||||||
|
if (cachedBoardCatalog) {
|
||||||
|
return cachedBoardCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
const killableBossByMonsterId = new Map<
|
||||||
|
string,
|
||||||
|
{ readonly hcIdx: number; readonly id: string; readonly nameKey: string }
|
||||||
|
>()
|
||||||
|
const killableBossList: {
|
||||||
|
readonly hcIdx: number
|
||||||
|
readonly id: string
|
||||||
|
readonly nameKey: string
|
||||||
|
}[] = []
|
||||||
|
|
||||||
|
const monsterIdToHcIdx = new Map<string, number>()
|
||||||
|
for (let hcIdx = 0; hcIdx <= MONSTATS_MAX_HCIDX; hcIdx++) {
|
||||||
|
const kind = getMonsterKindByClassId(hcIdx)
|
||||||
|
if (!kind) continue
|
||||||
|
monsterIdToHcIdx.set(kind.id, hcIdx)
|
||||||
|
if (isCountableBossClass(hcIdx)) {
|
||||||
|
const entry = { hcIdx, id: kind.id, nameKey: kind.nameKey }
|
||||||
|
killableBossList.push(entry)
|
||||||
|
killableBossByMonsterId.set(kind.id, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedBossHcIdxSet = new Set<number>()
|
||||||
|
const suEntries: KillStatsBoardCatalogEntry[] = []
|
||||||
|
|
||||||
|
for (let suIdx = 0; suIdx < SUPERUNIQUE_COUNT; suIdx++) {
|
||||||
|
const su = getSuperUniqueByIndex(suIdx)
|
||||||
|
if (!su) {
|
||||||
|
throw new Error(`Missing SuperUnique definition at index ${suIdx}`)
|
||||||
|
}
|
||||||
|
const overlappingBoss = killableBossByMonsterId.get(su.monsterId)
|
||||||
|
if (overlappingBoss) {
|
||||||
|
mergedBossHcIdxSet.add(overlappingBoss.hcIdx)
|
||||||
|
}
|
||||||
|
const baseHcIdx = monsterIdToHcIdx.get(su.monsterId) ?? -1
|
||||||
|
suEntries.push({
|
||||||
|
key: `superunique:${suIdx}`,
|
||||||
|
rowKind: 'superunique',
|
||||||
|
subject: suIdx,
|
||||||
|
canonicalId: su.id,
|
||||||
|
baseMonsterId: su.monsterId,
|
||||||
|
baseHcIdx,
|
||||||
|
mergedBossHcIdx: overlappingBoss ? overlappingBoss.hcIdx : null,
|
||||||
|
nameKey: su.nameKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const standaloneBossEntries: KillStatsBoardCatalogEntry[] = []
|
||||||
|
for (const boss of killableBossList) {
|
||||||
|
if (mergedBossHcIdxSet.has(boss.hcIdx)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
standaloneBossEntries.push({
|
||||||
|
key: `boss:${boss.hcIdx}`,
|
||||||
|
rowKind: 'boss',
|
||||||
|
subject: boss.hcIdx,
|
||||||
|
canonicalId: boss.id,
|
||||||
|
baseMonsterId: boss.id,
|
||||||
|
baseHcIdx: boss.hcIdx,
|
||||||
|
mergedBossHcIdx: null,
|
||||||
|
nameKey: boss.nameKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedBoardCatalog = [...standaloneBossEntries, ...suEntries]
|
||||||
|
return cachedBoardCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DifficultyAccumulator {
|
||||||
|
normal: number
|
||||||
|
nightmare: number
|
||||||
|
hell: number
|
||||||
|
lastAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyAccumulator(): DifficultyAccumulator {
|
||||||
|
return {
|
||||||
|
normal: 0,
|
||||||
|
nightmare: 0,
|
||||||
|
hell: 0,
|
||||||
|
lastAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTallyToAccumulator(
|
||||||
|
acc: DifficultyAccumulator,
|
||||||
|
difficulty: 0 | 1 | 2,
|
||||||
|
amount: number,
|
||||||
|
lastAt: number,
|
||||||
|
): void {
|
||||||
|
if (amount <= 0) return
|
||||||
|
if (difficulty === 0) {
|
||||||
|
acc.normal += amount
|
||||||
|
} else if (difficulty === 1) {
|
||||||
|
acc.nightmare += amount
|
||||||
|
} else if (difficulty === 2) {
|
||||||
|
acc.hell += amount
|
||||||
|
}
|
||||||
|
if (acc.lastAt === null || lastAt > acc.lastAt) {
|
||||||
|
acc.lastAt = lastAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-stage cross-replica G-counter summation:
|
||||||
|
* Wait: in `KillStatsSnapshot.tallies`, multiple replicas may have rows for the same
|
||||||
|
* `(charKey, kind, difficulty, subject)`. Each replica's row has already been G-counter
|
||||||
|
* merged (`max` per replica) on write/import, so displayed totals sum across all replicas!
|
||||||
|
*/
|
||||||
|
function effectiveTallyCount(tally: KillTallyRecord, soloOnly: boolean): number {
|
||||||
|
return soloOnly ? tally.soloCount : tally.count
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActiveScope(
|
||||||
|
requested: KillStatsScope | undefined,
|
||||||
|
characters: readonly KillCharacterRecord[],
|
||||||
|
): KillStatsScope {
|
||||||
|
if (!requested || requested.kind === 'all') {
|
||||||
|
return { kind: 'all' }
|
||||||
|
}
|
||||||
|
if (requested.kind === 'character') {
|
||||||
|
const exists = characters.some((c) => c.charKey === requested.charKey)
|
||||||
|
return exists ? requested : { kind: 'all' }
|
||||||
|
}
|
||||||
|
const targetAccountKey = computeAccountKey(
|
||||||
|
requested.host,
|
||||||
|
requested.realm,
|
||||||
|
requested.account,
|
||||||
|
)
|
||||||
|
const exists = characters.some(
|
||||||
|
(c) => computeAccountKey(c.host, c.realm, c.account) === targetAccountKey,
|
||||||
|
)
|
||||||
|
return exists ? requested : { kind: 'all' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesScope(
|
||||||
|
charKey: string,
|
||||||
|
charByKey: ReadonlyMap<string, KillCharacterRecord>,
|
||||||
|
scope: KillStatsScope,
|
||||||
|
): boolean {
|
||||||
|
if (scope.kind === 'all') return true
|
||||||
|
if (scope.kind === 'character') return charKey === scope.charKey
|
||||||
|
const c = charByKey.get(charKey)
|
||||||
|
if (!c) return false
|
||||||
|
return (
|
||||||
|
computeAccountKey(c.host, c.realm, c.account) ===
|
||||||
|
computeAccountKey(scope.host, scope.realm, scope.account)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildKillStatsViewModel(
|
||||||
|
snapshot: KillStatsSnapshot,
|
||||||
|
options: KillStatsViewOptions = {},
|
||||||
|
): KillStatsViewModel {
|
||||||
|
const soloOnly = Boolean(options.soloOnly)
|
||||||
|
const showUnkilled = options.showUnkilled ?? true
|
||||||
|
const lang: GameLang = options.lang ?? 'zh'
|
||||||
|
const monsterSearch = options.monsterSearch ?? ''
|
||||||
|
const monsterSort: MonsterTableSort = options.monsterSort ?? {
|
||||||
|
column: 'total',
|
||||||
|
direction: 'desc',
|
||||||
|
}
|
||||||
|
|
||||||
|
const charByKey = new Map<string, KillCharacterRecord>()
|
||||||
|
for (const c of snapshot.characters) {
|
||||||
|
charByKey.set(c.charKey, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = resolveActiveScope(options.scope, snapshot.characters)
|
||||||
|
|
||||||
|
// Compute per-character monster kill totals for sidebar badges
|
||||||
|
const charMonsterKills = new Map<string, number>()
|
||||||
|
let totalAllAccountsKills = 0
|
||||||
|
for (const t of snapshot.tallies) {
|
||||||
|
if (t.kind !== 'monster') continue
|
||||||
|
if (!charByKey.has(t.charKey)) continue
|
||||||
|
const amt = effectiveTallyCount(t, soloOnly)
|
||||||
|
if (amt <= 0) continue
|
||||||
|
totalAllAccountsKills += amt
|
||||||
|
charMonsterKills.set(t.charKey, (charMonsterKills.get(t.charKey) ?? 0) + amt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group characters into account tree nodes
|
||||||
|
const accountGroupMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
host: string
|
||||||
|
realm: string
|
||||||
|
account: string
|
||||||
|
latestSeenAt: number
|
||||||
|
characters: KillStatsCharacterNode[]
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
let selectedCharacter: KillStatsCharacterNode | null = null
|
||||||
|
|
||||||
|
for (const c of snapshot.characters) {
|
||||||
|
const accountKey = computeAccountKey(c.host, c.realm, c.account)
|
||||||
|
let group = accountGroupMap.get(accountKey)
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
host: c.host,
|
||||||
|
realm: c.realm,
|
||||||
|
account: c.account,
|
||||||
|
latestSeenAt: c.lastSeenAt,
|
||||||
|
characters: [],
|
||||||
|
}
|
||||||
|
accountGroupMap.set(accountKey, group)
|
||||||
|
} else if (c.lastSeenAt >= group.latestSeenAt) {
|
||||||
|
group.host = c.host
|
||||||
|
group.realm = c.realm
|
||||||
|
group.account = c.account
|
||||||
|
group.latestSeenAt = c.lastSeenAt
|
||||||
|
}
|
||||||
|
|
||||||
|
const charNode: KillStatsCharacterNode = {
|
||||||
|
charKey: c.charKey,
|
||||||
|
host: c.host,
|
||||||
|
realm: c.realm,
|
||||||
|
account: c.account,
|
||||||
|
charName: c.charName,
|
||||||
|
charClass: c.charClass,
|
||||||
|
className: resolveCharacterClassName(c.charClass, lang),
|
||||||
|
level: c.level,
|
||||||
|
hardcore: c.hardcore,
|
||||||
|
expansion: c.expansion,
|
||||||
|
ladder: c.ladder,
|
||||||
|
totalKills: charMonsterKills.get(c.charKey) ?? 0,
|
||||||
|
lastSeenAt: c.lastSeenAt,
|
||||||
|
}
|
||||||
|
group.characters.push(charNode)
|
||||||
|
if (scope.kind === 'character' && c.charKey === scope.charKey) {
|
||||||
|
selectedCharacter = charNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts: KillStatsAccountNode[] = [...accountGroupMap.entries()]
|
||||||
|
.sort(([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0))
|
||||||
|
.map(([accountKey, group]) => {
|
||||||
|
const sortedChars = [...group.characters].sort((a, b) =>
|
||||||
|
a.charName.localeCompare(b.charName),
|
||||||
|
)
|
||||||
|
const totalKills = sortedChars.reduce((sum, ch) => sum + ch.totalKills, 0)
|
||||||
|
return {
|
||||||
|
accountKey,
|
||||||
|
host: group.host,
|
||||||
|
realm: group.realm,
|
||||||
|
account: group.account,
|
||||||
|
label: `${group.host} · ${group.realm} · ${group.account}`,
|
||||||
|
totalKills,
|
||||||
|
characters: sortedChars,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Aggregate tallies within the active scope
|
||||||
|
const summaryAcc = createEmptyAccumulator()
|
||||||
|
const monsterAccByHcIdx = new Map<number, DifficultyAccumulator>()
|
||||||
|
const bossAccByHcIdx = new Map<number, DifficultyAccumulator>()
|
||||||
|
const suAccByIdx = new Map<number, DifficultyAccumulator>()
|
||||||
|
|
||||||
|
for (const t of snapshot.tallies) {
|
||||||
|
if (!matchesScope(t.charKey, charByKey, scope)) continue
|
||||||
|
const amt = effectiveTallyCount(t, soloOnly)
|
||||||
|
if (amt <= 0) continue
|
||||||
|
|
||||||
|
if (t.kind === 'monster') {
|
||||||
|
if (!isCountableKillClass(t.subject)) continue
|
||||||
|
addTallyToAccumulator(summaryAcc, t.difficulty, amt, t.lastAt)
|
||||||
|
let mAcc = monsterAccByHcIdx.get(t.subject)
|
||||||
|
if (!mAcc) {
|
||||||
|
mAcc = createEmptyAccumulator()
|
||||||
|
monsterAccByHcIdx.set(t.subject, mAcc)
|
||||||
|
}
|
||||||
|
addTallyToAccumulator(mAcc, t.difficulty, amt, t.lastAt)
|
||||||
|
} else if (t.kind === 'boss') {
|
||||||
|
if (!isCountableBossClass(t.subject)) continue
|
||||||
|
let bAcc = bossAccByHcIdx.get(t.subject)
|
||||||
|
if (!bAcc) {
|
||||||
|
bAcc = createEmptyAccumulator()
|
||||||
|
bossAccByHcIdx.set(t.subject, bAcc)
|
||||||
|
}
|
||||||
|
addTallyToAccumulator(bAcc, t.difficulty, amt, t.lastAt)
|
||||||
|
} else if (t.kind === 'superunique') {
|
||||||
|
if (t.subject < 0 || t.subject >= SUPERUNIQUE_COUNT) continue
|
||||||
|
let sAcc = suAccByIdx.get(t.subject)
|
||||||
|
if (!sAcc) {
|
||||||
|
sAcc = createEmptyAccumulator()
|
||||||
|
suAccByIdx.set(t.subject, sAcc)
|
||||||
|
}
|
||||||
|
addTallyToAccumulator(sAcc, t.difficulty, amt, t.lastAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: KillStatsSummaryCards = {
|
||||||
|
normal: summaryAcc.normal,
|
||||||
|
nightmare: summaryAcc.nightmare,
|
||||||
|
hell: summaryAcc.hell,
|
||||||
|
total: summaryAcc.normal + summaryAcc.nightmare + summaryAcc.hell,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the 86-row Boss & SuperUnique Kill Board
|
||||||
|
const catalog = getBossSuperUniqueBoardCatalog()
|
||||||
|
|
||||||
|
// Count localized display name occurrences across all 86 catalog entries to disambiguate duplicates
|
||||||
|
const nameCounts = new Map<string, number>()
|
||||||
|
const localizedCatalog = catalog.map((entry) => {
|
||||||
|
const nameZh = resolveLocalizedMonsterName(entry.nameKey, entry.canonicalId, 'zh')
|
||||||
|
const nameEn = resolveLocalizedMonsterName(entry.nameKey, entry.canonicalId, 'en')
|
||||||
|
const name = lang === 'en' ? nameEn : nameZh
|
||||||
|
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1)
|
||||||
|
return { entry, name, nameZh, nameEn }
|
||||||
|
})
|
||||||
|
|
||||||
|
const allBoardRows: KillStatsBoardRow[] = localizedCatalog.map(
|
||||||
|
({ entry, name, nameZh, nameEn }) => {
|
||||||
|
const acc = createEmptyAccumulator()
|
||||||
|
if (entry.rowKind === 'boss') {
|
||||||
|
const bAcc = bossAccByHcIdx.get(entry.subject)
|
||||||
|
if (bAcc) {
|
||||||
|
acc.normal += bAcc.normal
|
||||||
|
acc.nightmare += bAcc.nightmare
|
||||||
|
acc.hell += bAcc.hell
|
||||||
|
acc.lastAt = bAcc.lastAt
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sAcc = suAccByIdx.get(entry.subject)
|
||||||
|
if (sAcc) {
|
||||||
|
acc.normal += sAcc.normal
|
||||||
|
acc.nightmare += sAcc.nightmare
|
||||||
|
acc.hell += sAcc.hell
|
||||||
|
acc.lastAt = sAcc.lastAt
|
||||||
|
}
|
||||||
|
if (entry.mergedBossHcIdx !== null) {
|
||||||
|
const bAcc = bossAccByHcIdx.get(entry.mergedBossHcIdx)
|
||||||
|
if (bAcc) {
|
||||||
|
acc.normal += bAcc.normal
|
||||||
|
acc.nightmare += bAcc.nightmare
|
||||||
|
acc.hell += bAcc.hell
|
||||||
|
if (
|
||||||
|
bAcc.lastAt !== null &&
|
||||||
|
(acc.lastAt === null || bAcc.lastAt > acc.lastAt)
|
||||||
|
) {
|
||||||
|
acc.lastAt = bAcc.lastAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = acc.normal + acc.nightmare + acc.hell
|
||||||
|
const hasDuplicateName = (nameCounts.get(name) ?? 0) > 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
name,
|
||||||
|
nameZh,
|
||||||
|
nameEn,
|
||||||
|
subtitle: hasDuplicateName ? entry.canonicalId : null,
|
||||||
|
normal: acc.normal,
|
||||||
|
nightmare: acc.nightmare,
|
||||||
|
hell: acc.hell,
|
||||||
|
total,
|
||||||
|
lastAt: total > 0 ? acc.lastAt : null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const boardRows = showUnkilled
|
||||||
|
? allBoardRows
|
||||||
|
: allBoardRows.filter((row) => row.total > 0)
|
||||||
|
|
||||||
|
// Build Monster Type × Difficulty Table (all killed MonStats rows in active scope/filter)
|
||||||
|
const rawMonsterRows: KillStatsMonsterRow[] = []
|
||||||
|
for (const [hcIdx, mAcc] of monsterAccByHcIdx.entries()) {
|
||||||
|
const total = mAcc.normal + mAcc.nightmare + mAcc.hell
|
||||||
|
if (total <= 0) continue
|
||||||
|
const kind = getMonsterKindByClassId(hcIdx)
|
||||||
|
if (!kind) continue
|
||||||
|
const nameZh = resolveLocalizedMonsterName(kind.nameKey, kind.id, 'zh')
|
||||||
|
const nameEn = resolveLocalizedMonsterName(kind.nameKey, kind.id, 'en')
|
||||||
|
const name = lang === 'en' ? nameEn : nameZh
|
||||||
|
rawMonsterRows.push({
|
||||||
|
hcIdx,
|
||||||
|
id: kind.id,
|
||||||
|
nameKey: kind.nameKey,
|
||||||
|
name,
|
||||||
|
nameZh,
|
||||||
|
nameEn,
|
||||||
|
normal: mAcc.normal,
|
||||||
|
nightmare: mAcc.nightmare,
|
||||||
|
hell: mAcc.hell,
|
||||||
|
total,
|
||||||
|
lastAt: mAcc.lastAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = monsterSearch.trim().toLowerCase()
|
||||||
|
const filteredMonsterRows =
|
||||||
|
normalizedQuery.length === 0
|
||||||
|
? rawMonsterRows
|
||||||
|
: rawMonsterRows.filter(
|
||||||
|
(row) =>
|
||||||
|
row.nameZh.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
row.nameEn.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
row.id.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
String(row.hcIdx).includes(normalizedQuery),
|
||||||
|
)
|
||||||
|
|
||||||
|
const dirMultiplier = monsterSort.direction === 'asc' ? 1 : -1
|
||||||
|
const sortedMonsterRows = [...filteredMonsterRows].sort((a, b) => {
|
||||||
|
let cmp = 0
|
||||||
|
switch (monsterSort.column) {
|
||||||
|
case 'hcIdx':
|
||||||
|
cmp = a.hcIdx - b.hcIdx
|
||||||
|
break
|
||||||
|
case 'id':
|
||||||
|
cmp = a.id.localeCompare(b.id)
|
||||||
|
break
|
||||||
|
case 'name':
|
||||||
|
cmp = a.name.localeCompare(b.name, lang === 'en' ? 'en' : 'zh-CN')
|
||||||
|
break
|
||||||
|
case 'normal':
|
||||||
|
cmp = a.normal - b.normal
|
||||||
|
break
|
||||||
|
case 'nightmare':
|
||||||
|
cmp = a.nightmare - b.nightmare
|
||||||
|
break
|
||||||
|
case 'hell':
|
||||||
|
cmp = a.hell - b.hell
|
||||||
|
break
|
||||||
|
case 'total':
|
||||||
|
default:
|
||||||
|
cmp = a.total - b.total
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (cmp !== 0) {
|
||||||
|
return cmp * dirMultiplier
|
||||||
|
}
|
||||||
|
return a.hcIdx - b.hcIdx
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
soloOnly,
|
||||||
|
showUnkilled,
|
||||||
|
lang,
|
||||||
|
monsterSearch,
|
||||||
|
monsterSort,
|
||||||
|
totalAllAccountsKills,
|
||||||
|
accounts,
|
||||||
|
selectedCharacter,
|
||||||
|
summary,
|
||||||
|
boardRows,
|
||||||
|
totalBoardCatalogRows: catalog.length,
|
||||||
|
monsterRows: sortedMonsterRows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,693 @@
|
||||||
|
/**
|
||||||
|
* Real-time in-game hostile monster death tracker (`KillTracker`) for `play.html`.
|
||||||
|
*
|
||||||
|
* Subscribes to `OnlineSession.onServerEvent` and `OnlineSession.onStateChange`, applies
|
||||||
|
* 1.13c monster kill eligibility rules (`MonStats.txt` + `D2Game.dll` `0x6fcacd52..0x6fcacdb8`),
|
||||||
|
* tracks per-GUID death/revive state transitions, batches deltas for 2 seconds, persists to
|
||||||
|
* `KillStatsRepository`, and notifies open `stats.html` tabs via `BroadcastChannel`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isCountableKillClass } from '../../common/units/monster-kill-classes.ts'
|
||||||
|
import { getMonsterKindByClassId } from '../../common/units/monsters-data.ts'
|
||||||
|
import {
|
||||||
|
unitTypeFromId,
|
||||||
|
type CharClassId,
|
||||||
|
type ServerEvent,
|
||||||
|
} from '../../netproto/index.ts'
|
||||||
|
import type { OnlineSession } from '../session/online-session.ts'
|
||||||
|
import {
|
||||||
|
KILL_STATS_CHANNEL_NAME,
|
||||||
|
SUPERUNIQUE_COUNT,
|
||||||
|
computeCharKey,
|
||||||
|
computeDeltaKey,
|
||||||
|
type KillCharacterProfile,
|
||||||
|
type KillDifficulty,
|
||||||
|
type KillStatsBroadcastMessage,
|
||||||
|
type KillTallyDelta,
|
||||||
|
type KillTallyKind,
|
||||||
|
} from './kill-stats-model.ts'
|
||||||
|
import {
|
||||||
|
IndexedDbKillStatsRepository,
|
||||||
|
type KillStatsRepository,
|
||||||
|
} from './kill-stats-store.ts'
|
||||||
|
|
||||||
|
export interface KillTrackerScheduler {
|
||||||
|
setTimeout(cb: () => void, delayMs: number): unknown
|
||||||
|
clearTimeout(handle: unknown): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BroadcastChannelLike {
|
||||||
|
postMessage(message: unknown): void
|
||||||
|
close(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillTrackerDocumentLike {
|
||||||
|
readonly visibilityState?: string | undefined
|
||||||
|
addEventListener?(type: string, listener: () => void): void
|
||||||
|
removeEventListener?(type: string, listener: () => void): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillTrackerWindowLike {
|
||||||
|
addEventListener?(type: string, listener: () => void): void
|
||||||
|
removeEventListener?(type: string, listener: () => void): void
|
||||||
|
readonly document?: KillTrackerDocumentLike | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillTrackerStatus {
|
||||||
|
readonly pendingKills: number
|
||||||
|
readonly pendingDeltasCount: number
|
||||||
|
readonly lastFlushAt: number | null
|
||||||
|
readonly lastError: string | null
|
||||||
|
readonly difficulty: KillDifficulty | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillTrackerOptions {
|
||||||
|
readonly session: OnlineSession
|
||||||
|
readonly repository?: KillStatsRepository | undefined
|
||||||
|
readonly resolveIdentity?: ((session: OnlineSession) => KillCharacterProfile | null) | undefined
|
||||||
|
readonly now?: (() => number) | undefined
|
||||||
|
readonly scheduler?: KillTrackerScheduler | undefined
|
||||||
|
readonly channelFactory?: ((name: string) => BroadcastChannelLike | null) | undefined
|
||||||
|
readonly windowLike?: KillTrackerWindowLike | null | undefined
|
||||||
|
readonly flushDelayMs?: number | undefined
|
||||||
|
readonly maxBackoffMs?: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrackedMonsterUnit {
|
||||||
|
readonly classId: number
|
||||||
|
readonly isSuperUnique: boolean
|
||||||
|
readonly superUniqueId: number | undefined
|
||||||
|
state: 'armed' | 'dead'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingCharacterBatch {
|
||||||
|
profile: KillCharacterProfile
|
||||||
|
readonly deltas: Map<string, KillTallyDelta>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the active 4-tuple character identity `(host, realm, account, charName)` and
|
||||||
|
* character profile metadata from `OnlineSession`. Returns `null` if any of the 4 identity
|
||||||
|
* components is missing or blank.
|
||||||
|
*/
|
||||||
|
export function resolveSessionIdentity(
|
||||||
|
session: OnlineSession,
|
||||||
|
hasGameFlags = true,
|
||||||
|
): KillCharacterProfile | null {
|
||||||
|
const host = (session.loginHost ?? '').trim()
|
||||||
|
const snap = session.getSnapshot()
|
||||||
|
const realm = (snap.selectedRealm?.title ?? '').trim()
|
||||||
|
const account = (snap.username ?? '').trim()
|
||||||
|
const charName = (snap.selectedChar ?? '').trim()
|
||||||
|
|
||||||
|
if (!host || !realm || !account || !charName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const charKey = computeCharKey(host, realm, account, charName)
|
||||||
|
const lowerName = charName.toLowerCase()
|
||||||
|
const matchedChar = snap.characters.find(
|
||||||
|
(c) => c.name.trim().toLowerCase() === lowerName,
|
||||||
|
)
|
||||||
|
|
||||||
|
const selfClass = session.world.self.charClass
|
||||||
|
let charClass: CharClassId = 0
|
||||||
|
if (
|
||||||
|
selfClass !== null &&
|
||||||
|
Number.isInteger(selfClass) &&
|
||||||
|
selfClass >= 0 &&
|
||||||
|
selfClass <= 6
|
||||||
|
) {
|
||||||
|
charClass = selfClass as CharClassId
|
||||||
|
} else if (
|
||||||
|
matchedChar &&
|
||||||
|
Number.isInteger(matchedChar.charClass) &&
|
||||||
|
matchedChar.charClass >= 0 &&
|
||||||
|
matchedChar.charClass <= 6
|
||||||
|
) {
|
||||||
|
charClass = matchedChar.charClass as CharClassId
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfLevel = session.world.self.level
|
||||||
|
const rawLevel =
|
||||||
|
Number.isFinite(selfLevel) && selfLevel >= 1
|
||||||
|
? selfLevel
|
||||||
|
: (matchedChar?.level ?? 1)
|
||||||
|
const level = Math.min(99, Math.max(1, Math.trunc(rawLevel)))
|
||||||
|
|
||||||
|
const hardcore = hasGameFlags
|
||||||
|
? Boolean(session.world.hardcore)
|
||||||
|
: Boolean(matchedChar?.hardcore ?? false)
|
||||||
|
const expansion = hasGameFlags
|
||||||
|
? Boolean(session.world.expansion)
|
||||||
|
: Boolean(matchedChar?.expansion ?? true)
|
||||||
|
const ladder = hasGameFlags
|
||||||
|
? Boolean(session.world.ladder)
|
||||||
|
: Boolean(matchedChar?.ladder ?? false)
|
||||||
|
|
||||||
|
return {
|
||||||
|
charKey,
|
||||||
|
host,
|
||||||
|
realm,
|
||||||
|
account,
|
||||||
|
charName,
|
||||||
|
charClass,
|
||||||
|
level,
|
||||||
|
hardcore,
|
||||||
|
expansion,
|
||||||
|
ladder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SCHEDULER: KillTrackerScheduler = {
|
||||||
|
setTimeout: (cb, delayMs) => globalThis.setTimeout(cb, delayMs),
|
||||||
|
clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultChannelFactory(name: string): BroadcastChannelLike | null {
|
||||||
|
if (typeof globalThis.BroadcastChannel !== 'function') return null
|
||||||
|
try {
|
||||||
|
return new globalThis.BroadcastChannel(name)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KillTracker {
|
||||||
|
readonly repository: KillStatsRepository
|
||||||
|
private readonly session: OnlineSession
|
||||||
|
private readonly resolveIdentityFn: (session: OnlineSession) => KillCharacterProfile | null
|
||||||
|
private readonly now: () => number
|
||||||
|
private readonly scheduler: KillTrackerScheduler
|
||||||
|
private readonly channel: BroadcastChannelLike | null
|
||||||
|
private readonly windowLike: KillTrackerWindowLike | null
|
||||||
|
private readonly flushDelayMs: number
|
||||||
|
private readonly maxBackoffMs: number
|
||||||
|
|
||||||
|
private difficulty: KillDifficulty | null = null
|
||||||
|
private readonly units = new Map<number, TrackedMonsterUnit>()
|
||||||
|
private readonly owned = new Set<number>()
|
||||||
|
private warnedMissingGameFlags = false
|
||||||
|
private warnedMissingIdentity = false
|
||||||
|
private lastPhase: string
|
||||||
|
|
||||||
|
private readonly pendingByChar = new Map<string, PendingCharacterBatch>()
|
||||||
|
private flushTimer: unknown = null
|
||||||
|
private retryAttempts = 0
|
||||||
|
private lastFlushAt: number | null = null
|
||||||
|
private lastError: string | null = null
|
||||||
|
private inFlightFlush: Promise<void> | null = null
|
||||||
|
|
||||||
|
private readonly unsubServerEvent: () => void
|
||||||
|
private readonly unsubStateChange: () => void
|
||||||
|
private readonly onVisibilityChangeBound: () => void
|
||||||
|
private readonly onPageHideBound: () => void
|
||||||
|
|
||||||
|
constructor(options: KillTrackerOptions) {
|
||||||
|
this.session = options.session
|
||||||
|
this.now = options.now ?? (() => Date.now())
|
||||||
|
this.repository =
|
||||||
|
options.repository ?? new IndexedDbKillStatsRepository({ now: this.now })
|
||||||
|
this.resolveIdentityFn =
|
||||||
|
options.resolveIdentity ??
|
||||||
|
((s) => resolveSessionIdentity(s, this.difficulty !== null))
|
||||||
|
this.scheduler = options.scheduler ?? DEFAULT_SCHEDULER
|
||||||
|
const createChannel = options.channelFactory ?? defaultChannelFactory
|
||||||
|
this.channel = createChannel(KILL_STATS_CHANNEL_NAME)
|
||||||
|
this.windowLike =
|
||||||
|
options.windowLike !== undefined
|
||||||
|
? options.windowLike
|
||||||
|
: typeof globalThis.window !== 'undefined'
|
||||||
|
? (globalThis.window as unknown as KillTrackerWindowLike)
|
||||||
|
: null
|
||||||
|
this.flushDelayMs = options.flushDelayMs ?? 2000
|
||||||
|
this.maxBackoffMs = options.maxBackoffMs ?? 60000
|
||||||
|
this.lastPhase = this.session.phase
|
||||||
|
|
||||||
|
this.unsubServerEvent = this.session.onServerEvent((ev) => {
|
||||||
|
try {
|
||||||
|
this.handleServerEvent(ev)
|
||||||
|
} catch (err) {
|
||||||
|
this.recordInternalError('Error in onServerEvent listener', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.unsubStateChange = this.session.onStateChange((snap) => {
|
||||||
|
try {
|
||||||
|
this.handlePhaseChange(snap.phase)
|
||||||
|
} catch (err) {
|
||||||
|
this.recordInternalError('Error in onStateChange listener', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.onVisibilityChangeBound = () => {
|
||||||
|
try {
|
||||||
|
if (this.windowLike?.document?.visibilityState === 'hidden') {
|
||||||
|
void this.flushNow()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.recordInternalError('Error in visibilitychange listener', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onPageHideBound = () => {
|
||||||
|
try {
|
||||||
|
void this.flushNow()
|
||||||
|
} catch (err) {
|
||||||
|
this.recordInternalError('Error in pagehide listener', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.windowLike?.document?.addEventListener?.(
|
||||||
|
'visibilitychange',
|
||||||
|
this.onVisibilityChangeBound,
|
||||||
|
)
|
||||||
|
this.windowLike?.addEventListener?.('pagehide', this.onPageHideBound)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): KillTrackerStatus {
|
||||||
|
let pendingKills = 0
|
||||||
|
let pendingDeltasCount = 0
|
||||||
|
for (const batch of this.pendingByChar.values()) {
|
||||||
|
for (const delta of batch.deltas.values()) {
|
||||||
|
pendingDeltasCount++
|
||||||
|
if (delta.kind === 'monster') {
|
||||||
|
pendingKills += delta.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pendingKills,
|
||||||
|
pendingDeltasCount,
|
||||||
|
lastFlushAt: this.lastFlushAt,
|
||||||
|
lastError: this.lastError,
|
||||||
|
difficulty: this.difficulty,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.unsubServerEvent()
|
||||||
|
this.unsubStateChange()
|
||||||
|
this.windowLike?.document?.removeEventListener?.(
|
||||||
|
'visibilitychange',
|
||||||
|
this.onVisibilityChangeBound,
|
||||||
|
)
|
||||||
|
this.windowLike?.removeEventListener?.('pagehide', this.onPageHideBound)
|
||||||
|
this.clearFlushTimer()
|
||||||
|
try {
|
||||||
|
this.channel?.close()
|
||||||
|
} catch {
|
||||||
|
// Ignore channel close errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordInternalError(prefix: string, err: unknown): void {
|
||||||
|
const detail = err instanceof Error ? err.message : String(err)
|
||||||
|
this.lastError = `${prefix}: ${detail}`
|
||||||
|
console.error(`[KillTracker] ${prefix}:`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetPerGameState(): void {
|
||||||
|
this.difficulty = null
|
||||||
|
this.units.clear()
|
||||||
|
this.owned.clear()
|
||||||
|
this.warnedMissingGameFlags = false
|
||||||
|
this.warnedMissingIdentity = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePhaseChange(nextPhase: string): void {
|
||||||
|
const prevPhase = this.lastPhase
|
||||||
|
this.lastPhase = nextPhase
|
||||||
|
|
||||||
|
if (prevPhase !== 'ingame' && nextPhase === 'ingame') {
|
||||||
|
this.resetPerGameState()
|
||||||
|
} else if (prevPhase === 'ingame' && nextPhase !== 'ingame') {
|
||||||
|
this.resetPerGameState()
|
||||||
|
void this.flushNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleServerEvent(ev: ServerEvent): void {
|
||||||
|
if (this.session.phase !== 'ingame') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'GameFlags': {
|
||||||
|
this.resetPerGameState()
|
||||||
|
if (ev.difficulty === 0 || ev.difficulty === 1 || ev.difficulty === 2) {
|
||||||
|
this.difficulty = ev.difficulty
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Merc': {
|
||||||
|
if (ev.kind === 'assign' && ev.mercId !== undefined && ev.mercId !== 0) {
|
||||||
|
this.owned.add(ev.mercId)
|
||||||
|
this.units.delete(ev.mercId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'UnitRemove': {
|
||||||
|
if (unitTypeFromId(ev.unitType) === 'monster') {
|
||||||
|
this.units.delete(ev.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'UnitAssign': {
|
||||||
|
const u = ev.unit
|
||||||
|
if (unitTypeFromId(u.unitType) !== 'monster') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (u.ownerId !== undefined && u.ownerId !== 0) {
|
||||||
|
this.owned.add(u.id)
|
||||||
|
this.units.delete(u.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.owned.has(u.id)) {
|
||||||
|
this.units.delete(u.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isCountableKillClass(u.classId)) {
|
||||||
|
this.units.delete(u.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAlive =
|
||||||
|
(u.lifePct === undefined || u.lifePct > 0) &&
|
||||||
|
u.mode !== 0 &&
|
||||||
|
u.mode !== 12
|
||||||
|
|
||||||
|
this.units.set(u.id, {
|
||||||
|
classId: u.classId,
|
||||||
|
isSuperUnique: Boolean(u.isSuperUnique),
|
||||||
|
superUniqueId: u.superUniqueId,
|
||||||
|
state: isAlive ? 'armed' : 'dead',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'UnitMode': {
|
||||||
|
if (unitTypeFromId(ev.unitType) !== 'monster') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ev.mode === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.owned.has(ev.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const tracked = this.units.get(ev.id)
|
||||||
|
if (!tracked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracked.state === 'armed' && (ev.mode === 0 || ev.mode === 12)) {
|
||||||
|
tracked.state = 'dead'
|
||||||
|
this.recordKill(tracked)
|
||||||
|
} else if (
|
||||||
|
tracked.state === 'dead' &&
|
||||||
|
ev.mode >= 1 &&
|
||||||
|
ev.mode <= 15 &&
|
||||||
|
ev.mode !== 12
|
||||||
|
) {
|
||||||
|
tracked.state = 'armed'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSoloKillNow(): boolean {
|
||||||
|
const selfId = this.session.world.selfId
|
||||||
|
const selfName = (
|
||||||
|
this.session.world.self.name ||
|
||||||
|
this.session.getSnapshot().selectedChar ||
|
||||||
|
''
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
for (const member of this.session.world.party.values()) {
|
||||||
|
if (member.unitId === 0 || member.unitId === selfId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
selfId === 0 &&
|
||||||
|
selfName.length > 0 &&
|
||||||
|
member.charName &&
|
||||||
|
member.charName.trim().toLowerCase() === selfName
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordKill(unit: TrackedMonsterUnit): void {
|
||||||
|
if (this.difficulty === null) {
|
||||||
|
if (!this.warnedMissingGameFlags) {
|
||||||
|
this.warnedMissingGameFlags = true
|
||||||
|
const msg =
|
||||||
|
'Skipping monster kill count: GameFlags (0x01) difficulty has not been received in this game'
|
||||||
|
this.lastError = msg
|
||||||
|
console.warn(`[KillTracker] ${msg}`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = this.resolveIdentityFn(this.session)
|
||||||
|
if (
|
||||||
|
!profile ||
|
||||||
|
!profile.host.trim() ||
|
||||||
|
!profile.realm.trim() ||
|
||||||
|
!profile.account.trim() ||
|
||||||
|
!profile.charName.trim()
|
||||||
|
) {
|
||||||
|
if (!this.warnedMissingIdentity) {
|
||||||
|
this.warnedMissingIdentity = true
|
||||||
|
const msg =
|
||||||
|
'Skipping monster kill count: incomplete character identity (host, realm, account, charName)'
|
||||||
|
this.lastError = msg
|
||||||
|
console.warn(`[KillTracker] ${msg}`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCountableKillClass(unit.classId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const kind = getMonsterKindByClassId(unit.classId)
|
||||||
|
if (!kind || !kind.killable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficulty = this.difficulty
|
||||||
|
const isSolo = this.isSoloKillNow()
|
||||||
|
const at = this.now()
|
||||||
|
|
||||||
|
this.enqueueDelta(profile, 'monster', difficulty, unit.classId, isSolo, at)
|
||||||
|
|
||||||
|
if (unit.isSuperUnique) {
|
||||||
|
if (
|
||||||
|
unit.superUniqueId !== undefined &&
|
||||||
|
Number.isInteger(unit.superUniqueId) &&
|
||||||
|
unit.superUniqueId >= 0 &&
|
||||||
|
unit.superUniqueId < SUPERUNIQUE_COUNT
|
||||||
|
) {
|
||||||
|
this.enqueueDelta(
|
||||||
|
profile,
|
||||||
|
'superunique',
|
||||||
|
difficulty,
|
||||||
|
unit.superUniqueId,
|
||||||
|
isSolo,
|
||||||
|
at,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const msg = `Out-of-range superUniqueId ${String(unit.superUniqueId)} for monster classId ${unit.classId}`
|
||||||
|
this.lastError = msg
|
||||||
|
console.error(`[KillTracker] ${msg}`)
|
||||||
|
}
|
||||||
|
} else if (kind.boss === true) {
|
||||||
|
this.enqueueDelta(profile, 'boss', difficulty, unit.classId, isSolo, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.flushTimer === null) {
|
||||||
|
this.scheduleFlush(this.flushDelayMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueDelta(
|
||||||
|
profile: KillCharacterProfile,
|
||||||
|
kind: KillTallyKind,
|
||||||
|
difficulty: KillDifficulty,
|
||||||
|
subject: number,
|
||||||
|
isSolo: boolean,
|
||||||
|
at: number,
|
||||||
|
): void {
|
||||||
|
let charBatch = this.pendingByChar.get(profile.charKey)
|
||||||
|
if (!charBatch) {
|
||||||
|
charBatch = {
|
||||||
|
profile,
|
||||||
|
deltas: new Map<string, KillTallyDelta>(),
|
||||||
|
}
|
||||||
|
this.pendingByChar.set(profile.charKey, charBatch)
|
||||||
|
} else {
|
||||||
|
charBatch.profile = profile
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaKey = computeDeltaKey(profile.charKey, kind, difficulty, subject)
|
||||||
|
const existing = charBatch.deltas.get(deltaKey)
|
||||||
|
if (!existing) {
|
||||||
|
charBatch.deltas.set(deltaKey, {
|
||||||
|
charKey: profile.charKey,
|
||||||
|
kind,
|
||||||
|
difficulty,
|
||||||
|
subject,
|
||||||
|
count: 1,
|
||||||
|
soloCount: isSolo ? 1 : 0,
|
||||||
|
firstAt: at,
|
||||||
|
lastAt: at,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
charBatch.deltas.set(deltaKey, {
|
||||||
|
charKey: profile.charKey,
|
||||||
|
kind,
|
||||||
|
difficulty,
|
||||||
|
subject,
|
||||||
|
count: existing.count + 1,
|
||||||
|
soloCount: existing.soloCount + (isSolo ? 1 : 0),
|
||||||
|
firstAt: Math.min(existing.firstAt, at),
|
||||||
|
lastAt: Math.max(existing.lastAt, at),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeBatchBackOnFailure(failedBatch: PendingCharacterBatch): void {
|
||||||
|
let currentBatch = this.pendingByChar.get(failedBatch.profile.charKey)
|
||||||
|
if (!currentBatch) {
|
||||||
|
currentBatch = {
|
||||||
|
profile: failedBatch.profile,
|
||||||
|
deltas: new Map<string, KillTallyDelta>(),
|
||||||
|
}
|
||||||
|
this.pendingByChar.set(failedBatch.profile.charKey, currentBatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [deltaKey, failedDelta] of failedBatch.deltas.entries()) {
|
||||||
|
const existing = currentBatch.deltas.get(deltaKey)
|
||||||
|
if (!existing) {
|
||||||
|
currentBatch.deltas.set(deltaKey, failedDelta)
|
||||||
|
} else {
|
||||||
|
currentBatch.deltas.set(deltaKey, {
|
||||||
|
charKey: failedDelta.charKey,
|
||||||
|
kind: failedDelta.kind,
|
||||||
|
difficulty: failedDelta.difficulty,
|
||||||
|
subject: failedDelta.subject,
|
||||||
|
count: failedDelta.count + existing.count,
|
||||||
|
soloCount: failedDelta.soloCount + existing.soloCount,
|
||||||
|
firstAt: Math.min(failedDelta.firstAt, existing.firstAt),
|
||||||
|
lastAt: Math.max(failedDelta.lastAt, existing.lastAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearFlushTimer(): void {
|
||||||
|
if (this.flushTimer !== null) {
|
||||||
|
this.scheduler.clearTimeout(this.flushTimer)
|
||||||
|
this.flushTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleFlush(delayMs: number): void {
|
||||||
|
this.clearFlushTimer()
|
||||||
|
this.flushTimer = this.scheduler.setTimeout(() => {
|
||||||
|
this.flushTimer = null
|
||||||
|
void this.flushNow()
|
||||||
|
}, delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
async flushNow(): Promise<void> {
|
||||||
|
this.clearFlushTimer()
|
||||||
|
if (this.inFlightFlush) {
|
||||||
|
await this.inFlightFlush
|
||||||
|
}
|
||||||
|
if (this.pendingByChar.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = async (): Promise<void> => {
|
||||||
|
const batches = [...this.pendingByChar.values()]
|
||||||
|
this.pendingByChar.clear()
|
||||||
|
const flushAt = this.now()
|
||||||
|
let anySucceeded = false
|
||||||
|
let failedError: unknown = null
|
||||||
|
|
||||||
|
for (let i = 0; i < batches.length; i++) {
|
||||||
|
const batch = batches[i]!
|
||||||
|
const deltas = [...batch.deltas.values()]
|
||||||
|
if (deltas.length === 0) continue
|
||||||
|
try {
|
||||||
|
await this.repository.applyBatch({
|
||||||
|
character: batch.profile,
|
||||||
|
deltas,
|
||||||
|
at: flushAt,
|
||||||
|
})
|
||||||
|
anySucceeded = true
|
||||||
|
} catch (err) {
|
||||||
|
failedError = err
|
||||||
|
for (let j = i; j < batches.length; j++) {
|
||||||
|
this.mergeBatchBackOnFailure(batches[j]!)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anySucceeded) {
|
||||||
|
this.lastFlushAt = flushAt
|
||||||
|
try {
|
||||||
|
const msg: KillStatsBroadcastMessage = {
|
||||||
|
type: 'updated',
|
||||||
|
at: flushAt,
|
||||||
|
}
|
||||||
|
this.channel?.postMessage(msg)
|
||||||
|
} catch {
|
||||||
|
// Ignore BroadcastChannel errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedError !== null) {
|
||||||
|
this.retryAttempts++
|
||||||
|
const msg =
|
||||||
|
failedError instanceof Error ? failedError.message : String(failedError)
|
||||||
|
this.lastError = msg
|
||||||
|
console.error('[KillTracker] Failed to flush kill stats batch:', failedError)
|
||||||
|
const backoffMs = Math.min(
|
||||||
|
this.maxBackoffMs,
|
||||||
|
this.flushDelayMs * Math.pow(2, this.retryAttempts - 1),
|
||||||
|
)
|
||||||
|
this.scheduleFlush(backoffMs)
|
||||||
|
} else {
|
||||||
|
this.retryAttempts = 0
|
||||||
|
if (this.pendingByChar.size > 0 && this.flushTimer === null) {
|
||||||
|
this.scheduleFlush(this.flushDelayMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = run()
|
||||||
|
this.inFlightFlush = p
|
||||||
|
try {
|
||||||
|
await p
|
||||||
|
} finally {
|
||||||
|
if (this.inFlightFlush === p) {
|
||||||
|
this.inFlightFlush = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
/**
|
||||||
|
* Bilingual (`zh` / `en`) UI strings for `stats.html`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GameLang } from '../i18n/lang.ts'
|
||||||
|
|
||||||
|
export type StatsI18nKey =
|
||||||
|
| 'stats.page.title'
|
||||||
|
| 'stats.sidebar.title'
|
||||||
|
| 'stats.sidebar.allAccounts'
|
||||||
|
| 'stats.sidebar.emptyAccounts'
|
||||||
|
| 'stats.sidebar.killsSuffix'
|
||||||
|
| 'stats.badge.hc'
|
||||||
|
| 'stats.badge.sc'
|
||||||
|
| 'stats.badge.ladder'
|
||||||
|
| 'stats.badge.expansion'
|
||||||
|
| 'stats.badge.classic'
|
||||||
|
| 'stats.summary.normal'
|
||||||
|
| 'stats.summary.nightmare'
|
||||||
|
| 'stats.summary.hell'
|
||||||
|
| 'stats.summary.total'
|
||||||
|
| 'stats.filter.soloOnly'
|
||||||
|
| 'stats.filter.showUnkilled'
|
||||||
|
| 'stats.filter.langToggle'
|
||||||
|
| 'stats.action.exportJson'
|
||||||
|
| 'stats.action.importJson'
|
||||||
|
| 'stats.action.deleteCharacter'
|
||||||
|
| 'stats.action.clearAll'
|
||||||
|
| 'stats.action.backToGame'
|
||||||
|
| 'stats.confirm.deleteCharacter'
|
||||||
|
| 'stats.confirm.clearAll'
|
||||||
|
| 'stats.board.title'
|
||||||
|
| 'stats.board.subtitle'
|
||||||
|
| 'stats.board.col.name'
|
||||||
|
| 'stats.board.col.kind'
|
||||||
|
| 'stats.board.col.normal'
|
||||||
|
| 'stats.board.col.nightmare'
|
||||||
|
| 'stats.board.col.hell'
|
||||||
|
| 'stats.board.col.total'
|
||||||
|
| 'stats.board.col.lastAt'
|
||||||
|
| 'stats.board.kind.boss'
|
||||||
|
| 'stats.board.kind.superunique'
|
||||||
|
| 'stats.board.empty'
|
||||||
|
| 'stats.monsters.title'
|
||||||
|
| 'stats.monsters.searchPlaceholder'
|
||||||
|
| 'stats.monsters.col.hcIdx'
|
||||||
|
| 'stats.monsters.col.name'
|
||||||
|
| 'stats.monsters.col.normal'
|
||||||
|
| 'stats.monsters.col.nightmare'
|
||||||
|
| 'stats.monsters.col.hell'
|
||||||
|
| 'stats.monsters.col.total'
|
||||||
|
| 'stats.monsters.empty'
|
||||||
|
| 'stats.emptyState.title'
|
||||||
|
| 'stats.emptyState.body'
|
||||||
|
| 'stats.footnote.attribution'
|
||||||
|
| 'stats.import.successSummary'
|
||||||
|
| 'stats.error.idbUnavailable'
|
||||||
|
| 'stats.error.idbBlocked'
|
||||||
|
| 'stats.error.idbVersionChange'
|
||||||
|
| 'stats.error.importFailedPrefix'
|
||||||
|
|
||||||
|
export const STATS_I18N: Readonly<
|
||||||
|
Record<StatsI18nKey, { readonly zh: string; readonly en: string }>
|
||||||
|
> = {
|
||||||
|
'stats.page.title': {
|
||||||
|
zh: '暗黑破坏神 II · 怪物击杀统计',
|
||||||
|
en: 'Diablo II · Monster Kill Statistics',
|
||||||
|
},
|
||||||
|
'stats.sidebar.title': {
|
||||||
|
zh: '账号与角色',
|
||||||
|
en: 'Accounts & Characters',
|
||||||
|
},
|
||||||
|
'stats.sidebar.allAccounts': {
|
||||||
|
zh: '全部账号',
|
||||||
|
en: 'All Accounts',
|
||||||
|
},
|
||||||
|
'stats.sidebar.emptyAccounts': {
|
||||||
|
zh: '暂无角色记录',
|
||||||
|
en: 'No characters recorded yet',
|
||||||
|
},
|
||||||
|
'stats.sidebar.killsSuffix': {
|
||||||
|
zh: '击杀',
|
||||||
|
en: 'kills',
|
||||||
|
},
|
||||||
|
'stats.badge.hc': {
|
||||||
|
zh: 'HC',
|
||||||
|
en: 'HC',
|
||||||
|
},
|
||||||
|
'stats.badge.sc': {
|
||||||
|
zh: 'SC',
|
||||||
|
en: 'SC',
|
||||||
|
},
|
||||||
|
'stats.badge.ladder': {
|
||||||
|
zh: 'L',
|
||||||
|
en: 'L',
|
||||||
|
},
|
||||||
|
'stats.badge.expansion': {
|
||||||
|
zh: 'EXP',
|
||||||
|
en: 'EXP',
|
||||||
|
},
|
||||||
|
'stats.badge.classic': {
|
||||||
|
zh: 'CL',
|
||||||
|
en: 'CL',
|
||||||
|
},
|
||||||
|
'stats.summary.normal': {
|
||||||
|
zh: '普通 (Normal)',
|
||||||
|
en: 'Normal',
|
||||||
|
},
|
||||||
|
'stats.summary.nightmare': {
|
||||||
|
zh: '噩梦 (Nightmare)',
|
||||||
|
en: 'Nightmare',
|
||||||
|
},
|
||||||
|
'stats.summary.hell': {
|
||||||
|
zh: '地狱 (Hell)',
|
||||||
|
en: 'Hell',
|
||||||
|
},
|
||||||
|
'stats.summary.total': {
|
||||||
|
zh: '总击杀 (Total)',
|
||||||
|
en: 'Total Kills',
|
||||||
|
},
|
||||||
|
'stats.filter.soloOnly': {
|
||||||
|
zh: '仅单人局',
|
||||||
|
en: 'Solo Only',
|
||||||
|
},
|
||||||
|
'stats.filter.showUnkilled': {
|
||||||
|
zh: '显示未击杀',
|
||||||
|
en: 'Show Unkilled',
|
||||||
|
},
|
||||||
|
'stats.filter.langToggle': {
|
||||||
|
zh: '中 / EN',
|
||||||
|
en: '中 / EN',
|
||||||
|
},
|
||||||
|
'stats.action.exportJson': {
|
||||||
|
zh: '导出 JSON',
|
||||||
|
en: 'Export JSON',
|
||||||
|
},
|
||||||
|
'stats.action.importJson': {
|
||||||
|
zh: '导入 JSON',
|
||||||
|
en: 'Import JSON',
|
||||||
|
},
|
||||||
|
'stats.action.deleteCharacter': {
|
||||||
|
zh: '删除角色',
|
||||||
|
en: 'Delete Character',
|
||||||
|
},
|
||||||
|
'stats.action.clearAll': {
|
||||||
|
zh: '清空全部',
|
||||||
|
en: 'Clear All',
|
||||||
|
},
|
||||||
|
'stats.action.backToGame': {
|
||||||
|
zh: '返回游戏',
|
||||||
|
en: 'Back to Game',
|
||||||
|
},
|
||||||
|
'stats.confirm.deleteCharacter': {
|
||||||
|
zh: '确定要删除当前选中角色的击杀统计记录吗?',
|
||||||
|
en: 'Are you sure you want to delete kill statistics for the selected character?',
|
||||||
|
},
|
||||||
|
'stats.confirm.clearAll': {
|
||||||
|
zh: '确定要清空本浏览器内所有账号和角色的击杀统计记录吗?',
|
||||||
|
en: 'Are you sure you want to clear all kill statistics across all accounts and characters?',
|
||||||
|
},
|
||||||
|
'stats.board.title': {
|
||||||
|
zh: '首领与超级精英击杀榜',
|
||||||
|
en: 'Boss & SuperUnique Kill Board',
|
||||||
|
},
|
||||||
|
'stats.board.subtitle': {
|
||||||
|
zh: '1.13c 原版 24 首领 + 66 超级精英(合并 4 重叠项,共 86 项)',
|
||||||
|
en: '1.13c Ground Truth: 24 Bosses + 66 SuperUniques (4 overlaps merged = 86 rows)',
|
||||||
|
},
|
||||||
|
'stats.board.col.name': {
|
||||||
|
zh: '名称',
|
||||||
|
en: 'Name',
|
||||||
|
},
|
||||||
|
'stats.board.col.kind': {
|
||||||
|
zh: '类别',
|
||||||
|
en: 'Category',
|
||||||
|
},
|
||||||
|
'stats.board.col.normal': {
|
||||||
|
zh: '普通',
|
||||||
|
en: 'Normal',
|
||||||
|
},
|
||||||
|
'stats.board.col.nightmare': {
|
||||||
|
zh: '噩梦',
|
||||||
|
en: 'Nightmare',
|
||||||
|
},
|
||||||
|
'stats.board.col.hell': {
|
||||||
|
zh: '地狱',
|
||||||
|
en: 'Hell',
|
||||||
|
},
|
||||||
|
'stats.board.col.total': {
|
||||||
|
zh: '合计',
|
||||||
|
en: 'Total',
|
||||||
|
},
|
||||||
|
'stats.board.col.lastAt': {
|
||||||
|
zh: '最近击杀',
|
||||||
|
en: 'Last Kill',
|
||||||
|
},
|
||||||
|
'stats.board.kind.boss': {
|
||||||
|
zh: '首领',
|
||||||
|
en: 'Boss',
|
||||||
|
},
|
||||||
|
'stats.board.kind.superunique': {
|
||||||
|
zh: '超级精英',
|
||||||
|
en: 'SuperUnique',
|
||||||
|
},
|
||||||
|
'stats.board.empty': {
|
||||||
|
zh: '当前筛选条件下暂无首领或超级精英击杀记录',
|
||||||
|
en: 'No Boss or SuperUnique kills under the current filter',
|
||||||
|
},
|
||||||
|
'stats.monsters.title': {
|
||||||
|
zh: '怪物类型 × 难度击杀表',
|
||||||
|
en: 'Monster Type × Difficulty Kills',
|
||||||
|
},
|
||||||
|
'stats.monsters.searchPlaceholder': {
|
||||||
|
zh: '搜索怪物中文名 / 英文名 / ID...',
|
||||||
|
en: 'Search monster Chinese / English name / ID...',
|
||||||
|
},
|
||||||
|
'stats.monsters.col.hcIdx': {
|
||||||
|
zh: 'ID (hcIdx)',
|
||||||
|
en: 'ID (hcIdx)',
|
||||||
|
},
|
||||||
|
'stats.monsters.col.name': {
|
||||||
|
zh: '怪物名称',
|
||||||
|
en: 'Monster Name',
|
||||||
|
},
|
||||||
|
'stats.monsters.col.normal': {
|
||||||
|
zh: '普通',
|
||||||
|
en: 'Normal',
|
||||||
|
},
|
||||||
|
'stats.monsters.col.nightmare': {
|
||||||
|
zh: '噩梦',
|
||||||
|
en: 'Nightmare',
|
||||||
|
},
|
||||||
|
'stats.monsters.col.hell': {
|
||||||
|
zh: '地狱',
|
||||||
|
en: 'Hell',
|
||||||
|
},
|
||||||
|
'stats.monsters.col.total': {
|
||||||
|
zh: '合计',
|
||||||
|
en: 'Total',
|
||||||
|
},
|
||||||
|
'stats.monsters.empty': {
|
||||||
|
zh: '暂无匹配的怪物击杀记录',
|
||||||
|
en: 'No matching monster kills recorded',
|
||||||
|
},
|
||||||
|
'stats.emptyState.title': {
|
||||||
|
zh: '暂无怪物击杀数据',
|
||||||
|
en: 'No Monster Kill Statistics Recorded Yet',
|
||||||
|
},
|
||||||
|
'stats.emptyState.body': {
|
||||||
|
zh: '在 play.html 进入游戏击杀敌对怪物后,统计数据将自动写入本地 IndexedDB 并实时同步至此页面;您也可以点击「导入 JSON」恢复已有备份。',
|
||||||
|
en: 'Enter a game in play.html and slay hostile monsters to record statistics automatically in local IndexedDB with live cross-tab sync, or click "Import JSON" to restore a backup.',
|
||||||
|
},
|
||||||
|
'stats.footnote.attribution': {
|
||||||
|
zh: '口径说明:1.13c D2GS 服务端 0x69 死亡包不下发击杀者归属,客户端记录视野内观察到的敌对怪物死亡。单人局内等于精确个人击杀(含佣兵与召唤物);多人局内包含附近队友击杀,可勾选「仅单人局」查看纯单人击杀。',
|
||||||
|
en: 'Attribution Note: 1.13c D2GS 0x69 death packets do not identify the killer; the client records hostile monster deaths observed in view. In solo games this equals exact personal kills (including merc/pets); in party games nearby party kills are included—toggle "Solo Only" for solo-only counts.',
|
||||||
|
},
|
||||||
|
'stats.import.successSummary': {
|
||||||
|
zh: '导入合并完成',
|
||||||
|
en: 'Import merge complete',
|
||||||
|
},
|
||||||
|
'stats.error.idbUnavailable': {
|
||||||
|
zh: 'IndexedDB 在当前浏览器环境下不可用,无法读取或保存击杀统计。',
|
||||||
|
en: 'IndexedDB is unavailable in this browser environment; cannot load or save kill statistics.',
|
||||||
|
},
|
||||||
|
'stats.error.idbBlocked': {
|
||||||
|
zh: 'IndexedDB 数据库连接被其他标签页阻塞,请关闭其他旧标签页后重试。',
|
||||||
|
en: 'IndexedDB connection is blocked by another tab. Please close older tabs and retry.',
|
||||||
|
},
|
||||||
|
'stats.error.idbVersionChange': {
|
||||||
|
zh: 'IndexedDB 数据库版本已在其他标签页更新,请刷新本页面。',
|
||||||
|
en: 'IndexedDB database version changed in another tab. Please refresh this page.',
|
||||||
|
},
|
||||||
|
'stats.error.importFailedPrefix': {
|
||||||
|
zh: 'JSON 导入校验失败:',
|
||||||
|
en: 'JSON import validation failed: ',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tStats(key: StatsI18nKey, lang: GameLang): string {
|
||||||
|
const entry = STATS_I18N[key]
|
||||||
|
return lang === 'en' ? entry.en : entry.zh
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* Browser entry point for `stats.html`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { initLangFromBrowser } from '../i18n/lang.ts'
|
||||||
|
import { getGlobalSettingsStore } from '../settings/settings-store.ts'
|
||||||
|
import type { KillStatsRepository } from './kill-stats-store.ts'
|
||||||
|
import type { KillStatsViewModel } from './kill-stats-view.ts'
|
||||||
|
import { KillStatsPage } from './stats-page.ts'
|
||||||
|
|
||||||
|
export interface StatsAppController {
|
||||||
|
readonly ready: boolean
|
||||||
|
readonly page: KillStatsPage
|
||||||
|
readonly repository: KillStatsRepository
|
||||||
|
refresh(): Promise<void>
|
||||||
|
getViewModel(): KillStatsViewModel | null
|
||||||
|
getLastExportJson(): string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__d2StatsApp?: StatsAppController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootStatsApp(): Promise<void> {
|
||||||
|
initLangFromBrowser()
|
||||||
|
const settingsStore = getGlobalSettingsStore()
|
||||||
|
const root =
|
||||||
|
document.getElementById('d2-stats-app') ??
|
||||||
|
(() => {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.id = 'd2-stats-app'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
return el
|
||||||
|
})()
|
||||||
|
|
||||||
|
const page = new KillStatsPage({
|
||||||
|
root,
|
||||||
|
settingsStore,
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.init()
|
||||||
|
|
||||||
|
const controller: StatsAppController = {
|
||||||
|
ready: true,
|
||||||
|
page,
|
||||||
|
repository: page.repository,
|
||||||
|
refresh: () => page.refresh(),
|
||||||
|
getViewModel: () => page.getViewModel(),
|
||||||
|
getLastExportJson: () => page.getLastExportJson(),
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__d2StatsApp = controller
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootStatsApp()
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -121,6 +121,7 @@ export class Toolbar {
|
||||||
private viewportLabelEl: HTMLElement | null = null
|
private viewportLabelEl: HTMLElement | null = null
|
||||||
private viewportSelectEl: HTMLSelectElement | null = null
|
private viewportSelectEl: HTMLSelectElement | null = null
|
||||||
private inspectorBtnEl: HTMLButtonElement | null = null
|
private inspectorBtnEl: HTMLButtonElement | null = null
|
||||||
|
private killStatsLinkEl: HTMLAnchorElement | null = null
|
||||||
private cacheClearBtnEl: HTMLButtonElement | null = null
|
private cacheClearBtnEl: HTMLButtonElement | null = null
|
||||||
private diagBannerEl: HTMLElement | null = null
|
private diagBannerEl: HTMLElement | null = null
|
||||||
|
|
||||||
|
|
@ -488,17 +489,20 @@ export class Toolbar {
|
||||||
border-color: #c7b377;
|
border-color: #c7b377;
|
||||||
color: #fff2c6;
|
color: #fff2c6;
|
||||||
}
|
}
|
||||||
.d2-toolbar__btn {
|
.d2-toolbar__btn,
|
||||||
|
a.d2-toolbar__btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
background: linear-gradient(180deg, #2b2316 0%, #17120b 100%);
|
background: linear-gradient(180deg, #2b2316 0%, #17120b 100%);
|
||||||
border: 1px solid #574629;
|
border: 1px solid #574629;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
color: #dfc991;
|
color: #dfc991;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.d2-toolbar__btn--active {
|
.d2-toolbar__btn--active {
|
||||||
|
|
@ -563,8 +567,16 @@ export class Toolbar {
|
||||||
this.toggleInspector()
|
this.toggleInspector()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const killStatsLink = doc.createElement('a')
|
||||||
|
killStatsLink.id = 'd2-toolbar-killstats'
|
||||||
|
killStatsLink.className = 'd2-toolbar__btn'
|
||||||
|
killStatsLink.href = 'stats.html'
|
||||||
|
killStatsLink.target = '_blank'
|
||||||
|
killStatsLink.rel = 'noopener'
|
||||||
|
|
||||||
statusGroup.appendChild(statusBadge)
|
statusGroup.appendChild(statusBadge)
|
||||||
statusGroup.appendChild(inspectorBtn)
|
statusGroup.appendChild(inspectorBtn)
|
||||||
|
statusGroup.appendChild(killStatsLink)
|
||||||
|
|
||||||
// Group 2: Language Toggle (中 / EN)
|
// Group 2: Language Toggle (中 / EN)
|
||||||
const langGroup = doc.createElement('div')
|
const langGroup = doc.createElement('div')
|
||||||
|
|
@ -676,6 +688,7 @@ export class Toolbar {
|
||||||
this.viewportLabelEl = viewportLabel
|
this.viewportLabelEl = viewportLabel
|
||||||
this.viewportSelectEl = viewportSelect
|
this.viewportSelectEl = viewportSelect
|
||||||
this.inspectorBtnEl = inspectorBtn
|
this.inspectorBtnEl = inspectorBtn
|
||||||
|
this.killStatsLinkEl = killStatsLink
|
||||||
this.cacheClearBtnEl = cacheClearBtn
|
this.cacheClearBtnEl = cacheClearBtn
|
||||||
this.diagBannerEl = diagBanner
|
this.diagBannerEl = diagBanner
|
||||||
|
|
||||||
|
|
@ -702,6 +715,9 @@ export class Toolbar {
|
||||||
? 'd2-toolbar__btn d2-toolbar__btn--active'
|
? 'd2-toolbar__btn d2-toolbar__btn--active'
|
||||||
: 'd2-toolbar__btn'
|
: 'd2-toolbar__btn'
|
||||||
}
|
}
|
||||||
|
if (this.killStatsLinkEl) {
|
||||||
|
this.killStatsLinkEl.textContent = tToolbar('toolbar.killStats.open', lang)
|
||||||
|
}
|
||||||
if (this.langBtnZhEl) {
|
if (this.langBtnZhEl) {
|
||||||
this.langBtnZhEl.className =
|
this.langBtnZhEl.className =
|
||||||
lang === 'zh' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn'
|
lang === 'zh' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
/**
|
||||||
|
* 1.13c `MonStats.txt` hostile monster kill eligibility (`D2Game.dll` `0x6fcacd52..0x6fcacdb8`).
|
||||||
|
*
|
||||||
|
* Excludes:
|
||||||
|
* - `killable === false` (checked via `getMonsterKindByClassId(hcIdx)`)
|
||||||
|
* - `Align !== 0` (115 rows: summons, hirelings, traps, town guards, critters, Act 5 POWs)
|
||||||
|
* - `neverCount !== 0` (77 rows: bone prison, windows, trapped souls, barricade doors/walls, familiars)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getMonsterKindByClassId } from './monsters-data.ts'
|
||||||
|
|
||||||
|
export const MONSTATS_MAX_HCIDX = 733
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact 115 `hcIdx` values from 1.13c MPQ `data\global\excel\MonStats.txt` where `Align !== 0`.
|
||||||
|
*/
|
||||||
|
export const MONSTATS_ALIGN_NONZERO_HCIDX: ReadonlySet<number> = new Set<number>([
|
||||||
|
146, 147, 148, 150, 151, 152, 154, 155, 157, 158,
|
||||||
|
159, 175, 176, 177, 178, 179, 185, 195, 196, 197,
|
||||||
|
198, 199, 200, 201, 202, 203, 204, 205, 210, 227,
|
||||||
|
244, 245, 246, 251, 252, 253, 254, 255, 257, 264,
|
||||||
|
265, 266, 268, 269, 270, 271, 272, 289, 290, 291,
|
||||||
|
292, 294, 296, 297, 318, 319, 320, 331, 338, 344,
|
||||||
|
351, 352, 353, 356, 357, 358, 359, 363, 364, 367,
|
||||||
|
377, 378, 405, 406, 408, 410, 411, 412, 413, 414,
|
||||||
|
415, 416, 417, 418, 419, 420, 421, 422, 423, 424,
|
||||||
|
425, 426, 427, 428, 429, 430, 431, 511, 512, 513,
|
||||||
|
514, 515, 520, 521, 522, 523, 527, 534, 535, 536,
|
||||||
|
537, 538, 539, 560, 561,
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact 77 `hcIdx` values from 1.13c MPQ `data\global\excel\MonStats.txt` where `neverCount !== 0`.
|
||||||
|
*/
|
||||||
|
export const MONSTATS_NEVER_COUNT_HCIDX: ReadonlySet<number> = new Set<number>([
|
||||||
|
153, 251, 270, 271, 289, 290, 291, 292, 293, 297,
|
||||||
|
326, 327, 328, 329, 330, 332, 340, 341, 342, 343,
|
||||||
|
344, 351, 352, 353, 355, 356, 357, 358, 363, 364,
|
||||||
|
366, 368, 369, 392, 393, 401, 403, 404, 410, 411,
|
||||||
|
412, 413, 414, 415, 416, 417, 418, 419, 420, 421,
|
||||||
|
422, 423, 424, 425, 426, 427, 428, 432, 433, 434,
|
||||||
|
522, 523, 524, 525, 535, 536, 537, 538, 539, 545,
|
||||||
|
559, 560, 561, 567, 568, 569, 574,
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` iff `hcIdx` (`0..733`) is a countable hostile monster class in 1.13c:
|
||||||
|
* - `getMonsterKindByClassId(hcIdx)` exists and has `killable === true`
|
||||||
|
* - `hcIdx` is not in `MONSTATS_ALIGN_NONZERO_HCIDX`
|
||||||
|
* - `hcIdx` is not in `MONSTATS_NEVER_COUNT_HCIDX`
|
||||||
|
*/
|
||||||
|
export function isCountableKillClass(hcIdx: number): boolean {
|
||||||
|
if (!Number.isInteger(hcIdx) || hcIdx < 0 || hcIdx > MONSTATS_MAX_HCIDX) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (MONSTATS_ALIGN_NONZERO_HCIDX.has(hcIdx) || MONSTATS_NEVER_COUNT_HCIDX.has(hcIdx)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const kind = getMonsterKindByClassId(hcIdx)
|
||||||
|
return kind !== undefined && kind.killable === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` iff `hcIdx` is one of the 24 countable `MonStats.txt` rows with `boss === true`.
|
||||||
|
*/
|
||||||
|
export function isCountableBossClass(hcIdx: number): boolean {
|
||||||
|
if (!isCountableKillClass(hcIdx)) return false
|
||||||
|
const kind = getMonsterKindByClassId(hcIdx)
|
||||||
|
return kind?.boss === true
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,433 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Diablo II: Lord of Destruction · 击杀统计 (Kill Statistics)</title>
|
||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: "D2Exocet";
|
||||||
|
src: url("/ui/fonts/D2Exocet.ttf") format("truetype");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "D2Formal436";
|
||||||
|
src: url("/ui/fonts/D2Formal436.ttf") format("truetype");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--font-display: "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
|
||||||
|
--font-body: "D2Formal436", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
|
||||||
|
--d2-bg: #080706;
|
||||||
|
--d2-panel: #12100d;
|
||||||
|
--d2-panel-alt: #181510;
|
||||||
|
--d2-border: #3d3322;
|
||||||
|
--d2-gold: #c7b377;
|
||||||
|
--d2-gold-bright: #e8d28c;
|
||||||
|
--d2-text: #d9d0be;
|
||||||
|
--d2-muted: #938873;
|
||||||
|
--d2-danger: #c94a4a;
|
||||||
|
--d2-hc: #d84b39;
|
||||||
|
--d2-ladder: #58a6ff;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--d2-bg);
|
||||||
|
color: var(--d2-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
#d2-stats-app,
|
||||||
|
.d2-stats-shell {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.d2-stats__sidebar {
|
||||||
|
width: 300px;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 340px;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--d2-panel);
|
||||||
|
border-right: 1px solid var(--d2-border);
|
||||||
|
padding: 16px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.d2-stats__main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px 36px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.d2-stats__sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--d2-border);
|
||||||
|
}
|
||||||
|
.d2-stats__title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--d2-gold);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.d2-stats__sidebar-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--d2-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.d2-stats__all-btn,
|
||||||
|
.d2-stats__account-btn,
|
||||||
|
.d2-stats__char-btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--d2-panel-alt);
|
||||||
|
color: var(--d2-text);
|
||||||
|
border: 1px solid var(--d2-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
.d2-stats__all-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.d2-stats__account-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--d2-gold);
|
||||||
|
}
|
||||||
|
.d2-stats__all-btn:hover,
|
||||||
|
.d2-stats__account-btn:hover,
|
||||||
|
.d2-stats__char-btn:hover {
|
||||||
|
border-color: var(--d2-gold);
|
||||||
|
}
|
||||||
|
.d2-stats__nav-item--active {
|
||||||
|
border-color: var(--d2-gold-bright);
|
||||||
|
background: #251e13;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(199, 179, 119, 0.35);
|
||||||
|
}
|
||||||
|
.d2-stats__account-tree {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.d2-stats__account-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.d2-stats__char-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 1px solid var(--d2-border);
|
||||||
|
}
|
||||||
|
.d2-stats__char-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.d2-stats__char-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--d2-muted);
|
||||||
|
}
|
||||||
|
.d2-stats__nav-count {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--d2-gold);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.d2-stats__badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid var(--d2-border);
|
||||||
|
background: #1d1912;
|
||||||
|
color: var(--d2-gold);
|
||||||
|
}
|
||||||
|
.d2-stats__badge--hc {
|
||||||
|
color: #ff8b7b;
|
||||||
|
border-color: rgba(216, 75, 57, 0.6);
|
||||||
|
background: rgba(216, 75, 57, 0.14);
|
||||||
|
}
|
||||||
|
.d2-stats__badge--ladder {
|
||||||
|
color: #8cc4ff;
|
||||||
|
border-color: rgba(88, 166, 255, 0.5);
|
||||||
|
background: rgba(88, 166, 255, 0.12);
|
||||||
|
}
|
||||||
|
.d2-stats__badge--boss {
|
||||||
|
color: #f3b562;
|
||||||
|
border-color: rgba(243, 181, 98, 0.5);
|
||||||
|
}
|
||||||
|
.d2-stats__badge--su {
|
||||||
|
color: #d2b4ff;
|
||||||
|
border-color: rgba(185, 140, 255, 0.45);
|
||||||
|
}
|
||||||
|
.d2-stats__banner {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid var(--d2-border);
|
||||||
|
}
|
||||||
|
.d2-stats__banner--error {
|
||||||
|
background: rgba(201, 74, 74, 0.18);
|
||||||
|
border-color: var(--d2-danger);
|
||||||
|
color: #ffd0d0;
|
||||||
|
}
|
||||||
|
.d2-stats__banner--info {
|
||||||
|
background: rgba(199, 179, 119, 0.14);
|
||||||
|
border-color: var(--d2-gold);
|
||||||
|
color: var(--d2-gold-bright);
|
||||||
|
}
|
||||||
|
.d2-stats__summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(130px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.d2-stats__card {
|
||||||
|
background: var(--d2-panel);
|
||||||
|
border: 1px solid var(--d2-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.d2-stats__card--total {
|
||||||
|
border-color: var(--d2-gold);
|
||||||
|
}
|
||||||
|
.d2-stats__card-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--d2-muted);
|
||||||
|
}
|
||||||
|
.d2-stats__card-val {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--d2-gold-bright);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.d2-stats__controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--d2-panel);
|
||||||
|
border: 1px solid var(--d2-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.d2-stats__controls-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.d2-stats__checkbox-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.d2-stats__btn {
|
||||||
|
background: var(--d2-panel-alt);
|
||||||
|
color: var(--d2-gold);
|
||||||
|
border: 1px solid var(--d2-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 11px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.d2-stats__btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--d2-gold);
|
||||||
|
color: var(--d2-gold-bright);
|
||||||
|
}
|
||||||
|
.d2-stats__btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.d2-stats__btn--danger {
|
||||||
|
color: #ff9c9c;
|
||||||
|
border-color: rgba(201, 74, 74, 0.5);
|
||||||
|
}
|
||||||
|
.d2-stats__section {
|
||||||
|
background: var(--d2-panel);
|
||||||
|
border: 1px solid var(--d2-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.d2-stats__section-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.d2-stats__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.d2-stats__section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--d2-gold);
|
||||||
|
}
|
||||||
|
.d2-stats__section-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--d2-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.d2-stats__section-badge {
|
||||||
|
background: var(--d2-panel-alt);
|
||||||
|
border: 1px solid var(--d2-border);
|
||||||
|
color: var(--d2-gold);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.d2-stats__search-input {
|
||||||
|
background: var(--d2-bg);
|
||||||
|
color: var(--d2-text);
|
||||||
|
border: 1px solid var(--d2-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-width: 240px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.d2-stats__table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.d2-stats__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.d2-stats__table th,
|
||||||
|
.d2-stats__table td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid #241e15;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.d2-stats__table th {
|
||||||
|
color: var(--d2-gold);
|
||||||
|
font-weight: 600;
|
||||||
|
background: #17130d;
|
||||||
|
}
|
||||||
|
.d2-stats__table .d2-stats__num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.d2-stats__num--total {
|
||||||
|
color: var(--d2-gold-bright);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.d2-stats__entity-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--d2-text);
|
||||||
|
}
|
||||||
|
.d2-stats__entity-subtitle {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--d2-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.d2-stats__sort-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.d2-stats__table-empty,
|
||||||
|
.d2-stats__sidebar-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--d2-muted);
|
||||||
|
padding: 18px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.d2-stats__empty-state {
|
||||||
|
background: var(--d2-panel);
|
||||||
|
border: 1px dashed var(--d2-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.d2-stats__empty-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--d2-gold);
|
||||||
|
}
|
||||||
|
.d2-stats__empty-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--d2-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.d2-stats__footnote {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--d2-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="d2-stats-app"></div>
|
||||||
|
<script type="module" src="/src/client/stats/stats-main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -178,15 +178,27 @@ describe('Architectural Boundaries & 5-Module Isolation (R3, F9 — Zero Whiteli
|
||||||
expect(violations).toEqual([])
|
expect(violations).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Rule 7: play.html is the sole HTML entry point in vite.config.ts and all 5 legacy HTML files are deleted', () => {
|
it('Rule 7: root HTML entry points are exactly {play.html, stats.html} in vite.config.ts and all 5 legacy HTML files are deleted', () => {
|
||||||
|
const rootHtmlFiles = readdirSync(ROOT_DIR)
|
||||||
|
.filter((f) => f.endsWith('.html'))
|
||||||
|
.sort()
|
||||||
|
expect(rootHtmlFiles).toEqual(['play.html', 'stats.html'])
|
||||||
|
|
||||||
expect(existsSync(resolve(ROOT_DIR, 'play.html'))).toBe(true)
|
expect(existsSync(resolve(ROOT_DIR, 'play.html'))).toBe(true)
|
||||||
const playHtml = readFileSync(resolve(ROOT_DIR, 'play.html'), 'utf8')
|
const playHtml = readFileSync(resolve(ROOT_DIR, 'play.html'), 'utf8')
|
||||||
expect(playHtml).toContain('/src/client/main.ts')
|
expect(playHtml).toContain('/src/client/main.ts')
|
||||||
expect(playHtml).toContain('D2Exocet')
|
expect(playHtml).toContain('D2Exocet')
|
||||||
expect(playHtml).toContain('D2Formal436')
|
expect(playHtml).toContain('D2Formal436')
|
||||||
|
|
||||||
|
expect(existsSync(resolve(ROOT_DIR, 'stats.html'))).toBe(true)
|
||||||
|
const statsHtml = readFileSync(resolve(ROOT_DIR, 'stats.html'), 'utf8')
|
||||||
|
expect(statsHtml).toContain('/src/client/stats/stats-main.ts')
|
||||||
|
expect(statsHtml).toContain('D2Exocet')
|
||||||
|
expect(statsHtml).toContain('D2Formal436')
|
||||||
|
|
||||||
const viteCfg = readFileSync(resolve(ROOT_DIR, 'vite.config.ts'), 'utf8')
|
const viteCfg = readFileSync(resolve(ROOT_DIR, 'vite.config.ts'), 'utf8')
|
||||||
expect(viteCfg).toContain('play.html')
|
expect(viteCfg).toContain('play.html')
|
||||||
|
expect(viteCfg).toContain('stats.html')
|
||||||
|
|
||||||
for (const legacyHtml of LEGACY_HTML_FILES) {
|
for (const legacyHtml of LEGACY_HTML_FILES) {
|
||||||
expect(existsSync(resolve(ROOT_DIR, legacyHtml))).toBe(false)
|
expect(existsSync(resolve(ROOT_DIR, legacyHtml))).toBe(false)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,571 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
KILL_STATS_FORMAT,
|
||||||
|
KILL_STATS_VERSION,
|
||||||
|
KillStatsImportError,
|
||||||
|
applyDeltasToState,
|
||||||
|
buildExportPayload,
|
||||||
|
computeCharKey,
|
||||||
|
computeTallyId,
|
||||||
|
generateReplicaId,
|
||||||
|
isValidUuid,
|
||||||
|
mergeCharacterRecords,
|
||||||
|
mergeImportPayload,
|
||||||
|
mergeTallyRecords,
|
||||||
|
validateImportPayload,
|
||||||
|
type KillCharacterProfile,
|
||||||
|
type KillCharacterRecord,
|
||||||
|
type KillStatsExportPayload,
|
||||||
|
type KillStatsSnapshot,
|
||||||
|
type KillTallyDelta,
|
||||||
|
type KillTallyRecord,
|
||||||
|
} from '../../src/client/stats/kill-stats-model.ts'
|
||||||
|
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts'
|
||||||
|
|
||||||
|
const REPLICA_A = '11111111-1111-4111-8111-111111111111'
|
||||||
|
const REPLICA_B = '22222222-2222-4222-8222-222222222222'
|
||||||
|
const REPLICA_C = '33333333-3333-4333-8333-333333333333'
|
||||||
|
|
||||||
|
function makeCharProfile(overrides: Partial<KillCharacterProfile> = {}): KillCharacterProfile {
|
||||||
|
const host = overrides.host ?? 'www.laiseek.xyz'
|
||||||
|
const realm = overrides.realm ?? 'D2CS'
|
||||||
|
const account = overrides.account ?? 'MyAccount'
|
||||||
|
const charName = overrides.charName ?? 'BlizzSorc'
|
||||||
|
return {
|
||||||
|
charKey: overrides.charKey ?? computeCharKey(host, realm, account, charName),
|
||||||
|
host,
|
||||||
|
realm,
|
||||||
|
account,
|
||||||
|
charName,
|
||||||
|
charClass: overrides.charClass ?? 1,
|
||||||
|
level: overrides.level ?? 85,
|
||||||
|
hardcore: overrides.hardcore ?? true,
|
||||||
|
expansion: overrides.expansion ?? true,
|
||||||
|
ladder: overrides.ladder ?? true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCharRecord(overrides: Partial<KillCharacterRecord> = {}): KillCharacterRecord {
|
||||||
|
const profile = makeCharProfile(overrides)
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
firstSeenAt: overrides.firstSeenAt ?? 1000,
|
||||||
|
lastSeenAt: overrides.lastSeenAt ?? 2000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTallyRecord(
|
||||||
|
overrides: Partial<KillTallyRecord> & { readonly charKey: string },
|
||||||
|
): KillTallyRecord {
|
||||||
|
const replicaId = overrides.replicaId ?? REPLICA_A
|
||||||
|
const kind = overrides.kind ?? 'monster'
|
||||||
|
const difficulty = overrides.difficulty ?? 2
|
||||||
|
const subject = overrides.subject ?? 156
|
||||||
|
return {
|
||||||
|
id:
|
||||||
|
overrides.id ??
|
||||||
|
computeTallyId(replicaId, overrides.charKey, kind, difficulty, subject),
|
||||||
|
replicaId,
|
||||||
|
charKey: overrides.charKey,
|
||||||
|
kind,
|
||||||
|
difficulty,
|
||||||
|
subject,
|
||||||
|
count: overrides.count ?? 5,
|
||||||
|
soloCount: overrides.soloCount ?? 3,
|
||||||
|
firstAt: overrides.firstAt ?? 1000,
|
||||||
|
lastAt: overrides.lastAt ?? 2000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('kill-stats-model: Key Normalization & UUID Generation', () => {
|
||||||
|
it('normalizes computeCharKey by trimming and lowercasing all 4 components', () => {
|
||||||
|
const key1 = computeCharKey(' WWW.LAISEEK.XYZ ', ' D2CS ', ' MyAcct ', ' Sorc_One ')
|
||||||
|
const key2 = computeCharKey('www.laiseek.xyz', 'd2cs', 'myacct', 'sorc_one')
|
||||||
|
expect(key1).toBe(key2)
|
||||||
|
expect(key1).toBe('["www.laiseek.xyz","d2cs","myacct","sorc_one"]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes deterministic tally IDs and generates valid RFC 4122 v4 UUIDs via getRandomValues', () => {
|
||||||
|
const charKey = computeCharKey('host', 'realm', 'acct', 'hero')
|
||||||
|
const id = computeTallyId(REPLICA_A, charKey, 'monster', 2, 156)
|
||||||
|
expect(id).toBe(JSON.stringify([REPLICA_A, charKey, 'monster', 2, 156]))
|
||||||
|
|
||||||
|
const uuid1 = generateReplicaId()
|
||||||
|
const uuid2 = generateReplicaId()
|
||||||
|
expect(isValidUuid(uuid1)).toBe(true)
|
||||||
|
expect(isValidUuid(uuid2)).toBe(true)
|
||||||
|
expect(uuid1).not.toBe(uuid2)
|
||||||
|
expect(uuid1[14]).toBe('4')
|
||||||
|
expect(['8', '9', 'a', 'b']).toContain(uuid1[19])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('kill-stats-model: G-Counter & LWW Merge Algebra', () => {
|
||||||
|
it('satisfies commutativity, associativity, and idempotency for mergeTallyRecords', () => {
|
||||||
|
const charKey = computeCharKey('host', 'realm', 'acct', 'hero')
|
||||||
|
const t1 = makeTallyRecord({
|
||||||
|
charKey,
|
||||||
|
count: 4,
|
||||||
|
soloCount: 2,
|
||||||
|
firstAt: 1500,
|
||||||
|
lastAt: 2500,
|
||||||
|
})
|
||||||
|
const t2 = makeTallyRecord({
|
||||||
|
charKey,
|
||||||
|
count: 7,
|
||||||
|
soloCount: 1,
|
||||||
|
firstAt: 1200,
|
||||||
|
lastAt: 2200,
|
||||||
|
})
|
||||||
|
const t3 = makeTallyRecord({
|
||||||
|
charKey,
|
||||||
|
count: 6,
|
||||||
|
soloCount: 5,
|
||||||
|
firstAt: 1800,
|
||||||
|
lastAt: 3000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Idempotency: merge(a, a) === a
|
||||||
|
expect(mergeTallyRecords(t1, t1)).toEqual(t1)
|
||||||
|
|
||||||
|
// Commutativity: merge(a, b) === merge(b, a)
|
||||||
|
expect(mergeTallyRecords(t1, t2)).toEqual(mergeTallyRecords(t2, t1))
|
||||||
|
|
||||||
|
// Associativity: merge(merge(a, b), c) === merge(a, merge(b, c))
|
||||||
|
const left = mergeTallyRecords(mergeTallyRecords(t1, t2), t3)
|
||||||
|
const right = mergeTallyRecords(t1, mergeTallyRecords(t2, t3))
|
||||||
|
expect(left).toEqual(right)
|
||||||
|
expect(left.count).toBe(7)
|
||||||
|
expect(left.soloCount).toBe(5)
|
||||||
|
expect(left.firstAt).toBe(1200)
|
||||||
|
expect(left.lastAt).toBe(3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('satisfies LWW commutativity, associativity, and idempotency for mergeCharacterRecords', () => {
|
||||||
|
const c1 = makeCharRecord({ level: 80, firstSeenAt: 1000, lastSeenAt: 2000 })
|
||||||
|
const c2 = makeCharRecord({ level: 88, firstSeenAt: 1500, lastSeenAt: 3000 })
|
||||||
|
const c3 = makeCharRecord({ level: 85, firstSeenAt: 800, lastSeenAt: 2500 })
|
||||||
|
|
||||||
|
expect(mergeCharacterRecords(c1, c1)).toEqual(c1)
|
||||||
|
expect(mergeCharacterRecords(c1, c2)).toEqual(mergeCharacterRecords(c2, c1))
|
||||||
|
|
||||||
|
const left = mergeCharacterRecords(mergeCharacterRecords(c1, c2), c3)
|
||||||
|
const right = mergeCharacterRecords(c1, mergeCharacterRecords(c2, c3))
|
||||||
|
expect(left).toEqual(right)
|
||||||
|
expect(left.level).toBe(88)
|
||||||
|
expect(left.firstSeenAt).toBe(800)
|
||||||
|
expect(left.lastSeenAt).toBe(3000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('kill-stats-model: Export/Import Round-Trip, Multi-Replica Summation & Replica Rotation', () => {
|
||||||
|
it('round-trips export -> import idempotently and accumulates counts across distinct replicas', async () => {
|
||||||
|
const repoA = new MemoryKillStatsRepository({
|
||||||
|
initialReplicaId: REPLICA_A,
|
||||||
|
now: () => 5000,
|
||||||
|
})
|
||||||
|
const profile = makeCharProfile()
|
||||||
|
const deltasA: KillTallyDelta[] = [
|
||||||
|
{
|
||||||
|
charKey: profile.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 156,
|
||||||
|
count: 3,
|
||||||
|
soloCount: 2,
|
||||||
|
firstAt: 1000,
|
||||||
|
lastAt: 4000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
charKey: profile.charKey,
|
||||||
|
kind: 'boss',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 156,
|
||||||
|
count: 3,
|
||||||
|
soloCount: 2,
|
||||||
|
firstAt: 1000,
|
||||||
|
lastAt: 4000,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await repoA.applyBatch(profile, deltasA, 4000)
|
||||||
|
|
||||||
|
const exportedA = await repoA.exportPayload(5000)
|
||||||
|
expect(exportedA.format).toBe(KILL_STATS_FORMAT)
|
||||||
|
expect(exportedA.version).toBe(KILL_STATS_VERSION)
|
||||||
|
expect(exportedA.replicaId).toBe(REPLICA_A)
|
||||||
|
expect(exportedA.characters).toHaveLength(1)
|
||||||
|
expect(exportedA.tallies).toHaveLength(2)
|
||||||
|
|
||||||
|
// Re-importing the exact same payload into repoA is a no-op (unchangedTallies = 2)
|
||||||
|
const reimportSummary = await repoA.importPayload(JSON.stringify(exportedA))
|
||||||
|
expect(reimportSummary).toEqual({
|
||||||
|
addedCharacters: 0,
|
||||||
|
updatedCharacters: 0,
|
||||||
|
addedTallies: 0,
|
||||||
|
updatedTallies: 0,
|
||||||
|
unchangedTallies: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Second browser (REPLICA_B) records 4 more kills of Andariel (156) and imports exportedA
|
||||||
|
const repoB = new MemoryKillStatsRepository({
|
||||||
|
initialReplicaId: REPLICA_B,
|
||||||
|
now: () => 6000,
|
||||||
|
})
|
||||||
|
await repoB.applyBatch(
|
||||||
|
{ ...profile, level: 86 },
|
||||||
|
[
|
||||||
|
{
|
||||||
|
charKey: profile.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 156,
|
||||||
|
count: 4,
|
||||||
|
soloCount: 4,
|
||||||
|
firstAt: 4500,
|
||||||
|
lastAt: 5500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
charKey: profile.charKey,
|
||||||
|
kind: 'boss',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 156,
|
||||||
|
count: 4,
|
||||||
|
soloCount: 4,
|
||||||
|
firstAt: 4500,
|
||||||
|
lastAt: 5500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
5500,
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergeSummary = await repoB.importPayload(exportedA)
|
||||||
|
expect(mergeSummary).toEqual({
|
||||||
|
addedCharacters: 0,
|
||||||
|
updatedCharacters: 1, // firstSeenAt updated from 4500 -> 1000
|
||||||
|
addedTallies: 2,
|
||||||
|
updatedTallies: 0,
|
||||||
|
unchangedTallies: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const snapB = await repoB.loadAll()
|
||||||
|
const monsterTallies = snapB.tallies.filter(
|
||||||
|
(t) => t.charKey === profile.charKey && t.kind === 'monster' && t.subject === 156,
|
||||||
|
)
|
||||||
|
expect(monsterTallies).toHaveLength(2)
|
||||||
|
const totalKills = monsterTallies.reduce((acc, t) => acc + t.count, 0)
|
||||||
|
const totalSolo = monsterTallies.reduce((acc, t) => acc + t.soloCount, 0)
|
||||||
|
expect(totalKills).toBe(7)
|
||||||
|
expect(totalSolo).toBe(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rotates replicaId on deleteCharacter and clearAll so post-delete kills add to re-imported backups', async () => {
|
||||||
|
let nextIdIdx = 0
|
||||||
|
const ids = [REPLICA_A, REPLICA_B, REPLICA_C]
|
||||||
|
const repo = new MemoryKillStatsRepository({
|
||||||
|
initialReplicaId: ids[nextIdIdx++],
|
||||||
|
generateId: () => ids[nextIdIdx++] ?? generateReplicaId(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const profile = makeCharProfile()
|
||||||
|
await repo.applyBatch(
|
||||||
|
profile,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
charKey: profile.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 19,
|
||||||
|
count: 10,
|
||||||
|
soloCount: 10,
|
||||||
|
firstAt: 1000,
|
||||||
|
lastAt: 2000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
2000,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Take backup before deletion (under REPLICA_A)
|
||||||
|
const backup = await repo.exportPayload(2500)
|
||||||
|
expect(backup.replicaId).toBe(REPLICA_A)
|
||||||
|
|
||||||
|
// Delete character -> deletes records AND rotates replicaId to REPLICA_B
|
||||||
|
await repo.deleteCharacter(profile.charKey)
|
||||||
|
const afterDelete = await repo.loadAll()
|
||||||
|
expect(afterDelete.replicaId).toBe(REPLICA_B)
|
||||||
|
expect(afterDelete.characters).toHaveLength(0)
|
||||||
|
expect(afterDelete.tallies).toHaveLength(0)
|
||||||
|
|
||||||
|
// Record 3 new kills after deletion (stored under REPLICA_B)
|
||||||
|
await repo.applyBatch(
|
||||||
|
profile,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
charKey: profile.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 19,
|
||||||
|
count: 3,
|
||||||
|
soloCount: 3,
|
||||||
|
firstAt: 3000,
|
||||||
|
lastAt: 3500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
3500,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Re-import old backup -> REPLICA_A (10) + REPLICA_B (3) = 13 total kills (not swallowed by max(10, 3))
|
||||||
|
await repo.importPayload(backup)
|
||||||
|
const afterRestore = await repo.loadAll()
|
||||||
|
const total = afterRestore.tallies
|
||||||
|
.filter((t) => t.kind === 'monster' && t.subject === 19)
|
||||||
|
.reduce((sum, t) => sum + t.count, 0)
|
||||||
|
expect(total).toBe(13)
|
||||||
|
|
||||||
|
// clearAll also rotates replicaId to REPLICA_C
|
||||||
|
await repo.clearAll()
|
||||||
|
const afterClear = await repo.loadAll()
|
||||||
|
expect(afterClear.replicaId).toBe(REPLICA_C)
|
||||||
|
expect(afterClear.characters).toHaveLength(0)
|
||||||
|
expect(afterClear.tallies).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('kill-stats-model: Strict Import Validation (Table-Driven KillStatsImportError)', () => {
|
||||||
|
const baseChar = makeCharRecord()
|
||||||
|
const baseTally = makeTallyRecord({ charKey: baseChar.charKey })
|
||||||
|
const validPayload: KillStatsExportPayload = {
|
||||||
|
format: KILL_STATS_FORMAT,
|
||||||
|
version: KILL_STATS_VERSION,
|
||||||
|
exportedAt: 5000,
|
||||||
|
replicaId: REPLICA_A,
|
||||||
|
characters: [baseChar],
|
||||||
|
tallies: [baseTally],
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases: ReadonlyArray<{
|
||||||
|
readonly name: string
|
||||||
|
readonly input: unknown
|
||||||
|
readonly expectedPath: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'malformed JSON string',
|
||||||
|
input: '{not-valid-json',
|
||||||
|
expectedPath: '$',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'non-object root',
|
||||||
|
input: null,
|
||||||
|
expectedPath: '$',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'wrong format string',
|
||||||
|
input: { ...validPayload, format: 'wrong-format' },
|
||||||
|
expectedPath: 'format',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'wrong version number',
|
||||||
|
input: { ...validPayload, version: 2 },
|
||||||
|
expectedPath: 'version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'negative exportedAt',
|
||||||
|
input: { ...validPayload, exportedAt: -1 },
|
||||||
|
expectedPath: 'exportedAt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'invalid root replicaId UUID',
|
||||||
|
input: { ...validPayload, replicaId: 'not-a-uuid' },
|
||||||
|
expectedPath: 'replicaId',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'characters not an array',
|
||||||
|
input: { ...validPayload, characters: {} },
|
||||||
|
expectedPath: 'characters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tallies not an array',
|
||||||
|
input: { ...validPayload, tallies: null },
|
||||||
|
expectedPath: 'tallies',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'empty character host',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
characters: [{ ...baseChar, host: ' ' }],
|
||||||
|
},
|
||||||
|
expectedPath: 'characters[0].host',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mismatched recomputed charKey',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
characters: [{ ...baseChar, charKey: '["tampered","d2cs","myaccount","blizzsorc"]' }],
|
||||||
|
},
|
||||||
|
expectedPath: 'characters[0].charKey',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'duplicate charKey in characters[]',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
characters: [baseChar, baseChar],
|
||||||
|
},
|
||||||
|
expectedPath: 'characters[1].charKey',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'out-of-range charClass (7)',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
characters: [{ ...baseChar, charClass: 7 }],
|
||||||
|
},
|
||||||
|
expectedPath: 'characters[0].charClass',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'out-of-range character level (100)',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
characters: [{ ...baseChar, level: 100 }],
|
||||||
|
},
|
||||||
|
expectedPath: 'characters[0].level',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'character firstSeenAt > lastSeenAt',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
characters: [{ ...baseChar, firstSeenAt: 3000, lastSeenAt: 2000 }],
|
||||||
|
},
|
||||||
|
expectedPath: 'characters[0].firstSeenAt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'invalid tally replicaId UUID',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
tallies: [{ ...baseTally, replicaId: 'bad-uuid' }],
|
||||||
|
},
|
||||||
|
expectedPath: 'tallies[0].replicaId',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tally charKey missing from characters[] (referential integrity)',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
tallies: [
|
||||||
|
{
|
||||||
|
...baseTally,
|
||||||
|
charKey: computeCharKey('other', 'realm', 'acct', 'ghost'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
expectedPath: 'tallies[0].charKey',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'invalid tally kind',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
tallies: [{ ...baseTally, kind: 'area' }],
|
||||||
|
},
|
||||||
|
expectedPath: 'tallies[0].kind',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'invalid tally difficulty (3)',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
tallies: [{ ...baseTally, difficulty: 3 }],
|
||||||
|
},
|
||||||
|
expectedPath: 'tallies[0].difficulty',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'non-countable monster hcIdx (363 = necroskeleton Align=1)',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
tallies: [
|
||||||
|
makeTallyRecord({
|
||||||
|
charKey: baseChar.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
subject: 363,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
expectedPath: 'tallies[0].subject',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'boss tally for non-boss monster hcIdx (19 = fallen1)',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
tallies: [
|
||||||
|
makeTallyRecord({
|
||||||
|
charKey: baseChar.charKey,
|
||||||
|
kind: 'boss',
|
||||||
|
subject: 19,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
expectedPath: 'tallies[0].subject',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'superunique tally out of range (66)',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
tallies: [
|
||||||
|
makeTallyRecord({
|
||||||
|
charKey: baseChar.charKey,
|
||||||
|
kind: 'superunique',
|
||||||
|
subject: 66,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
expectedPath: 'tallies[0].subject',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mismatched recomputed tally id',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
tallies: [{ ...baseTally, id: '["tampered-id"]' }],
|
||||||
|
},
|
||||||
|
expectedPath: 'tallies[0].id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'duplicate tally id in tallies[]',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
tallies: [baseTally, baseTally],
|
||||||
|
},
|
||||||
|
expectedPath: 'tallies[1].id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tally count < 1 (0)',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
tallies: [{ ...baseTally, count: 0, soloCount: 0 }],
|
||||||
|
},
|
||||||
|
expectedPath: 'tallies[0].count',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tally soloCount > count',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
tallies: [{ ...baseTally, count: 5, soloCount: 6 }],
|
||||||
|
},
|
||||||
|
expectedPath: 'tallies[0].soloCount',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tally firstAt > lastAt',
|
||||||
|
input: {
|
||||||
|
...validPayload,
|
||||||
|
tallies: [{ ...baseTally, firstAt: 5000, lastAt: 4000 }],
|
||||||
|
},
|
||||||
|
expectedPath: 'tallies[0].firstAt',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const tc of cases) {
|
||||||
|
it(`rejects ${tc.name} with KillStatsImportError at path "${tc.expectedPath}"`, () => {
|
||||||
|
let caught: unknown = null
|
||||||
|
try {
|
||||||
|
validateImportPayload(tc.input)
|
||||||
|
} catch (err) {
|
||||||
|
caught = err
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(KillStatsImportError)
|
||||||
|
expect((caught as KillStatsImportError).path).toBe(tc.expectedPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
||||||
|
import { computeCharKey } from '../../src/client/stats/kill-stats-model.ts'
|
||||||
|
import { KillTracker } from '../../src/client/stats/kill-tracker.ts'
|
||||||
|
import { encodeD2gsCompressedFrame } from '../../src/netproto/d2gs/compression.ts'
|
||||||
|
import { BitWriter, ByteWriter } from '../../src/netproto/index.ts'
|
||||||
|
import { createFakeRealm, type FakeRealm } from '../netproto/fake-realm-servers.ts'
|
||||||
|
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts'
|
||||||
|
|
||||||
|
function sendD2gsPacket(realm: FakeRealm, rawPacket: Uint8Array): void {
|
||||||
|
const gameConn = realm.latest('game')
|
||||||
|
gameConn.server.write(encodeD2gsCompressedFrame(rawPacket))
|
||||||
|
}
|
||||||
|
|
||||||
|
function make0x01GameFlags(
|
||||||
|
difficulty: 0 | 1 | 2,
|
||||||
|
hardcore = true,
|
||||||
|
expansion = true,
|
||||||
|
ladder = true,
|
||||||
|
): Uint8Array {
|
||||||
|
return new Uint8Array([
|
||||||
|
0x01,
|
||||||
|
difficulty,
|
||||||
|
0x00,
|
||||||
|
hardcore ? 0x08 : 0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
expansion ? 1 : 0,
|
||||||
|
ladder ? 1 : 0,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function make0x59AssignPlayer(
|
||||||
|
unitId: number,
|
||||||
|
charClass: number,
|
||||||
|
charName: string,
|
||||||
|
x = 0,
|
||||||
|
y = 0,
|
||||||
|
): Uint8Array {
|
||||||
|
const buf = new Uint8Array(26)
|
||||||
|
const dv = new DataView(buf.buffer)
|
||||||
|
buf[0] = 0x59
|
||||||
|
dv.setUint32(1, unitId >>> 0, true)
|
||||||
|
buf[5] = charClass & 0xff
|
||||||
|
for (let i = 0; i < Math.min(15, charName.length); i++) {
|
||||||
|
buf[6 + i] = charName.charCodeAt(i) & 0xff
|
||||||
|
}
|
||||||
|
dv.setUint16(22, x & 0xffff, true)
|
||||||
|
dv.setUint16(24, y & 0xffff, true)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
function make0xACSimpleNpc(
|
||||||
|
unitId: number,
|
||||||
|
classId: number,
|
||||||
|
lifePct = 128,
|
||||||
|
x = 5000,
|
||||||
|
y = 5000,
|
||||||
|
): Uint8Array {
|
||||||
|
const buf = new Uint8Array(13)
|
||||||
|
const dv = new DataView(buf.buffer)
|
||||||
|
buf[0] = 0xac
|
||||||
|
dv.setUint32(1, unitId >>> 0, true)
|
||||||
|
dv.setUint16(5, classId & 0xffff, true)
|
||||||
|
dv.setUint16(7, x & 0xffff, true)
|
||||||
|
dv.setUint16(9, y & 0xffff, true)
|
||||||
|
buf[11] = lifePct & 0xff
|
||||||
|
buf[12] = 13
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
function make0xACSuperUniqueNpc(
|
||||||
|
unitId: number,
|
||||||
|
classId: number,
|
||||||
|
superUniqueId: number,
|
||||||
|
lifePct = 128,
|
||||||
|
x = 5000,
|
||||||
|
y = 5000,
|
||||||
|
): Uint8Array {
|
||||||
|
const bw = new BitWriter()
|
||||||
|
bw.writeBits(1, 4) // rawMode = 1 (NU)
|
||||||
|
bw.writeBit(0) // hasAnimComp = 0
|
||||||
|
bw.writeBit(1) // hasMonsterFlags = 1
|
||||||
|
bw.writeBit(0) // champion = 0
|
||||||
|
bw.writeBit(0) // unique = 0
|
||||||
|
bw.writeBit(1) // superunique = 1
|
||||||
|
bw.writeBit(0) // minion = 0
|
||||||
|
bw.writeBit(0) // ghostly = 0
|
||||||
|
bw.writeBits(superUniqueId & 0xffff, 16) // 16-bit superUniqueId
|
||||||
|
bw.writeBits(0, 8) // bossMods terminator (0)
|
||||||
|
bw.writeBits(0x1234, 16) // nameSeed
|
||||||
|
bw.writeBit(0) // hasOwner = 0
|
||||||
|
const tail = bw.toUint8Array()
|
||||||
|
|
||||||
|
const totalLen = 13 + tail.byteLength
|
||||||
|
const w = new ByteWriter(totalLen)
|
||||||
|
w.u8(0xac)
|
||||||
|
w.u32LE(unitId >>> 0)
|
||||||
|
w.u16LE(classId & 0xffff)
|
||||||
|
w.u16LE(x & 0xffff)
|
||||||
|
w.u16LE(y & 0xffff)
|
||||||
|
w.u8(lifePct & 0xff)
|
||||||
|
w.u8(totalLen & 0xff)
|
||||||
|
w.bytes(tail)
|
||||||
|
return w.toUint8Array()
|
||||||
|
}
|
||||||
|
|
||||||
|
function make0x69SetNpcMode(
|
||||||
|
unitId: number,
|
||||||
|
rawCmd: number,
|
||||||
|
lifePct = 0,
|
||||||
|
x = 5000,
|
||||||
|
y = 5000,
|
||||||
|
): Uint8Array {
|
||||||
|
const buf = new Uint8Array(12)
|
||||||
|
const dv = new DataView(buf.buffer)
|
||||||
|
buf[0] = 0x69
|
||||||
|
dv.setUint32(1, unitId >>> 0, true)
|
||||||
|
buf[5] = rawCmd & 0xff
|
||||||
|
dv.setUint16(6, x & 0xffff, true)
|
||||||
|
dv.setUint16(8, y & 0xffff, true)
|
||||||
|
buf[10] = lifePct & 0xff
|
||||||
|
buf[11] = 0
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
function make0x7ASummonAction(
|
||||||
|
ownerId: number,
|
||||||
|
mercId: number,
|
||||||
|
classId: number,
|
||||||
|
action = 1,
|
||||||
|
petType = 1,
|
||||||
|
): Uint8Array {
|
||||||
|
const buf = new Uint8Array(13)
|
||||||
|
const dv = new DataView(buf.buffer)
|
||||||
|
buf[0] = 0x7a
|
||||||
|
buf[1] = action & 0xff
|
||||||
|
buf[2] = petType & 0xff
|
||||||
|
dv.setUint16(3, classId & 0xffff, true)
|
||||||
|
dv.setUint32(5, ownerId >>> 0, true)
|
||||||
|
dv.setUint32(9, mercId >>> 0, true)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
function make0x81AssignMerc(ownerId: number, mercId: number, classId = 338): Uint8Array {
|
||||||
|
const buf = new Uint8Array(20)
|
||||||
|
const dv = new DataView(buf.buffer)
|
||||||
|
buf[0] = 0x81
|
||||||
|
buf[1] = 0
|
||||||
|
dv.setUint16(2, classId & 0xffff, true)
|
||||||
|
dv.setUint32(4, ownerId >>> 0, true)
|
||||||
|
dv.setUint32(8, mercId >>> 0, true)
|
||||||
|
dv.setUint32(12, 0x12345678, true)
|
||||||
|
dv.setUint32(16, 0, true)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
function make0x5BPlayerInGame(
|
||||||
|
unitId: number,
|
||||||
|
charName: string,
|
||||||
|
charClass = 1,
|
||||||
|
charLevel = 80,
|
||||||
|
partyId = 0xffff,
|
||||||
|
): Uint8Array {
|
||||||
|
const buf = new Uint8Array(36)
|
||||||
|
const dv = new DataView(buf.buffer)
|
||||||
|
buf[0] = 0x5b
|
||||||
|
dv.setUint16(1, 36, true)
|
||||||
|
dv.setUint32(3, unitId >>> 0, true)
|
||||||
|
buf[7] = charClass & 0xff
|
||||||
|
for (let i = 0; i < Math.min(15, charName.length); i++) {
|
||||||
|
buf[8 + i] = charName.charCodeAt(i) & 0xff
|
||||||
|
}
|
||||||
|
dv.setUint16(24, charLevel & 0xffff, true)
|
||||||
|
dv.setUint16(26, partyId & 0xffff, true)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Full-Wire OnlineSession + KillTracker Integration (createFakeRealm)', () => {
|
||||||
|
it('authenticates via BNCS/MCP, joins D2GS, decodes compressed 0x01/0x59/0xAC/0x69/0x7A/0x81/0x5B packets, and persists tallies under (host, D2CS, account, d2webbot_sorc)', async () => {
|
||||||
|
const realm = createFakeRealm()
|
||||||
|
let nowMs = 1_700_000_000_000
|
||||||
|
const repository = new MemoryKillStatsRepository({
|
||||||
|
initialReplicaId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||||
|
now: () => nowMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = new OnlineSession({
|
||||||
|
resolver: realm.resolver,
|
||||||
|
defaultHost: 'www.laiseek.xyz',
|
||||||
|
})
|
||||||
|
const tracker = new KillTracker({
|
||||||
|
session,
|
||||||
|
repository,
|
||||||
|
now: () => nowMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1. Login to BNCS + MCP and select 'd2webbot_sorc' on realm 'D2CS'
|
||||||
|
const loginRes = await session.login({
|
||||||
|
host: 'www.laiseek.xyz',
|
||||||
|
username: 'DeckardCain',
|
||||||
|
password: 'secret',
|
||||||
|
})
|
||||||
|
expect(session.loginHost).toBe('www.laiseek.xyz')
|
||||||
|
expect(loginRes.realms[0]?.title).toBe('D2CS')
|
||||||
|
expect(loginRes.characters[0]?.name).toBe('d2webbot_sorc')
|
||||||
|
|
||||||
|
await session.selectCharacter('d2webbot_sorc')
|
||||||
|
await session.enterGame({
|
||||||
|
mode: 'create',
|
||||||
|
spec: {
|
||||||
|
name: 'ks-wire-1',
|
||||||
|
password: '',
|
||||||
|
description: '',
|
||||||
|
difficulty: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(session.phase).toBe('ingame')
|
||||||
|
|
||||||
|
// 2. Inject Hell GameFlags (0x01), GameHandshake (0x0B, selfId = 1), and local player assignment (0x59, unitId = 1)
|
||||||
|
sendD2gsPacket(realm, make0x01GameFlags(2, true, true, true))
|
||||||
|
sendD2gsPacket(realm, new Uint8Array([0x0b, 0x00, 0x01, 0x00, 0x00, 0x00]))
|
||||||
|
sendD2gsPacket(realm, make0x59AssignPlayer(1, 1, 'd2webbot_sorc', 0, 0))
|
||||||
|
sendD2gsPacket(realm, make0x5BPlayerInGame(1, 'd2webbot_sorc', 1, 80, 0xffff))
|
||||||
|
|
||||||
|
// 3. Solo kill #1: Andariel (hcIdx = 156, unitId = 1001) -> monster/156 + boss/156 (solo)
|
||||||
|
sendD2gsPacket(realm, make0xACSimpleNpc(1001, 156, 128))
|
||||||
|
sendD2gsPacket(realm, make0x69SetNpcMode(1001, 0x08, 0)) // dying (mode 0)
|
||||||
|
sendD2gsPacket(realm, make0x69SetNpcMode(1001, 0x09, 0)) // dead (mode 12, no double count)
|
||||||
|
|
||||||
|
// 4. Solo kill #2: Pindleskin (hcIdx = 440, superUniqueId = 52, unitId = 1002) -> monster/440 + superunique/52 (solo)
|
||||||
|
nowMs += 1000
|
||||||
|
sendD2gsPacket(realm, make0xACSuperUniqueNpc(1002, 440, 52, 128))
|
||||||
|
sendD2gsPacket(realm, make0x69SetNpcMode(1002, 0x08, 0))
|
||||||
|
|
||||||
|
// 5. Merc (0x81, unitId = 2001) and Summon (0x7A, unitId = 2002) deaths -> ignored
|
||||||
|
sendD2gsPacket(realm, make0x81AssignMerc(1, 2001, 338))
|
||||||
|
sendD2gsPacket(realm, make0xACSimpleNpc(2001, 19, 128))
|
||||||
|
sendD2gsPacket(realm, make0x69SetNpcMode(2001, 0x08, 0))
|
||||||
|
|
||||||
|
sendD2gsPacket(realm, make0x7ASummonAction(1, 2002, 363, 1, 1))
|
||||||
|
sendD2gsPacket(realm, make0xACSimpleNpc(2002, 19, 128))
|
||||||
|
sendD2gsPacket(realm, make0x69SetNpcMode(2002, 0x08, 0))
|
||||||
|
|
||||||
|
// 6. Teammate joins (0x5B, unitId = 2) -> party kill of Fallen (hcIdx = 19, unitId = 1003) -> count = 1, soloCount = 0
|
||||||
|
nowMs += 1000
|
||||||
|
sendD2gsPacket(realm, make0x5BPlayerInGame(2, 'PartyPaladin', 3, 88, 1))
|
||||||
|
sendD2gsPacket(realm, make0xACSimpleNpc(1003, 19, 128))
|
||||||
|
sendD2gsPacket(realm, make0x69SetNpcMode(1003, 0x08, 0))
|
||||||
|
|
||||||
|
await tracker.flushNow()
|
||||||
|
|
||||||
|
const expectedCharKey = computeCharKey(
|
||||||
|
'www.laiseek.xyz',
|
||||||
|
'D2CS',
|
||||||
|
'DeckardCain',
|
||||||
|
'd2webbot_sorc',
|
||||||
|
)
|
||||||
|
const snap = await repository.loadAll()
|
||||||
|
expect(snap.characters).toHaveLength(1)
|
||||||
|
expect(snap.characters[0]?.charKey).toBe(expectedCharKey)
|
||||||
|
expect(snap.characters[0]?.host).toBe('www.laiseek.xyz')
|
||||||
|
expect(snap.characters[0]?.realm).toBe('D2CS')
|
||||||
|
expect(snap.characters[0]?.account).toBe('DeckardCain')
|
||||||
|
expect(snap.characters[0]?.charName).toBe('d2webbot_sorc')
|
||||||
|
expect(snap.characters[0]?.charClass).toBe(1)
|
||||||
|
expect(snap.characters[0]?.level).toBe(80)
|
||||||
|
expect(snap.characters[0]?.hardcore).toBe(true)
|
||||||
|
expect(snap.characters[0]?.expansion).toBe(true)
|
||||||
|
expect(snap.characters[0]?.ladder).toBe(true)
|
||||||
|
|
||||||
|
const byKey = new Map(
|
||||||
|
snap.tallies.map((t) => [
|
||||||
|
`${t.kind}:${t.difficulty}:${t.subject}`,
|
||||||
|
{ count: t.count, soloCount: t.soloCount },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(byKey.get('monster:2:156')).toEqual({ count: 1, soloCount: 1 })
|
||||||
|
expect(byKey.get('boss:2:156')).toEqual({ count: 1, soloCount: 1 })
|
||||||
|
expect(byKey.get('monster:2:440')).toEqual({ count: 1, soloCount: 1 })
|
||||||
|
expect(byKey.get('superunique:2:52')).toEqual({ count: 1, soloCount: 1 })
|
||||||
|
expect(byKey.get('monster:2:19')).toEqual({ count: 1, soloCount: 0 })
|
||||||
|
expect(snap.tallies).toHaveLength(5)
|
||||||
|
|
||||||
|
tracker.destroy()
|
||||||
|
await session.disconnect()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,416 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
computeCharKey,
|
||||||
|
computeTallyId,
|
||||||
|
type KillCharacterRecord,
|
||||||
|
type KillStatsSnapshot,
|
||||||
|
type KillTallyRecord,
|
||||||
|
} from '../../src/client/stats/kill-stats-model.ts'
|
||||||
|
import {
|
||||||
|
BOSS_SU_BOARD_ROW_COUNT,
|
||||||
|
buildKillStatsViewModel,
|
||||||
|
getBossSuperUniqueBoardCatalog,
|
||||||
|
resolveCharacterClassName,
|
||||||
|
resolveLocalizedMonsterName,
|
||||||
|
} from '../../src/client/stats/kill-stats-view.ts'
|
||||||
|
|
||||||
|
const REPLICA_A = '11111111-1111-4111-8111-111111111111'
|
||||||
|
const REPLICA_B = '22222222-2222-4222-8222-222222222222'
|
||||||
|
|
||||||
|
function makeChar(overrides: Partial<KillCharacterRecord> & {
|
||||||
|
host: string
|
||||||
|
realm: string
|
||||||
|
account: string
|
||||||
|
charName: string
|
||||||
|
}): KillCharacterRecord {
|
||||||
|
const charKey = computeCharKey(
|
||||||
|
overrides.host,
|
||||||
|
overrides.realm,
|
||||||
|
overrides.account,
|
||||||
|
overrides.charName,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
charKey,
|
||||||
|
host: overrides.host,
|
||||||
|
realm: overrides.realm,
|
||||||
|
account: overrides.account,
|
||||||
|
charName: overrides.charName,
|
||||||
|
charClass: overrides.charClass ?? 1,
|
||||||
|
level: overrides.level ?? 85,
|
||||||
|
hardcore: overrides.hardcore ?? true,
|
||||||
|
expansion: overrides.expansion ?? true,
|
||||||
|
ladder: overrides.ladder ?? true,
|
||||||
|
firstSeenAt: overrides.firstSeenAt ?? 1000,
|
||||||
|
lastSeenAt: overrides.lastSeenAt ?? 2000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTally(params: {
|
||||||
|
replicaId?: string
|
||||||
|
charKey: string
|
||||||
|
kind: 'monster' | 'boss' | 'superunique'
|
||||||
|
difficulty: 0 | 1 | 2
|
||||||
|
subject: number
|
||||||
|
count: number
|
||||||
|
soloCount: number
|
||||||
|
firstAt?: number
|
||||||
|
lastAt?: number
|
||||||
|
}): KillTallyRecord {
|
||||||
|
const replicaId = params.replicaId ?? REPLICA_A
|
||||||
|
return {
|
||||||
|
id: computeTallyId(
|
||||||
|
replicaId,
|
||||||
|
params.charKey,
|
||||||
|
params.kind,
|
||||||
|
params.difficulty,
|
||||||
|
params.subject,
|
||||||
|
),
|
||||||
|
replicaId,
|
||||||
|
charKey: params.charKey,
|
||||||
|
kind: params.kind,
|
||||||
|
difficulty: params.difficulty,
|
||||||
|
subject: params.subject,
|
||||||
|
count: params.count,
|
||||||
|
soloCount: params.soloCount,
|
||||||
|
firstAt: params.firstAt ?? 1000,
|
||||||
|
lastAt: params.lastAt ?? 2000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('kill-stats-view (Pure View Model & 86-Row Boss/SU Board)', () => {
|
||||||
|
it('derives the exact 86-row Boss & SuperUnique catalog (20 standalone bosses + 66 SuperUniques with 4 merged overlaps)', () => {
|
||||||
|
const catalog = getBossSuperUniqueBoardCatalog()
|
||||||
|
expect(catalog).toHaveLength(BOSS_SU_BOARD_ROW_COUNT)
|
||||||
|
|
||||||
|
const bossRows = catalog.filter((r) => r.rowKind === 'boss')
|
||||||
|
const suRows = catalog.filter((r) => r.rowKind === 'superunique')
|
||||||
|
expect(bossRows).toHaveLength(20)
|
||||||
|
expect(suRows).toHaveLength(66)
|
||||||
|
|
||||||
|
// Verify the 4 overlap boss hcIdx values are NOT present as standalone boss rows
|
||||||
|
const standaloneBossSubjects = new Set(bossRows.map((r) => r.subject))
|
||||||
|
expect(standaloneBossSubjects.has(229)).toBe(false) // radament -> SU10
|
||||||
|
expect(standaloneBossSubjects.has(250)).toBe(false) // summoner -> SU18
|
||||||
|
expect(standaloneBossSubjects.has(365)).toBe(false) // griswold -> SU5
|
||||||
|
expect(standaloneBossSubjects.has(526)).toBe(false) // nihlathakboss -> SU60
|
||||||
|
|
||||||
|
// Verify they are merged into SU5, SU10, SU18, SU60
|
||||||
|
const mergedSu = suRows
|
||||||
|
.filter((r) => r.mergedBossHcIdx !== null)
|
||||||
|
.map((r) => ({ suIdx: r.subject, id: r.canonicalId, mergedBossHcIdx: r.mergedBossHcIdx }))
|
||||||
|
expect(mergedSu).toEqual([
|
||||||
|
{ suIdx: 5, id: 'Griswold', mergedBossHcIdx: 365 },
|
||||||
|
{ suIdx: 10, id: 'Radament', mergedBossHcIdx: 229 },
|
||||||
|
{ suIdx: 18, id: 'The Summoner', mergedBossHcIdx: 250 },
|
||||||
|
{ suIdx: 60, id: 'Nihlathak Boss', mergedBossHcIdx: 526 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disambiguates the 12 duplicate-name Boss rows with canonicalId subtitles in both zh and en', () => {
|
||||||
|
const emptySnapshot: KillStatsSnapshot = {
|
||||||
|
replicaId: REPLICA_A,
|
||||||
|
characters: [],
|
||||||
|
tallies: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedDupCanonicalIds = [
|
||||||
|
'baalclone',
|
||||||
|
'baalcrab',
|
||||||
|
'diablo',
|
||||||
|
'diabloclone',
|
||||||
|
'duriel',
|
||||||
|
'izual',
|
||||||
|
'mephisto',
|
||||||
|
'uberbaal',
|
||||||
|
'uberdiablo',
|
||||||
|
'uberduriel',
|
||||||
|
'uberizual',
|
||||||
|
'ubermephisto',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const lang of ['zh', 'en'] as const) {
|
||||||
|
const vm = buildKillStatsViewModel(emptySnapshot, { lang, showUnkilled: true })
|
||||||
|
expect(vm.boardRows).toHaveLength(86)
|
||||||
|
const withSubtitle = vm.boardRows
|
||||||
|
.filter((r) => r.subtitle !== null)
|
||||||
|
.map((r) => r.subtitle!)
|
||||||
|
.sort()
|
||||||
|
expect(withSubtitle).toEqual(expectedDupCanonicalIds)
|
||||||
|
|
||||||
|
// Unique names like Andariel and Pindleskin have null subtitle
|
||||||
|
const andariel = vm.boardRows.find((r) => r.canonicalId === 'andariel')
|
||||||
|
const pindleskin = vm.boardRows.find((r) => r.canonicalId === 'Pindleskin')
|
||||||
|
expect(andariel?.subtitle).toBeNull()
|
||||||
|
expect(pindleskin?.subtitle).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges tallies for the 4 overlapping Boss/SuperUnique rows (SU5/365, SU10/229, SU18/250, SU60/526) without double-counting summary totals', () => {
|
||||||
|
const char = makeChar({
|
||||||
|
host: 'www.laiseek.xyz',
|
||||||
|
realm: 'D2CS',
|
||||||
|
account: 'acct1',
|
||||||
|
charName: 'SorcA',
|
||||||
|
})
|
||||||
|
|
||||||
|
const snapshot: KillStatsSnapshot = {
|
||||||
|
replicaId: REPLICA_A,
|
||||||
|
characters: [char],
|
||||||
|
tallies: [
|
||||||
|
// Radament killed once as boss/229 in Normal, and once as superunique/10 in Hell
|
||||||
|
makeTally({
|
||||||
|
charKey: char.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 0,
|
||||||
|
subject: 229,
|
||||||
|
count: 1,
|
||||||
|
soloCount: 1,
|
||||||
|
lastAt: 1500,
|
||||||
|
}),
|
||||||
|
makeTally({
|
||||||
|
charKey: char.charKey,
|
||||||
|
kind: 'boss',
|
||||||
|
difficulty: 0,
|
||||||
|
subject: 229,
|
||||||
|
count: 1,
|
||||||
|
soloCount: 1,
|
||||||
|
lastAt: 1500,
|
||||||
|
}),
|
||||||
|
makeTally({
|
||||||
|
charKey: char.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 229,
|
||||||
|
count: 2,
|
||||||
|
soloCount: 1,
|
||||||
|
lastAt: 3500,
|
||||||
|
}),
|
||||||
|
makeTally({
|
||||||
|
charKey: char.charKey,
|
||||||
|
kind: 'superunique',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 10,
|
||||||
|
count: 2,
|
||||||
|
soloCount: 1,
|
||||||
|
lastAt: 3500,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const vm = buildKillStatsViewModel(snapshot, { showUnkilled: false })
|
||||||
|
// Summary cards sum ONLY kind === 'monster'
|
||||||
|
expect(vm.summary).toEqual({
|
||||||
|
normal: 1,
|
||||||
|
nightmare: 0,
|
||||||
|
hell: 2,
|
||||||
|
total: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Board has only SU10 (Radament) with Normal=1, Hell=2, Total=3, lastAt=3500
|
||||||
|
expect(vm.boardRows).toHaveLength(1)
|
||||||
|
expect(vm.boardRows[0]).toMatchObject({
|
||||||
|
key: 'superunique:10',
|
||||||
|
canonicalId: 'Radament',
|
||||||
|
normal: 1,
|
||||||
|
nightmare: 0,
|
||||||
|
hell: 2,
|
||||||
|
total: 3,
|
||||||
|
lastAt: 3500,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports All / Account / Character scopes, cross-replica summation, and soloOnly filtering', () => {
|
||||||
|
const char1 = makeChar({
|
||||||
|
host: 'www.laiseek.xyz',
|
||||||
|
realm: 'D2CS',
|
||||||
|
account: 'Alpha',
|
||||||
|
charName: 'SorcOne',
|
||||||
|
charClass: 1,
|
||||||
|
level: 90,
|
||||||
|
})
|
||||||
|
const char2 = makeChar({
|
||||||
|
host: 'www.laiseek.xyz',
|
||||||
|
realm: 'D2CS',
|
||||||
|
account: 'Alpha',
|
||||||
|
charName: 'PalTwo',
|
||||||
|
charClass: 3,
|
||||||
|
level: 82,
|
||||||
|
})
|
||||||
|
const char3 = makeChar({
|
||||||
|
host: 'www.laiseek.xyz',
|
||||||
|
realm: 'D2CS',
|
||||||
|
account: 'Beta',
|
||||||
|
charName: 'BarbThree',
|
||||||
|
charClass: 4,
|
||||||
|
level: 75,
|
||||||
|
})
|
||||||
|
|
||||||
|
const snapshot: KillStatsSnapshot = {
|
||||||
|
replicaId: REPLICA_A,
|
||||||
|
characters: [char1, char2, char3],
|
||||||
|
tallies: [
|
||||||
|
// char1 on replica A & replica B: Andariel (156) in Hell
|
||||||
|
makeTally({
|
||||||
|
replicaId: REPLICA_A,
|
||||||
|
charKey: char1.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 156,
|
||||||
|
count: 3,
|
||||||
|
soloCount: 2,
|
||||||
|
}),
|
||||||
|
makeTally({
|
||||||
|
replicaId: REPLICA_B,
|
||||||
|
charKey: char1.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 156,
|
||||||
|
count: 4,
|
||||||
|
soloCount: 4,
|
||||||
|
}),
|
||||||
|
// char2 on replica A: Fallen (19) in Normal (party only: soloCount = 0)
|
||||||
|
makeTally({
|
||||||
|
replicaId: REPLICA_A,
|
||||||
|
charKey: char2.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 0,
|
||||||
|
subject: 19,
|
||||||
|
count: 10,
|
||||||
|
soloCount: 0,
|
||||||
|
}),
|
||||||
|
// char3 on replica A: Baal (544) in Nightmare
|
||||||
|
makeTally({
|
||||||
|
replicaId: REPLICA_A,
|
||||||
|
charKey: char3.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 1,
|
||||||
|
subject: 544,
|
||||||
|
count: 5,
|
||||||
|
soloCount: 5,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. All scope, soloOnly = false
|
||||||
|
const allVm = buildKillStatsViewModel(snapshot, { scope: { kind: 'all' }, soloOnly: false })
|
||||||
|
expect(allVm.totalAllAccountsKills).toBe(3 + 4 + 10 + 5)
|
||||||
|
expect(allVm.summary).toEqual({
|
||||||
|
normal: 10,
|
||||||
|
nightmare: 5,
|
||||||
|
hell: 7,
|
||||||
|
total: 22,
|
||||||
|
})
|
||||||
|
expect(allVm.accounts).toHaveLength(2)
|
||||||
|
expect(allVm.accounts[0]?.account).toBe('Alpha')
|
||||||
|
expect(allVm.accounts[0]?.totalKills).toBe(17)
|
||||||
|
expect(allVm.accounts[1]?.account).toBe('Beta')
|
||||||
|
expect(allVm.accounts[1]?.totalKills).toBe(5)
|
||||||
|
|
||||||
|
// 2. Account scope ('Alpha'), soloOnly = false
|
||||||
|
const acctVm = buildKillStatsViewModel(snapshot, {
|
||||||
|
scope: { kind: 'account', host: 'www.laiseek.xyz', realm: 'D2CS', account: 'Alpha' },
|
||||||
|
soloOnly: false,
|
||||||
|
})
|
||||||
|
expect(acctVm.summary).toEqual({
|
||||||
|
normal: 10,
|
||||||
|
nightmare: 0,
|
||||||
|
hell: 7,
|
||||||
|
total: 17,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Account scope ('Alpha'), soloOnly = true -> char2's party-only Fallen kills drop to 0
|
||||||
|
const acctSoloVm = buildKillStatsViewModel(snapshot, {
|
||||||
|
scope: { kind: 'account', host: 'www.laiseek.xyz', realm: 'D2CS', account: 'Alpha' },
|
||||||
|
soloOnly: true,
|
||||||
|
})
|
||||||
|
expect(acctSoloVm.summary).toEqual({
|
||||||
|
normal: 0,
|
||||||
|
nightmare: 0,
|
||||||
|
hell: 6,
|
||||||
|
total: 6,
|
||||||
|
})
|
||||||
|
// Fallen (19) had soloCount = 0 so it should not appear in monsterRows when soloOnly = true
|
||||||
|
expect(acctSoloVm.monsterRows.map((r) => r.hcIdx)).toEqual([156])
|
||||||
|
|
||||||
|
// 4. Character scope (char1)
|
||||||
|
const charVm = buildKillStatsViewModel(snapshot, {
|
||||||
|
scope: { kind: 'character', charKey: char1.charKey },
|
||||||
|
soloOnly: false,
|
||||||
|
})
|
||||||
|
expect(charVm.selectedCharacter?.charName).toBe('SorcOne')
|
||||||
|
expect(charVm.summary).toEqual({
|
||||||
|
normal: 0,
|
||||||
|
nightmare: 0,
|
||||||
|
hell: 7,
|
||||||
|
total: 7,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves bilingual names (trimming Act 5 space-padded CHI entries) and supports sorting and search on the monster table', () => {
|
||||||
|
// hcIdx 440 = reanimatedhorde5 (NameStr = DefiledWarrior, which is space-padded in CHI .tbl)
|
||||||
|
const zh440 = resolveLocalizedMonsterName('DefiledWarrior', 'reanimatedhorde5', 'zh')
|
||||||
|
const en440 = resolveLocalizedMonsterName('DefiledWarrior', 'reanimatedhorde5', 'en')
|
||||||
|
expect(zh440.trim().length).toBeGreaterThan(0)
|
||||||
|
expect(en440).toBe('Defiled Warrior')
|
||||||
|
|
||||||
|
expect(resolveCharacterClassName(1, 'zh')).toBe('法師')
|
||||||
|
expect(resolveCharacterClassName(1, 'en')).toBe('Sorceress')
|
||||||
|
|
||||||
|
const char = makeChar({
|
||||||
|
host: 'www.laiseek.xyz',
|
||||||
|
realm: 'D2CS',
|
||||||
|
account: 'acct',
|
||||||
|
charName: 'Hero',
|
||||||
|
})
|
||||||
|
const snapshot: KillStatsSnapshot = {
|
||||||
|
replicaId: REPLICA_A,
|
||||||
|
characters: [char],
|
||||||
|
tallies: [
|
||||||
|
makeTally({
|
||||||
|
charKey: char.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 0,
|
||||||
|
subject: 19, // fallen1
|
||||||
|
count: 25,
|
||||||
|
soloCount: 25,
|
||||||
|
}),
|
||||||
|
makeTally({
|
||||||
|
charKey: char.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 156, // andariel
|
||||||
|
count: 5,
|
||||||
|
soloCount: 5,
|
||||||
|
}),
|
||||||
|
makeTally({
|
||||||
|
charKey: char.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 1,
|
||||||
|
subject: 544, // baalcrab
|
||||||
|
count: 12,
|
||||||
|
soloCount: 12,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by total desc (default)
|
||||||
|
const byTotalDesc = buildKillStatsViewModel(snapshot)
|
||||||
|
expect(byTotalDesc.monsterRows.map((r) => r.hcIdx)).toEqual([19, 544, 156])
|
||||||
|
|
||||||
|
// Sort by hcIdx asc
|
||||||
|
const byHcIdxAsc = buildKillStatsViewModel(snapshot, {
|
||||||
|
monsterSort: { column: 'hcIdx', direction: 'asc' },
|
||||||
|
})
|
||||||
|
expect(byHcIdxAsc.monsterRows.map((r) => r.hcIdx)).toEqual([19, 156, 544])
|
||||||
|
|
||||||
|
// Search by Chinese name ('安達利爾'), English name ('andariel'), or canonical ID ('fallen1')
|
||||||
|
const searchZh = buildKillStatsViewModel(snapshot, { monsterSearch: '安達利爾' })
|
||||||
|
expect(searchZh.monsterRows.map((r) => r.hcIdx)).toEqual([156])
|
||||||
|
|
||||||
|
const searchEn = buildKillStatsViewModel(snapshot, { monsterSearch: 'ANDARIEL' })
|
||||||
|
expect(searchEn.monsterRows.map((r) => r.hcIdx)).toEqual([156])
|
||||||
|
|
||||||
|
const searchId = buildKillStatsViewModel(snapshot, { monsterSearch: 'fallen1' })
|
||||||
|
expect(searchId.monsterRows.map((r) => r.hcIdx)).toEqual([19])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
/**
|
||||||
|
* In-memory `KillStatsRepository` implementation for unit & integration tests.
|
||||||
|
* Reuses pure functions from `src/client/stats/kill-stats-model.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyDeltasToState,
|
||||||
|
buildExportPayload,
|
||||||
|
generateReplicaId,
|
||||||
|
mergeImportPayload,
|
||||||
|
validateImportPayload,
|
||||||
|
type KillCharacterProfile,
|
||||||
|
type KillStatsExportPayload,
|
||||||
|
type KillStatsImportSummary,
|
||||||
|
type KillStatsSnapshot,
|
||||||
|
type KillTallyDelta,
|
||||||
|
} from '../../../src/client/stats/kill-stats-model.ts'
|
||||||
|
import {
|
||||||
|
normalizeApplyBatchArgs,
|
||||||
|
type KillStatsBatchInput,
|
||||||
|
type KillStatsRepository,
|
||||||
|
} from '../../../src/client/stats/kill-stats-store.ts'
|
||||||
|
|
||||||
|
export interface MemoryKillStatsRepositoryOptions {
|
||||||
|
readonly initialReplicaId?: string | undefined
|
||||||
|
readonly now?: (() => number) | undefined
|
||||||
|
readonly generateId?: (() => string) | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemoryKillStatsRepository implements KillStatsRepository {
|
||||||
|
private state: KillStatsSnapshot
|
||||||
|
private readonly now: () => number
|
||||||
|
private readonly generateId: () => string
|
||||||
|
public failNextApplyCount = 0
|
||||||
|
public failApplyError: Error = new Error('Simulated repository write failure')
|
||||||
|
public applyCallCount = 0
|
||||||
|
|
||||||
|
constructor(options: MemoryKillStatsRepositoryOptions = {}) {
|
||||||
|
this.now = options.now ?? (() => Date.now())
|
||||||
|
this.generateId = options.generateId ?? (() => generateReplicaId())
|
||||||
|
this.state = {
|
||||||
|
replicaId: options.initialReplicaId ?? this.generateId(),
|
||||||
|
characters: [],
|
||||||
|
tallies: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshotSync(): KillStatsSnapshot {
|
||||||
|
return this.state
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAll(): Promise<KillStatsSnapshot> {
|
||||||
|
return this.state
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyBatch(batch: KillStatsBatchInput): Promise<void>
|
||||||
|
async applyBatch(
|
||||||
|
character: KillCharacterProfile,
|
||||||
|
deltas: readonly KillTallyDelta[],
|
||||||
|
at?: number,
|
||||||
|
): Promise<void>
|
||||||
|
async applyBatch(
|
||||||
|
replicaId: string,
|
||||||
|
character: KillCharacterProfile,
|
||||||
|
deltas: readonly KillTallyDelta[],
|
||||||
|
at?: number,
|
||||||
|
): Promise<void>
|
||||||
|
async applyBatch(
|
||||||
|
arg0: KillStatsBatchInput | KillCharacterProfile | string,
|
||||||
|
arg1?: readonly KillTallyDelta[] | KillCharacterProfile,
|
||||||
|
arg2?: number | readonly KillTallyDelta[],
|
||||||
|
arg3?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
this.applyCallCount++
|
||||||
|
if (this.failNextApplyCount > 0) {
|
||||||
|
this.failNextApplyCount--
|
||||||
|
throw this.failApplyError
|
||||||
|
}
|
||||||
|
const { explicitReplicaId, character, deltas, at } = normalizeApplyBatchArgs(
|
||||||
|
arg0,
|
||||||
|
arg1,
|
||||||
|
arg2,
|
||||||
|
arg3,
|
||||||
|
)
|
||||||
|
const baseState: KillStatsSnapshot =
|
||||||
|
explicitReplicaId && !this.state.replicaId
|
||||||
|
? { ...this.state, replicaId: explicitReplicaId }
|
||||||
|
: this.state
|
||||||
|
this.state = applyDeltasToState(baseState, character, deltas, at ?? this.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportPayload(exportedAt?: number): Promise<KillStatsExportPayload> {
|
||||||
|
return buildExportPayload(this.state, exportedAt ?? this.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
async importPayload(raw: unknown): Promise<KillStatsImportSummary> {
|
||||||
|
const validated = validateImportPayload(raw)
|
||||||
|
const { next, summary } = mergeImportPayload(this.state, validated)
|
||||||
|
this.state = next
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
private rotateReplicaId(): string {
|
||||||
|
let next = this.generateId()
|
||||||
|
if (next === this.state.replicaId) {
|
||||||
|
next = generateReplicaId()
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCharacter(charKey: string): Promise<void> {
|
||||||
|
const nextReplicaId = this.rotateReplicaId()
|
||||||
|
this.state = {
|
||||||
|
replicaId: nextReplicaId,
|
||||||
|
characters: this.state.characters.filter((c) => c.charKey !== charKey),
|
||||||
|
tallies: this.state.tallies.filter((t) => t.charKey !== charKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAll(): Promise<void> {
|
||||||
|
const nextReplicaId = this.rotateReplicaId()
|
||||||
|
this.state = {
|
||||||
|
replicaId: nextReplicaId,
|
||||||
|
characters: [],
|
||||||
|
tallies: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,805 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
||||||
|
import {
|
||||||
|
computeCharKey,
|
||||||
|
type KillCharacterProfile,
|
||||||
|
type KillStatsBroadcastMessage,
|
||||||
|
} from '../../src/client/stats/kill-stats-model.ts'
|
||||||
|
import {
|
||||||
|
KillTracker,
|
||||||
|
resolveSessionIdentity,
|
||||||
|
type BroadcastChannelLike,
|
||||||
|
type KillTrackerScheduler,
|
||||||
|
type KillTrackerWindowLike,
|
||||||
|
} from '../../src/client/stats/kill-tracker.ts'
|
||||||
|
import type {
|
||||||
|
ClientCommand,
|
||||||
|
CloseReason,
|
||||||
|
GameServerAdapter,
|
||||||
|
GameServerStats,
|
||||||
|
ServerEvent,
|
||||||
|
} from '../../src/netproto/index.ts'
|
||||||
|
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts'
|
||||||
|
|
||||||
|
class FakeGameServerAdapter implements GameServerAdapter {
|
||||||
|
state: 'loading' | 'ingame' | 'closed' = 'ingame'
|
||||||
|
readonly sent: ClientCommand[] = []
|
||||||
|
private readonly eventListeners = new Set<(ev: ServerEvent) => void>()
|
||||||
|
private readonly closeListeners = new Set<(reason: CloseReason) => void>()
|
||||||
|
|
||||||
|
readonly stats: GameServerStats = {
|
||||||
|
unknownPackets: 0,
|
||||||
|
opaquePackets: 0,
|
||||||
|
rttMs: 25,
|
||||||
|
bytesIn: 0,
|
||||||
|
bytesOut: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
send(cmd: ClientCommand): void {
|
||||||
|
this.sent.push(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(cb: (ev: ServerEvent) => void): () => void {
|
||||||
|
this.eventListeners.add(cb)
|
||||||
|
return () => {
|
||||||
|
this.eventListeners.delete(cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(cb: (reason: CloseReason) => void): () => void {
|
||||||
|
this.closeListeners.add(cb)
|
||||||
|
return () => {
|
||||||
|
this.closeListeners.delete(cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(ev: ServerEvent): void {
|
||||||
|
for (const cb of this.eventListeners) {
|
||||||
|
cb(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
leave(): void {
|
||||||
|
this.state = 'closed'
|
||||||
|
for (const cb of this.closeListeners) {
|
||||||
|
cb({ kind: 'local' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeScheduler implements KillTrackerScheduler {
|
||||||
|
private nextHandle = 1
|
||||||
|
readonly timers = new Map<number, { readonly cb: () => void; readonly delayMs: number }>()
|
||||||
|
|
||||||
|
setTimeout(cb: () => void, delayMs: number): unknown {
|
||||||
|
const id = this.nextHandle++
|
||||||
|
this.timers.set(id, { cb, delayMs })
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(handle: unknown): void {
|
||||||
|
if (typeof handle === 'number') {
|
||||||
|
this.timers.delete(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDelays(): number[] {
|
||||||
|
return [...this.timers.values()].map((t) => t.delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fireNext(): boolean {
|
||||||
|
const first = this.timers.entries().next()
|
||||||
|
if (first.done) return false
|
||||||
|
const [id, entry] = first.value
|
||||||
|
this.timers.delete(id)
|
||||||
|
entry.cb()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeWindow implements KillTrackerWindowLike {
|
||||||
|
private readonly winListeners = new Map<string, Set<() => void>>()
|
||||||
|
private readonly docListeners = new Map<string, Set<() => void>>()
|
||||||
|
visibilityState = 'visible'
|
||||||
|
|
||||||
|
readonly document = {
|
||||||
|
get visibilityState(): string {
|
||||||
|
return this.outer.visibilityState
|
||||||
|
},
|
||||||
|
addEventListener: (type: string, listener: () => void): void => {
|
||||||
|
let set = this.docListeners.get(type)
|
||||||
|
if (!set) {
|
||||||
|
set = new Set()
|
||||||
|
this.docListeners.set(type, set)
|
||||||
|
}
|
||||||
|
set.add(listener)
|
||||||
|
},
|
||||||
|
removeEventListener: (type: string, listener: () => void): void => {
|
||||||
|
this.docListeners.get(type)?.delete(listener)
|
||||||
|
},
|
||||||
|
outer: this,
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: () => void): void {
|
||||||
|
let set = this.winListeners.get(type)
|
||||||
|
if (!set) {
|
||||||
|
set = new Set()
|
||||||
|
this.winListeners.set(type, set)
|
||||||
|
}
|
||||||
|
set.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, listener: () => void): void {
|
||||||
|
this.winListeners.get(type)?.delete(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchVisibilityChange(nextState: 'visible' | 'hidden'): void {
|
||||||
|
this.visibilityState = nextState
|
||||||
|
for (const cb of this.docListeners.get('visibilitychange') ?? []) {
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchPageHide(): void {
|
||||||
|
for (const cb of this.winListeners.get('pagehide') ?? []) {
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDefaultProfile(): KillCharacterProfile {
|
||||||
|
const host = 'www.laiseek.xyz'
|
||||||
|
const realm = 'D2CS'
|
||||||
|
const account = 'testacct'
|
||||||
|
const charName = 'TestSorc'
|
||||||
|
return {
|
||||||
|
charKey: computeCharKey(host, realm, account, charName),
|
||||||
|
host,
|
||||||
|
realm,
|
||||||
|
account,
|
||||||
|
charName,
|
||||||
|
charClass: 1,
|
||||||
|
level: 85,
|
||||||
|
hardcore: true,
|
||||||
|
expansion: true,
|
||||||
|
ladder: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupHarness(options?: {
|
||||||
|
readonly identity?: (() => KillCharacterProfile | null) | undefined
|
||||||
|
}) {
|
||||||
|
let nowMs = 10000
|
||||||
|
const session = new OnlineSession()
|
||||||
|
const adapter = new FakeGameServerAdapter()
|
||||||
|
const repository = new MemoryKillStatsRepository({
|
||||||
|
initialReplicaId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
now: () => nowMs,
|
||||||
|
})
|
||||||
|
const scheduler = new FakeScheduler()
|
||||||
|
const windowLike = new FakeWindow()
|
||||||
|
const broadcastMessages: KillStatsBroadcastMessage[] = []
|
||||||
|
const channel: BroadcastChannelLike = {
|
||||||
|
postMessage(msg) {
|
||||||
|
broadcastMessages.push(msg as KillStatsBroadcastMessage)
|
||||||
|
},
|
||||||
|
close() {},
|
||||||
|
}
|
||||||
|
const defaultProfile = makeDefaultProfile()
|
||||||
|
|
||||||
|
const tracker = new KillTracker({
|
||||||
|
session,
|
||||||
|
repository,
|
||||||
|
resolveIdentity: options?.identity ?? (() => defaultProfile),
|
||||||
|
now: () => nowMs,
|
||||||
|
scheduler,
|
||||||
|
channelFactory: () => channel,
|
||||||
|
windowLike,
|
||||||
|
})
|
||||||
|
|
||||||
|
session.attachGameServer(adapter)
|
||||||
|
|
||||||
|
const sendGameFlags = (difficulty: 0 | 1 | 2 = 2): void => {
|
||||||
|
adapter.emit({
|
||||||
|
type: 'GameFlags',
|
||||||
|
difficulty,
|
||||||
|
hardcore: true,
|
||||||
|
expansion: true,
|
||||||
|
ladder: true,
|
||||||
|
})
|
||||||
|
adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
isSelf: true,
|
||||||
|
unit: {
|
||||||
|
unitType: 'player',
|
||||||
|
id: 1,
|
||||||
|
classId: 1,
|
||||||
|
name: defaultProfile.charName,
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
adapter,
|
||||||
|
repository,
|
||||||
|
scheduler,
|
||||||
|
windowLike,
|
||||||
|
broadcastMessages,
|
||||||
|
tracker,
|
||||||
|
defaultProfile,
|
||||||
|
advanceTime: (delta: number): void => {
|
||||||
|
nowMs += delta
|
||||||
|
},
|
||||||
|
sendGameFlags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('KillTracker: Per-GUID State Machine, Exclusions & Revives', () => {
|
||||||
|
it('counts 1 kill across back-to-back 0x08 dying (mode 0) + 0x09 dead (mode 12) and ignores corpse UnitAssign', async () => {
|
||||||
|
const h = setupHarness()
|
||||||
|
h.sendGameFlags(2)
|
||||||
|
|
||||||
|
// 1. Live Fallen (hcIdx = 19) spawns, takes 0x0C damage (mode: undefined), then dies (mode 0 -> mode 12)
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 1,
|
||||||
|
id: 100,
|
||||||
|
classId: 19,
|
||||||
|
x: 5010,
|
||||||
|
y: 5010,
|
||||||
|
lifePct: 128,
|
||||||
|
mode: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// 0x0C NPCGetHit emits UnitMode with mode === undefined -> must not transition or count
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitMode',
|
||||||
|
unitType: 1,
|
||||||
|
id: 100,
|
||||||
|
lifePct: 64,
|
||||||
|
hitClass: 2,
|
||||||
|
})
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
||||||
|
|
||||||
|
// 0x08 dying -> mode = 0 (counts once)
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitMode',
|
||||||
|
unitType: 1,
|
||||||
|
id: 100,
|
||||||
|
mode: 0,
|
||||||
|
lifePct: 0,
|
||||||
|
})
|
||||||
|
// 0x09 dead -> mode = 12 (already dead, must not double-count)
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitMode',
|
||||||
|
unitType: 1,
|
||||||
|
id: 100,
|
||||||
|
mode: 12,
|
||||||
|
lifePct: 0,
|
||||||
|
})
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
||||||
|
|
||||||
|
// 2. Pre-existing corpse UnitAssign (lifePct = 0, mode = 12) followed by UnitMode(12) -> 0 count
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 1,
|
||||||
|
id: 101,
|
||||||
|
classId: 19,
|
||||||
|
x: 5020,
|
||||||
|
y: 5020,
|
||||||
|
lifePct: 0,
|
||||||
|
mode: 12,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitMode',
|
||||||
|
unitType: 1,
|
||||||
|
id: 101,
|
||||||
|
mode: 12,
|
||||||
|
lifePct: 0,
|
||||||
|
})
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
||||||
|
|
||||||
|
await h.tracker.flushNow()
|
||||||
|
const snap = await h.repository.loadAll()
|
||||||
|
expect(snap.tallies).toHaveLength(1)
|
||||||
|
expect(snap.tallies[0]?.subject).toBe(19)
|
||||||
|
expect(snap.tallies[0]?.count).toBe(1)
|
||||||
|
expect(snap.tallies[0]?.soloCount).toBe(1)
|
||||||
|
h.tracker.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-arms dead monsters when revived (alive mode 1..15 != 12) and counts a second kill on re-death, and forgets on UnitRemove', async () => {
|
||||||
|
const h = setupHarness()
|
||||||
|
h.sendGameFlags(1)
|
||||||
|
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 200,
|
||||||
|
classId: 19, // fallen1
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
lifePct: 128,
|
||||||
|
mode: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// First kill
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitMode',
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 200,
|
||||||
|
mode: 0,
|
||||||
|
})
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
||||||
|
|
||||||
|
// Shaman revives Fallen -> mode = 1 (NU)
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitMode',
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 200,
|
||||||
|
mode: 1,
|
||||||
|
lifePct: 128,
|
||||||
|
})
|
||||||
|
// Killed a second time -> counts 2
|
||||||
|
h.advanceTime(500)
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitMode',
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 200,
|
||||||
|
mode: 12,
|
||||||
|
})
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(2)
|
||||||
|
|
||||||
|
// Unit 201 spawns, leaves view via UnitRemove, then a late UnitMode arrives -> not counted
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 201,
|
||||||
|
classId: 19,
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
lifePct: 128,
|
||||||
|
mode: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitRemove',
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 201,
|
||||||
|
})
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitMode',
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 201,
|
||||||
|
mode: 0,
|
||||||
|
})
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(2)
|
||||||
|
|
||||||
|
await h.tracker.flushNow()
|
||||||
|
const snap = await h.repository.loadAll()
|
||||||
|
expect(snap.tallies).toHaveLength(1)
|
||||||
|
expect(snap.tallies[0]?.difficulty).toBe(1)
|
||||||
|
expect(snap.tallies[0]?.count).toBe(2)
|
||||||
|
h.tracker.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes mercs (0x81), summons (0x7A), 0xAC ownerId units, Align !== 0 (363, 338), and neverCount (340)', async () => {
|
||||||
|
const h = setupHarness()
|
||||||
|
h.sendGameFlags(2)
|
||||||
|
|
||||||
|
// 1. 0x81 AssignMerc (mercId = 300)
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'Merc',
|
||||||
|
kind: 'assign',
|
||||||
|
ownerId: 1,
|
||||||
|
mercId: 300,
|
||||||
|
classId: 338,
|
||||||
|
})
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 1,
|
||||||
|
id: 300,
|
||||||
|
classId: 19, // even if classId were countable, Merc ownership excludes GUID 300
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
lifePct: 128,
|
||||||
|
mode: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 300, mode: 0 })
|
||||||
|
|
||||||
|
// 2. 0x7A SummonAction (mercId = 301, sticky even after unsummon action = 0)
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'Merc',
|
||||||
|
kind: 'assign',
|
||||||
|
ownerId: 1,
|
||||||
|
mercId: 301,
|
||||||
|
classId: 363,
|
||||||
|
isSummonAction: true,
|
||||||
|
summonAction: 1,
|
||||||
|
})
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 1,
|
||||||
|
id: 301,
|
||||||
|
classId: 19,
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
lifePct: 128,
|
||||||
|
mode: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 301, mode: 0 })
|
||||||
|
|
||||||
|
// 3. 0xAC UnitAssign with ownerId (id = 302)
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 1,
|
||||||
|
id: 302,
|
||||||
|
classId: 19,
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
lifePct: 128,
|
||||||
|
mode: 1,
|
||||||
|
ownerId: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 302, mode: 0 })
|
||||||
|
|
||||||
|
// 4. Align !== 0 (363 = necroskeleton, 338 = act2hire) and neverCount !== 0 (340 = boneprison1)
|
||||||
|
for (const [idx, classId] of [
|
||||||
|
[303, 363],
|
||||||
|
[304, 338],
|
||||||
|
[305, 340],
|
||||||
|
] as const) {
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 1,
|
||||||
|
id: idx,
|
||||||
|
classId,
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
lifePct: 128,
|
||||||
|
mode: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: idx, mode: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
||||||
|
await h.tracker.flushNow()
|
||||||
|
const snap = await h.repository.loadAll()
|
||||||
|
expect(snap.tallies).toHaveLength(0)
|
||||||
|
h.tracker.destroy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('KillTracker: Boss / SuperUnique Classification, Solo vs Party, Guards & Flush Retry', () => {
|
||||||
|
it('classifies Boss (monster + boss), SuperUnique (monster + superunique), and logs out-of-range superUniqueId', async () => {
|
||||||
|
const h = setupHarness()
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
h.sendGameFlags(2)
|
||||||
|
|
||||||
|
// 1. Andariel (hcIdx = 156, boss = true) -> emits monster/156 + boss/156
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 1,
|
||||||
|
id: 400,
|
||||||
|
classId: 156,
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
lifePct: 128,
|
||||||
|
mode: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 400, mode: 0 })
|
||||||
|
|
||||||
|
// 2. Pindleskin (hcIdx = 440 reanimatedhorde5, isSuperUnique = true, superUniqueId = 52) -> monster/440 + superunique/52
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 1,
|
||||||
|
id: 401,
|
||||||
|
classId: 440,
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
lifePct: 128,
|
||||||
|
mode: 1,
|
||||||
|
isSuperUnique: true,
|
||||||
|
superUniqueId: 52,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 401, mode: 0 })
|
||||||
|
|
||||||
|
// 3. Radament (hcIdx = 229 boss=true AND isSuperUnique=true, superUniqueId = 10) -> monster/229 + superunique/10 (NOT boss/229)
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 1,
|
||||||
|
id: 402,
|
||||||
|
classId: 229,
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
lifePct: 128,
|
||||||
|
mode: 1,
|
||||||
|
isSuperUnique: true,
|
||||||
|
superUniqueId: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 402, mode: 0 })
|
||||||
|
|
||||||
|
// 4. Out-of-range superUniqueId (99) -> emits monster/440 only and logs console.error
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 1,
|
||||||
|
id: 403,
|
||||||
|
classId: 440,
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
lifePct: 128,
|
||||||
|
mode: 1,
|
||||||
|
isSuperUnique: true,
|
||||||
|
superUniqueId: 99,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 403, mode: 0 })
|
||||||
|
expect(errSpy).toHaveBeenCalled()
|
||||||
|
expect(h.tracker.getStatus().lastError).toContain('99')
|
||||||
|
|
||||||
|
await h.tracker.flushNow()
|
||||||
|
const snap = await h.repository.loadAll()
|
||||||
|
const summary = snap.tallies.map((t) => `${t.kind}:${t.subject}=${t.count}`).sort()
|
||||||
|
expect(summary).toEqual([
|
||||||
|
'boss:156=1',
|
||||||
|
'monster:156=1',
|
||||||
|
'monster:229=1',
|
||||||
|
'monster:440=2',
|
||||||
|
'superunique:10=1',
|
||||||
|
'superunique:52=1',
|
||||||
|
])
|
||||||
|
|
||||||
|
errSpy.mockRestore()
|
||||||
|
h.tracker.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tracks soloCount accurately from world.party (0x5B PlayerInGame / 0x5C PlayerLeftGame)', async () => {
|
||||||
|
const h = setupHarness()
|
||||||
|
h.sendGameFlags(2)
|
||||||
|
|
||||||
|
// Self in roster (unitId = 1) -> still solo
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'Party',
|
||||||
|
kind: 'roster',
|
||||||
|
unitId: 1,
|
||||||
|
charName: 'TestSorc',
|
||||||
|
charClass: 1,
|
||||||
|
charLevel: 85,
|
||||||
|
partyId: 0xffff,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Kill #1 while solo -> count = 1, soloCount = 1
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 500, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 500, mode: 0 })
|
||||||
|
|
||||||
|
// Second player enters game (0x5B, unitId = 2) -> multiplayer
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'Party',
|
||||||
|
kind: 'roster',
|
||||||
|
unitId: 2,
|
||||||
|
charName: 'TeammatePal',
|
||||||
|
charClass: 3,
|
||||||
|
charLevel: 90,
|
||||||
|
partyId: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Kill #2 while another player is in game -> count = 2, soloCount = 1
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 501, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 501, mode: 0 })
|
||||||
|
|
||||||
|
// Second player leaves game (0x5C, unitId = 2, partyId = 0xffff, no charName) -> solo again
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'Party',
|
||||||
|
kind: 'roster',
|
||||||
|
unitId: 2,
|
||||||
|
partyId: 0xffff,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Kill #3 after other player left -> count = 3, soloCount = 2
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 502, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 502, mode: 0 })
|
||||||
|
|
||||||
|
await h.tracker.flushNow()
|
||||||
|
const snap = await h.repository.loadAll()
|
||||||
|
expect(snap.tallies).toHaveLength(1)
|
||||||
|
expect(snap.tallies[0]?.count).toBe(3)
|
||||||
|
expect(snap.tallies[0]?.soloCount).toBe(2)
|
||||||
|
h.tracker.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips counting before GameFlags, when identity is incomplete, or during replay phase, and resets per-game', async () => {
|
||||||
|
let currentIdentity: KillCharacterProfile | null = makeDefaultProfile()
|
||||||
|
const h = setupHarness({ identity: () => currentIdentity })
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
|
||||||
|
// 1. Before GameFlags -> warns once and skips
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 600, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 600, mode: 0 })
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 601, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 601, mode: 0 })
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
||||||
|
|
||||||
|
// 2. GameFlags arrives, but identity is null -> warns once and skips
|
||||||
|
currentIdentity = null
|
||||||
|
h.sendGameFlags(0)
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 602, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 602, mode: 0 })
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(2)
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
||||||
|
|
||||||
|
// 3. New GameFlags resets units from previous game so old unit 603 doesn't carry over
|
||||||
|
currentIdentity = makeDefaultProfile()
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 603, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
h.sendGameFlags(1) // new game resets units
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 603, mode: 0 })
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
||||||
|
|
||||||
|
// 4. Replay phase is ignored
|
||||||
|
h.session.loadD2CapReplay('')
|
||||||
|
expect(h.session.phase).toBe('replay')
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 604, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 604, mode: 0 })
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
||||||
|
|
||||||
|
warnSpy.mockRestore()
|
||||||
|
h.tracker.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flushes on 2000ms timer, visibilitychange(hidden), pagehide, leaving ingame, retries with exponential backoff, and isolates listener errors', async () => {
|
||||||
|
let throwInIdentity = false
|
||||||
|
const defaultProfile = makeDefaultProfile()
|
||||||
|
const h = setupHarness({
|
||||||
|
identity: () => {
|
||||||
|
if (throwInIdentity) {
|
||||||
|
throw new Error('Injected identity failure')
|
||||||
|
}
|
||||||
|
return defaultProfile
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
h.sendGameFlags(2)
|
||||||
|
|
||||||
|
// 1. Schedule 2000ms timer on first kill
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 700, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 700, mode: 0 })
|
||||||
|
expect(h.scheduler.pendingDelays()).toEqual([2000])
|
||||||
|
|
||||||
|
// Simulate 2 repository write failures -> backoff 2000ms then 4000ms
|
||||||
|
h.repository.failNextApplyCount = 2
|
||||||
|
h.scheduler.fireNext()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
||||||
|
expect(h.scheduler.pendingDelays()).toEqual([2000])
|
||||||
|
|
||||||
|
h.scheduler.fireNext()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
||||||
|
expect(h.scheduler.pendingDelays()).toEqual([4000])
|
||||||
|
|
||||||
|
// Third attempt succeeds and broadcasts 'updated'
|
||||||
|
h.scheduler.fireNext()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
||||||
|
expect(h.broadcastMessages).toHaveLength(1)
|
||||||
|
expect(h.broadcastMessages[0]?.type).toBe('updated')
|
||||||
|
|
||||||
|
// 2. visibilitychange('hidden') triggers immediate flush
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 701, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 701, mode: 0 })
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
||||||
|
h.windowLike.dispatchVisibilityChange('hidden')
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
||||||
|
expect(h.broadcastMessages).toHaveLength(2)
|
||||||
|
|
||||||
|
// 3. pagehide triggers immediate flush
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 702, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 702, mode: 0 })
|
||||||
|
h.windowLike.dispatchPageHide()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
||||||
|
expect(h.broadcastMessages).toHaveLength(3)
|
||||||
|
|
||||||
|
// 4. Listener error isolation: throwing inside KillTracker never breaks subsequent session listeners
|
||||||
|
let downstreamListenerCalled = false
|
||||||
|
h.session.onServerEvent(() => {
|
||||||
|
downstreamListenerCalled = true
|
||||||
|
})
|
||||||
|
throwInIdentity = true
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 703, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
expect(() => {
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 703, mode: 0 })
|
||||||
|
}).not.toThrow()
|
||||||
|
expect(downstreamListenerCalled).toBe(true)
|
||||||
|
expect(h.tracker.getStatus().lastError).toContain('Injected identity failure')
|
||||||
|
|
||||||
|
// 5. Leaving ingame flushes any remaining kills
|
||||||
|
throwInIdentity = false
|
||||||
|
h.adapter.emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 1, id: 704, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
||||||
|
})
|
||||||
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 704, mode: 0 })
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
||||||
|
await h.session.disconnect('test exit')
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
||||||
|
|
||||||
|
const snap = await h.repository.loadAll()
|
||||||
|
expect(snap.tallies[0]?.count).toBe(4)
|
||||||
|
|
||||||
|
errSpy.mockRestore()
|
||||||
|
h.tracker.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolveSessionIdentity returns null when any of (host, realm, account, charName) is missing', () => {
|
||||||
|
const session = new OnlineSession()
|
||||||
|
expect(resolveSessionIdentity(session)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -280,6 +280,10 @@ describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => {
|
||||||
expect(store.get().inspectorOpen).toBe(true)
|
expect(store.get().inspectorOpen).toBe(true)
|
||||||
expect(inspectorSpy).toHaveBeenCalledWith(true)
|
expect(inspectorSpy).toHaveBeenCalledWith(true)
|
||||||
|
|
||||||
|
// Kill Stats link i18n
|
||||||
|
expect(tToolbar('toolbar.killStats.open', 'zh')).toBe('击杀统计')
|
||||||
|
expect(tToolbar('toolbar.killStats.open', 'en')).toBe('Kill Stats')
|
||||||
|
|
||||||
toolbar.destroy()
|
toolbar.destroy()
|
||||||
setLang('zh')
|
setLang('zh')
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import * as fs from 'node:fs'
|
||||||
|
import { MountedArchives } from '../../src/common/mpq/mount.ts'
|
||||||
|
import { MpqArchive } from '../../src/common/mpq/archive.ts'
|
||||||
|
import { fileSource } from '../../src/baker/io/file-source.ts'
|
||||||
|
import { cell, parseTable, type D2Table } from '../../src/common/world/acts.ts'
|
||||||
|
import { getMonsterKindByClassId } from '../../src/common/units/monsters-data.ts'
|
||||||
|
import { resolveTblKey } from '../../src/common/data/canonical-tbl-strings.ts'
|
||||||
|
import {
|
||||||
|
MONSTATS_ALIGN_NONZERO_HCIDX,
|
||||||
|
MONSTATS_MAX_HCIDX,
|
||||||
|
MONSTATS_NEVER_COUNT_HCIDX,
|
||||||
|
isCountableBossClass,
|
||||||
|
isCountableKillClass,
|
||||||
|
} from '../../src/common/units/monster-kill-classes.ts'
|
||||||
|
|
||||||
|
const d2Dir = fs.existsSync('samples/d2/Patch_D2.mpq')
|
||||||
|
? 'samples/d2'
|
||||||
|
: fs.existsSync('/usr/local/google/home/taodao/d2-data/Patch_D2.mpq')
|
||||||
|
? '/usr/local/google/home/taodao/d2-data'
|
||||||
|
: null
|
||||||
|
|
||||||
|
describe('1.13c Monster Kill Classes (MonStats.txt Ground Truth)', () => {
|
||||||
|
it('has exact expected sizes for Align !== 0 (115) and neverCount !== 0 (77)', () => {
|
||||||
|
expect(MONSTATS_ALIGN_NONZERO_HCIDX.size).toBe(115)
|
||||||
|
expect(MONSTATS_NEVER_COUNT_HCIDX.size).toBe(77)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('spot-checks friendly/summon, neverCount, and countable hostile classes', () => {
|
||||||
|
// 363 = necroskeleton (Align=1, neverCount=1) -> excluded
|
||||||
|
expect(MONSTATS_ALIGN_NONZERO_HCIDX.has(363)).toBe(true)
|
||||||
|
expect(MONSTATS_NEVER_COUNT_HCIDX.has(363)).toBe(true)
|
||||||
|
expect(isCountableKillClass(363)).toBe(false)
|
||||||
|
|
||||||
|
// 338 = act2hire (Align=1) -> excluded
|
||||||
|
expect(MONSTATS_ALIGN_NONZERO_HCIDX.has(338)).toBe(true)
|
||||||
|
expect(isCountableKillClass(338)).toBe(false)
|
||||||
|
|
||||||
|
// 340 = boneprison1 (neverCount=1) -> excluded
|
||||||
|
expect(MONSTATS_NEVER_COUNT_HCIDX.has(340)).toBe(true)
|
||||||
|
expect(isCountableKillClass(340)).toBe(false)
|
||||||
|
|
||||||
|
// 156 = andariel, 544 = baalcrab, 19 = fallen1 -> countable
|
||||||
|
expect(getMonsterKindByClassId(19)?.id).toBe('fallen1')
|
||||||
|
expect(isCountableKillClass(19)).toBe(true)
|
||||||
|
expect(isCountableKillClass(156)).toBe(true)
|
||||||
|
expect(isCountableBossClass(156)).toBe(true)
|
||||||
|
expect(isCountableKillClass(544)).toBe(true)
|
||||||
|
expect(isCountableBossClass(544)).toBe(true)
|
||||||
|
|
||||||
|
// Out-of-range / non-integer -> false
|
||||||
|
expect(isCountableKillClass(-1)).toBe(false)
|
||||||
|
expect(isCountableKillClass(734)).toBe(false)
|
||||||
|
expect(isCountableKillClass(1.5)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves TBL names in both CHI and ENG for all 559 countable monster rows', () => {
|
||||||
|
let countable = 0
|
||||||
|
for (let hcIdx = 0; hcIdx <= MONSTATS_MAX_HCIDX; hcIdx++) {
|
||||||
|
if (!isCountableKillClass(hcIdx)) continue
|
||||||
|
countable++
|
||||||
|
const kind = getMonsterKindByClassId(hcIdx)
|
||||||
|
expect(kind).toBeDefined()
|
||||||
|
expect(kind!.nameKey.length).toBeGreaterThan(0)
|
||||||
|
const zh = resolveTblKey(kind!.nameKey, 'CHI')
|
||||||
|
const en = resolveTblKey(kind!.nameKey, 'ENG')
|
||||||
|
expect(zh, `Missing CHI tbl key ${kind!.nameKey} for hcIdx ${hcIdx}`).toBeDefined()
|
||||||
|
expect(en, `Missing ENG tbl key ${kind!.nameKey} for hcIdx ${hcIdx}`).toBeDefined()
|
||||||
|
expect(en!.trim().length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
expect(countable).toBe(559)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.skipIf(!d2Dir)('MPQ MonStats.txt row-by-row parity', () => {
|
||||||
|
let monTable: D2Table
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const archives = new MountedArchives()
|
||||||
|
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||||
|
archives.add(name, await MpqArchive.open(await fileSource(`${d2Dir}/${name}`)))
|
||||||
|
}
|
||||||
|
const bytes = await archives.read('data\\global\\excel\\MonStats.txt')
|
||||||
|
monTable = parseTable(bytes)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches Align !== 0 (115 rows) and neverCount !== 0 (77 rows) row-by-row against MPQ MonStats.txt', () => {
|
||||||
|
const mpqAlign = new Set<number>()
|
||||||
|
const mpqNeverCount = new Set<number>()
|
||||||
|
let validRows = 0
|
||||||
|
|
||||||
|
for (const row of monTable.rows) {
|
||||||
|
const id = cell(monTable, row, 'Id').trim()
|
||||||
|
const hcIdxRaw = cell(monTable, row, 'hcIdx').trim()
|
||||||
|
if (!id || hcIdxRaw === '') continue
|
||||||
|
validRows++
|
||||||
|
const hcIdx = Number(hcIdxRaw)
|
||||||
|
const align = Number(cell(monTable, row, 'Align').trim() || '0')
|
||||||
|
const neverCount = Number(cell(monTable, row, 'neverCount').trim() || '0')
|
||||||
|
const killable = (cell(monTable, row, 'killable').trim() || '0') !== '0'
|
||||||
|
|
||||||
|
if (align !== 0) mpqAlign.add(hcIdx)
|
||||||
|
if (neverCount !== 0) mpqNeverCount.add(hcIdx)
|
||||||
|
|
||||||
|
const expectedCountable = killable && align === 0 && neverCount === 0
|
||||||
|
expect(isCountableKillClass(hcIdx)).toBe(expectedCountable)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(validRows).toBe(734)
|
||||||
|
expect([...MONSTATS_ALIGN_NONZERO_HCIDX].sort((a, b) => a - b)).toEqual(
|
||||||
|
[...mpqAlign].sort((a, b) => a - b),
|
||||||
|
)
|
||||||
|
expect([...MONSTATS_NEVER_COUNT_HCIDX].sort((a, b) => a - b)).toEqual(
|
||||||
|
[...mpqNeverCount].sort((a, b) => a - b),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -610,9 +610,10 @@ describe('Tier 1 — Feature Coverage: Module Split & Single Entry Consolidation
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('F8.3: configures vite.config.ts with play.html as the sole rollup input', () => {
|
it('F8.3: configures vite.config.ts with play.html and stats.html rollup inputs and zero legacy HTML inputs', () => {
|
||||||
const viteCfg = readRepoFile('vite.config.ts')
|
const viteCfg = readRepoFile('vite.config.ts')
|
||||||
expect(viteCfg).toContain('play.html')
|
expect(viteCfg).toContain('play.html')
|
||||||
|
expect(viteCfg).toContain('stats.html')
|
||||||
for (const legacyHtml of LEGACY_HTML_FILES) {
|
for (const legacyHtml of LEGACY_HTML_FILES) {
|
||||||
expect(viteCfg).not.toContain(legacyHtml)
|
expect(viteCfg).not.toContain(legacyHtml)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ describe('Tier 4: Real-World End-to-End Application Scenarios', () => {
|
||||||
}
|
}
|
||||||
}, 180_000)
|
}, 180_000)
|
||||||
|
|
||||||
it('Scenario 4: Unified play.html Vite Production Bundle Build Verification (R2)', async () => {
|
it('Scenario 4: Unified play.html & stats.html Vite Production Bundle Build Verification (R2)', async () => {
|
||||||
const tmpOutDir = mkdtempSync(join(tmpdir(), 'd2-vite-e2e-build-'))
|
const tmpOutDir = mkdtempSync(join(tmpdir(), 'd2-vite-e2e-build-'))
|
||||||
try {
|
try {
|
||||||
const viteBin = resolve(ROOT_DIR, 'node_modules/.bin/vite')
|
const viteBin = resolve(ROOT_DIR, 'node_modules/.bin/vite')
|
||||||
|
|
@ -228,6 +228,7 @@ describe('Tier 4: Real-World End-to-End Application Scenarios', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(existsSync(join(tmpOutDir, 'play.html'))).toBe(true)
|
expect(existsSync(join(tmpOutDir, 'play.html'))).toBe(true)
|
||||||
|
expect(existsSync(join(tmpOutDir, 'stats.html'))).toBe(true)
|
||||||
for (const legacyHtml of LEGACY_HTML_FILES) {
|
for (const legacyHtml of LEGACY_HTML_FILES) {
|
||||||
expect(
|
expect(
|
||||||
existsSync(join(tmpOutDir, legacyHtml)),
|
existsSync(join(tmpOutDir, legacyHtml)),
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('registers local @font-face D2Exocet and D2Formal436 across HTML pages with zero external CDN links', () => {
|
it('registers local @font-face D2Exocet and D2Formal436 across HTML pages with zero external CDN links', () => {
|
||||||
const htmlFiles = ['play.html']
|
const htmlFiles = ['play.html', 'stats.html']
|
||||||
for (const file of htmlFiles) {
|
for (const file of htmlFiles) {
|
||||||
const content = readFileSync(join(process.cwd(), file), 'utf8')
|
const content = readFileSync(join(process.cwd(), file), 'utf8')
|
||||||
expect(content, `${file} must declare D2Exocet @font-face`).toMatch(/font-family:\s*['"]D2Exocet['"]/)
|
expect(content, `${file} must declare D2Exocet @font-face`).toMatch(/font-family:\s*['"]D2Exocet['"]/)
|
||||||
|
|
@ -598,16 +598,15 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
|
|
||||||
// Check that multi-word English labels use font6 and fit strictly within their stone recesses (95px for resistances x=175..269)
|
// Check that multi-word English labels use font6 and fit strictly within their stone recesses (95px for resistances x=175..269)
|
||||||
const compactLabels = [
|
const compactLabels = [
|
||||||
'Left Attack',
|
'Attack',
|
||||||
'Right Attack',
|
|
||||||
'Damage',
|
'Damage',
|
||||||
'Stat Points',
|
'Stat Points',
|
||||||
'Remaining',
|
'Remaining',
|
||||||
'Fire Resistance',
|
'Fire',
|
||||||
'Cold Resistance',
|
'Cold',
|
||||||
'Lightning',
|
'Lightning',
|
||||||
|
'Poison',
|
||||||
'Resistance',
|
'Resistance',
|
||||||
'Poison Resistance',
|
|
||||||
]
|
]
|
||||||
for (const labelText of compactLabels) {
|
for (const labelText of compactLabels) {
|
||||||
const match = drawnLabels.find(d => d.text === labelText)
|
const match = drawnLabels.find(d => d.text === labelText)
|
||||||
|
|
@ -635,8 +634,8 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
expect(drawnLabels.find(d => d.text === 'Level')?.x).toBe(80 + 33)
|
expect(drawnLabels.find(d => d.text === 'Level')?.x).toBe(80 + 33)
|
||||||
expect(drawnLabels.find(d => d.text === 'Experience')?.x).toBe(80 + 124)
|
expect(drawnLabels.find(d => d.text === 'Experience')?.x).toBe(80 + 124)
|
||||||
expect(drawnLabels.find(d => d.text === 'Next Level')?.x).toBe(80 + 252)
|
expect(drawnLabels.find(d => d.text === 'Next Level')?.x).toBe(80 + 252)
|
||||||
expect(drawnLabels.find(d => d.text === 'Fire Resistance')?.x).toBe(80 + 222)
|
expect(drawnLabels.find(d => d.text === 'Fire')?.x).toBe(80 + 222)
|
||||||
expect(drawnLabels.find(d => d.text === 'Fire Resistance')?.align).toBe('center')
|
expect(drawnLabels.find(d => d.text === 'Fire')?.align).toBe('center')
|
||||||
expect(drawnLabels.find(d => d.text === 'Strength')?.y).toBe(60 + 94)
|
expect(drawnLabels.find(d => d.text === 'Strength')?.y).toBe(60 + 94)
|
||||||
expect(drawnLabels.find(d => d.text === 'Dexterity')?.y).toBe(60 + 156)
|
expect(drawnLabels.find(d => d.text === 'Dexterity')?.y).toBe(60 + 156)
|
||||||
expect(drawnLabels.find(d => d.text === 'Vitality')?.y).toBe(60 + 242)
|
expect(drawnLabels.find(d => d.text === 'Vitality')?.y).toBe(60 + 242)
|
||||||
|
|
@ -664,7 +663,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
},
|
},
|
||||||
spyFont,
|
spyFont,
|
||||||
)
|
)
|
||||||
for (const zhRes of ['火焰抗性', '冰冷抗性', '闪电抗性', '毒素抗性']) {
|
for (const zhRes of ['火焰抵抗力', '冰冷抵抗力', '閃電抵抗力', '毒素抵抗力']) {
|
||||||
const match = drawnLabels.find(d => d.text === zhRes)
|
const match = drawnLabels.find(d => d.text === zhRes)
|
||||||
expect(match?.x).toBe(80 + 222)
|
expect(match?.x).toBe(80 + 222)
|
||||||
expect(match?.align).toBe('center')
|
expect(match?.align).toBe('center')
|
||||||
|
|
@ -740,8 +739,10 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'),
|
readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'),
|
||||||
)
|
)
|
||||||
const renderer = new D2FontRenderer()
|
const renderer = new D2FontRenderer()
|
||||||
|
const mockAtlas16 = { width: 512, height: 512, tag: 'font16-atlas' } as unknown as HTMLImageElement
|
||||||
const mockAtlas10 = { width: 512, height: 512, tag: 'fontexocet10-atlas' } as unknown as HTMLImageElement
|
const mockAtlas10 = { width: 512, height: 512, tag: 'fontexocet10-atlas' } as unknown as HTMLImageElement
|
||||||
const mockAtlas8 = { width: 256, height: 256, tag: 'font8-atlas' } as unknown as HTMLImageElement
|
const mockAtlas8 = { width: 256, height: 256, tag: 'font8-atlas' } as unknown as HTMLImageElement
|
||||||
|
renderer.setFontAtlas('font16', metricsJson.font16, mockAtlas16)
|
||||||
renderer.setFontAtlas('fontexocet10', metricsJson.fontexocet10, mockAtlas10)
|
renderer.setFontAtlas('fontexocet10', metricsJson.fontexocet10, mockAtlas10)
|
||||||
renderer.setFontAtlas('font8', metricsJson.font8, mockAtlas8)
|
renderer.setFontAtlas('font8', metricsJson.font8, mockAtlas8)
|
||||||
|
|
||||||
|
|
@ -844,12 +845,12 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
worldPanels.drawItemTooltip(mockCtx, { item: rareDiadem, x: 560, y: 220 }, renderer, false)
|
worldPanels.drawItemTooltip(mockCtx, { item: rareDiadem, x: 560, y: 220 }, renderer, false)
|
||||||
// Every character in ENG tooltip must be blitted from DC6 fontexocet10 / font8 atlases (zero fillText calls)
|
// Every character in ENG tooltip must be blitted from DC6 font16 / fontexocet10 / font8 atlases (zero fillText calls)
|
||||||
expect(fillTextCalls).toBe(0)
|
expect(fillTextCalls).toBe(0)
|
||||||
expect(drawImageCalls.length).toBeGreaterThan(150)
|
expect(drawImageCalls.length).toBeGreaterThan(150)
|
||||||
expect(worldPanels.lastTooltipRect).not.toBeNull()
|
expect(worldPanels.lastTooltipRect).not.toBeNull()
|
||||||
expect(worldPanels.lastTooltipRect!.boxW).toBeGreaterThan(
|
expect(worldPanels.lastTooltipRect!.boxW).toBeGreaterThan(
|
||||||
renderer.measureText('+20% Faster Cast Rate (Lightning Resist +40%) (Fire Resist +35%)', 'font8'),
|
renderer.measureText('+20% Faster Cast Rate (Lightning Resist +40%) (Fire Resist +35%)', 'font16'),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,831 @@
|
||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* Headless Chrome CDP End-to-End Browser Verification for Kill Statistics:
|
||||||
|
* - Verifies `play.html` toolbar entry (`#d2-toolbar-killstats` -> `stats.html`, `target="_blank"`)
|
||||||
|
* - Drives `OnlineSession` + `KillTracker` in `play.html` with simulated D2GS `ServerEvent`s:
|
||||||
|
* - Solo kills: Andariel (`classId: 156`, double death event `0x08`+`0x09`), Pindleskin (`classId: 440`, `superUniqueId: 52`), Fallen (`classId: 19`)
|
||||||
|
* - Ignored non-hostile deaths: Mercenary (`0x81`), player summon (`ownerId`), friendly `Align !== 0` (`classId: 363`)
|
||||||
|
* - Party kill: `RosterUpdate` (`0x5B`) + Fallen (`classId: 19`)
|
||||||
|
* - Opens `stats.html` in a second tab in the same browser context:
|
||||||
|
* - Verifies local `@font-face` (`D2Exocet`, `D2Formal436`) and isolated `100vh` scroll containers
|
||||||
|
* - Verifies Account -> Character tree, Difficulty Summary Cards, 86-row Boss/SuperUnique Board, and Monster Type Table
|
||||||
|
* - Verifies Solo-Only toggle (`4` -> `3` -> `4`) and `中 / EN` language toggle
|
||||||
|
* - Verifies real-time cross-tab `BroadcastChannel('d2web-kill-stats')` auto-refresh (`4` -> `5` without reload)
|
||||||
|
* - Verifies G-counter JSON export + idempotent re-import via `DOM.setFileInputFiles`
|
||||||
|
* - Verifies tampered JSON (`soloCount > count`) rejection with `[role="alert"]` error banner
|
||||||
|
* - Verifies Character Deletion (`window.confirm` dialog) and empty state rendering
|
||||||
|
* - Verifies zero console errors and zero uncaught page exceptions across both tabs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { dirname, join, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { createServer, type ViteDevServer } from 'vite'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ROOT_DIR = resolve(__dirname, '..')
|
||||||
|
|
||||||
|
const CHROME_CANDIDATES = [
|
||||||
|
join(process.env.HOME ?? '', '.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'),
|
||||||
|
'/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome',
|
||||||
|
'/usr/bin/google-chrome',
|
||||||
|
]
|
||||||
|
|
||||||
|
function findChromeBinary(): string {
|
||||||
|
for (const cand of CHROME_CANDIDATES) {
|
||||||
|
if (cand && existsSync(cand)) return cand
|
||||||
|
}
|
||||||
|
throw new Error(`No headless Chrome binary found in candidates: ${CHROME_CANDIDATES.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOutDir(): string {
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i]!
|
||||||
|
if (arg === '--out' && args[i + 1]) return resolve(args[i + 1]!)
|
||||||
|
if (arg.startsWith('--out=')) return resolve(arg.slice('--out='.length))
|
||||||
|
}
|
||||||
|
return resolve(ROOT_DIR, 'scratch/verify-kill-stats')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(cond: unknown, message: string): asserts cond {
|
||||||
|
if (!cond) {
|
||||||
|
throw new Error(`Assertion failed: ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CdpSession {
|
||||||
|
private readonly ws: WebSocket
|
||||||
|
private msgId = 0
|
||||||
|
private readonly pending = new Map<
|
||||||
|
number,
|
||||||
|
{ readonly resolve: (val: any) => void; readonly reject: (err: Error) => void }
|
||||||
|
>()
|
||||||
|
readonly consoleErrors: string[] = []
|
||||||
|
readonly pageExceptions: string[] = []
|
||||||
|
readonly dialogsOpened: Array<{ readonly type: string; readonly message: string }> = []
|
||||||
|
autoAcceptDialogs = true
|
||||||
|
|
||||||
|
private constructor(ws: WebSocket) {
|
||||||
|
this.ws = ws
|
||||||
|
this.ws.onmessage = (ev) => {
|
||||||
|
const msg = JSON.parse(String(ev.data))
|
||||||
|
if (typeof msg.id === 'number') {
|
||||||
|
const entry = this.pending.get(msg.id)
|
||||||
|
if (entry) {
|
||||||
|
this.pending.delete(msg.id)
|
||||||
|
if (msg.error) {
|
||||||
|
entry.reject(new Error(msg.error.message || JSON.stringify(msg.error)))
|
||||||
|
} else {
|
||||||
|
entry.resolve(msg.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.method === 'Runtime.consoleAPICalled') {
|
||||||
|
const type = msg.params?.type
|
||||||
|
if (type === 'error') {
|
||||||
|
const text = (msg.params?.args ?? [])
|
||||||
|
.map((a: any) => a.value ?? a.description ?? '')
|
||||||
|
.join(' ')
|
||||||
|
if (!text.includes('favicon.ico') && !text.includes('404')) {
|
||||||
|
this.consoleErrors.push(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.method === 'Runtime.exceptionThrown') {
|
||||||
|
const details = msg.params?.exceptionDetails
|
||||||
|
const desc =
|
||||||
|
details?.exception?.description ?? details?.text ?? JSON.stringify(details)
|
||||||
|
this.pageExceptions.push(String(desc))
|
||||||
|
} else if (msg.method === 'Page.javascriptDialogOpening') {
|
||||||
|
this.dialogsOpened.push({
|
||||||
|
type: String(msg.params?.type ?? ''),
|
||||||
|
message: String(msg.params?.message ?? ''),
|
||||||
|
})
|
||||||
|
void this.send('Page.handleJavaScriptDialog', { accept: this.autoAcceptDialogs })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(wsUrl: string): Promise<CdpSession> {
|
||||||
|
const ws = new WebSocket(wsUrl)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
ws.onopen = () => resolve()
|
||||||
|
ws.onerror = (err) => reject(new Error(`CDP WebSocket error: ${String(err)}`))
|
||||||
|
})
|
||||||
|
const cdp = new CdpSession(ws)
|
||||||
|
await cdp.send('Runtime.enable')
|
||||||
|
await cdp.send('Page.enable')
|
||||||
|
return cdp
|
||||||
|
}
|
||||||
|
|
||||||
|
send(method: string, params: Record<string, unknown> = {}): Promise<any> {
|
||||||
|
const id = ++this.msgId
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject })
|
||||||
|
this.ws.send(JSON.stringify({ id, method, params }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate<T = any>(expression: string): Promise<T> {
|
||||||
|
const res = await this.send('Runtime.evaluate', {
|
||||||
|
expression,
|
||||||
|
returnByValue: true,
|
||||||
|
awaitPromise: true,
|
||||||
|
})
|
||||||
|
if (res?.exceptionDetails) {
|
||||||
|
const desc =
|
||||||
|
res.exceptionDetails.exception?.description ??
|
||||||
|
res.exceptionDetails.text ??
|
||||||
|
JSON.stringify(res.exceptionDetails)
|
||||||
|
throw new Error(`CDP evaluate exception: ${String(desc)}`)
|
||||||
|
}
|
||||||
|
return res?.result?.value as T
|
||||||
|
}
|
||||||
|
|
||||||
|
async setFileInputFiles(selector: string, filePaths: readonly string[]): Promise<void> {
|
||||||
|
await this.send('DOM.enable')
|
||||||
|
const { root } = await this.send('DOM.getDocument', { depth: -1 })
|
||||||
|
const { nodeId } = await this.send('DOM.querySelector', {
|
||||||
|
nodeId: root.nodeId,
|
||||||
|
selector,
|
||||||
|
})
|
||||||
|
if (!nodeId) {
|
||||||
|
throw new Error(`DOM.querySelector failed to find file input: ${selector}`)
|
||||||
|
}
|
||||||
|
await this.send('DOM.setFileInputFiles', {
|
||||||
|
nodeId,
|
||||||
|
files: [...filePaths],
|
||||||
|
})
|
||||||
|
await this.evaluate(`(() => {
|
||||||
|
const el = document.querySelector(${JSON.stringify(selector)});
|
||||||
|
if (el) el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
})()`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async captureScreenshot(outPath: string): Promise<number> {
|
||||||
|
const shot = await this.send('Page.captureScreenshot', { format: 'png' })
|
||||||
|
const data = shot?.data
|
||||||
|
if (typeof data !== 'string' || data.length === 0) {
|
||||||
|
throw new Error('Failed to capture PNG screenshot via CDP')
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(data, 'base64')
|
||||||
|
mkdirSync(dirname(outPath), { recursive: true })
|
||||||
|
writeFileSync(outPath, buf)
|
||||||
|
return buf.byteLength
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.ws.close()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNewPageTarget(
|
||||||
|
cdpPort: string,
|
||||||
|
targetUrl: string,
|
||||||
|
): Promise<{ readonly id: string; readonly webSocketDebuggerUrl: string }> {
|
||||||
|
const res = await fetch(
|
||||||
|
`http://127.0.0.1:${cdpPort}/json/new?${encodeURIComponent(targetUrl)}`,
|
||||||
|
{ method: 'PUT' },
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to open new tab for ${targetUrl}: HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return (await res.json()) as { id: string; webSocketDebuggerUrl: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(): Promise<void> {
|
||||||
|
const chromeBin = findChromeBinary()
|
||||||
|
const outDir = parseOutDir()
|
||||||
|
mkdirSync(outDir, { recursive: true })
|
||||||
|
|
||||||
|
const publicDir = process.env.D2W_PUBLIC_DIR
|
||||||
|
? resolve(process.env.D2W_PUBLIC_DIR)
|
||||||
|
: resolve(ROOT_DIR, 'public')
|
||||||
|
const tilesIndexPath = join(publicDir, 'act-packs/tiles/index.json')
|
||||||
|
if (!existsSync(tilesIndexPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing required packed tiles at ${tilesIndexPath}. Set D2W_PUBLIC_DIR=/usr/local/google/home/taodao/diablo2-web/public`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let viteServer: ViteDevServer | null = null
|
||||||
|
const userDataDir = mkdtempSync(join(tmpdir(), 'd2-verify-kill-stats-'))
|
||||||
|
const tmpImportDir = mkdtempSync(join(tmpdir(), 'd2-verify-kill-stats-import-'))
|
||||||
|
let chromeProc: ReturnType<typeof spawn> | null = null
|
||||||
|
let playCdp: CdpSession | null = null
|
||||||
|
let statsCdp: CdpSession | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
viteServer = await createServer({
|
||||||
|
root: ROOT_DIR,
|
||||||
|
publicDir,
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 0,
|
||||||
|
strictPort: false,
|
||||||
|
watch: null,
|
||||||
|
},
|
||||||
|
logLevel: 'error',
|
||||||
|
})
|
||||||
|
await viteServer.listen()
|
||||||
|
const addr = viteServer.httpServer?.address()
|
||||||
|
const port = typeof addr === 'object' && addr ? addr.port : 0
|
||||||
|
if (!port) throw new Error('Failed to start Vite server')
|
||||||
|
|
||||||
|
const playUrl = `http://127.0.0.1:${port}/play.html`
|
||||||
|
const statsUrl = `http://127.0.0.1:${port}/stats.html`
|
||||||
|
console.log(`[verify-kill-stats] Vite server listening at http://127.0.0.1:${port}`)
|
||||||
|
|
||||||
|
chromeProc = spawn(
|
||||||
|
chromeBin,
|
||||||
|
[
|
||||||
|
'--headless=new',
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-gpu-sandbox',
|
||||||
|
'--use-angle=swiftshader',
|
||||||
|
'--use-gl=angle',
|
||||||
|
'--enable-unsafe-swiftshader',
|
||||||
|
'--ignore-gpu-blocklist',
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
'--disable-backgrounding-occluded-windows',
|
||||||
|
'--no-proxy-server',
|
||||||
|
'--password-store=basic',
|
||||||
|
'--disable-extensions',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--force-device-scale-factor=1',
|
||||||
|
'--window-size=1440,960',
|
||||||
|
`--user-data-dir=${userDataDir}`,
|
||||||
|
'--remote-debugging-port=0',
|
||||||
|
'about:blank',
|
||||||
|
],
|
||||||
|
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
||||||
|
)
|
||||||
|
|
||||||
|
let cdpPort: string | null = null
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
await sleep(150)
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8')
|
||||||
|
const firstLine = raw.trim().split('\n')[0]
|
||||||
|
if (firstLine) {
|
||||||
|
cdpPort = firstLine
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// wait
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cdpPort) throw new Error('Chrome failed to start DevToolsActivePort')
|
||||||
|
|
||||||
|
let pageTarget: { readonly url: string; readonly webSocketDebuggerUrl: string } | null = null
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
await sleep(150)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${cdpPort}/json`)
|
||||||
|
const list = (await res.json()) as any[]
|
||||||
|
const pages = list.filter((p) => p.type === 'page')
|
||||||
|
if (pages.length > 0 && pages[0]) {
|
||||||
|
pageTarget = pages[0]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// wait
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!pageTarget) throw new Error('Failed to attach to page target in headless Chrome')
|
||||||
|
|
||||||
|
playCdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl)
|
||||||
|
await playCdp.send('Page.navigate', { url: playUrl })
|
||||||
|
|
||||||
|
// 1. Wait for play.html boot and verify #d2-toolbar-killstats
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
await sleep(200)
|
||||||
|
const ready = await playCdp.evaluate<boolean>('Boolean(window.__d2PlayApp && window.__d2PlayApp.ready)')
|
||||||
|
if (ready) break
|
||||||
|
}
|
||||||
|
const playReady = await playCdp.evaluate<boolean>('Boolean(window.__d2PlayApp && window.__d2PlayApp.ready)')
|
||||||
|
assert(playReady, 'play.html __d2PlayApp failed to reach ready=true')
|
||||||
|
|
||||||
|
const toolbarLink = await playCdp.evaluate<{
|
||||||
|
exists: boolean
|
||||||
|
href: string
|
||||||
|
target: string
|
||||||
|
text: string
|
||||||
|
}>(`(() => {
|
||||||
|
const el = document.getElementById('d2-toolbar-killstats');
|
||||||
|
return {
|
||||||
|
exists: Boolean(el),
|
||||||
|
href: el?.getAttribute('href') ?? '',
|
||||||
|
target: el?.getAttribute('target') ?? '',
|
||||||
|
text: el?.textContent?.trim() ?? '',
|
||||||
|
};
|
||||||
|
})()`)
|
||||||
|
assert(toolbarLink.exists, '#d2-toolbar-killstats must exist in play.html toolbar')
|
||||||
|
assert(toolbarLink.href.endsWith('stats.html'), `Expected href ending with stats.html, got ${toolbarLink.href}`)
|
||||||
|
assert(toolbarLink.target === '_blank', `Expected target="_blank", got ${toolbarLink.target}`)
|
||||||
|
console.log(`[verify-kill-stats] Verified play.html toolbar link: href="${toolbarLink.href}" target="${toolbarLink.target}" text="${toolbarLink.text}"`)
|
||||||
|
|
||||||
|
// 2. Drive OnlineSession + KillTracker in play.html with simulated ServerEvents
|
||||||
|
await playCdp.evaluate(`(async () => {
|
||||||
|
const app = window.__d2PlayApp;
|
||||||
|
const session = app.session;
|
||||||
|
session.loginHostValue = 'www.laiseek.xyz';
|
||||||
|
session.usernameValue = 'myacct';
|
||||||
|
session.selectedRealmValue = { title: 'D2CS', description: 'Realm' };
|
||||||
|
session.selectedCharValue = 'HeroSorc';
|
||||||
|
session.charactersValue = [
|
||||||
|
{
|
||||||
|
name: 'HeroSorc',
|
||||||
|
charClass: 1,
|
||||||
|
level: 85,
|
||||||
|
hardcore: true,
|
||||||
|
expansion: true,
|
||||||
|
ladder: true,
|
||||||
|
rawFlags: 0,
|
||||||
|
act: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let emitServerEvent = null;
|
||||||
|
const fakeAdapter = {
|
||||||
|
isConnected: true,
|
||||||
|
stats: { packetsIn: 0, packetsOut: 0, bytesIn: 0, bytesOut: 0, rttMs: 12, unknownPackets: 0 },
|
||||||
|
connect: async () => {},
|
||||||
|
disconnect: () => {},
|
||||||
|
send: () => {},
|
||||||
|
onEvent: (cb) => {
|
||||||
|
emitServerEvent = cb;
|
||||||
|
return () => { emitServerEvent = null; };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
session.attachGameServer(fakeAdapter);
|
||||||
|
window.__d2EmitTestServerEvent = (ev) => emitServerEvent && emitServerEvent(ev);
|
||||||
|
|
||||||
|
const emit = (ev) => emitServerEvent(ev);
|
||||||
|
|
||||||
|
// GameFlags: Hell difficulty (2)
|
||||||
|
emit({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
|
||||||
|
// Local player self assignment (id = 1)
|
||||||
|
emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
isSelf: true,
|
||||||
|
unit: {
|
||||||
|
unitType: 'player',
|
||||||
|
id: 1,
|
||||||
|
classId: 1,
|
||||||
|
name: 'HeroSorc',
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
mode: 1,
|
||||||
|
lifePct: 128,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Solo Kill #1: Andariel (classId 156, boss) with both MONMODE_DEATH (0) and MONMODE_DEAD (12) -> must count ONCE
|
||||||
|
emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 101,
|
||||||
|
classId: 156,
|
||||||
|
x: 5010,
|
||||||
|
y: 5010,
|
||||||
|
mode: 1,
|
||||||
|
lifePct: 128,
|
||||||
|
isBoss: true,
|
||||||
|
isSuperUnique: false,
|
||||||
|
superUniqueId: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emit({ type: 'UnitMode', unitType: 'monster', id: 101, mode: 0, x: 5010, y: 5010, lifePct: 0 });
|
||||||
|
emit({ type: 'UnitMode', unitType: 'monster', id: 101, mode: 12, x: 5010, y: 5010, lifePct: 0 });
|
||||||
|
|
||||||
|
// Solo Kill #2: Pindleskin (classId 440, superUniqueId 52)
|
||||||
|
emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 102,
|
||||||
|
classId: 440,
|
||||||
|
x: 5020,
|
||||||
|
y: 5020,
|
||||||
|
mode: 1,
|
||||||
|
lifePct: 128,
|
||||||
|
isBoss: true,
|
||||||
|
isSuperUnique: true,
|
||||||
|
superUniqueId: 52,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emit({ type: 'UnitMode', unitType: 'monster', id: 102, mode: 12, x: 5020, y: 5020, lifePct: 0 });
|
||||||
|
|
||||||
|
// Solo Kill #3: Fallen (classId 19 = fallen1)
|
||||||
|
emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 103,
|
||||||
|
classId: 19,
|
||||||
|
x: 5030,
|
||||||
|
y: 5030,
|
||||||
|
mode: 1,
|
||||||
|
lifePct: 128,
|
||||||
|
isBoss: false,
|
||||||
|
isSuperUnique: false,
|
||||||
|
superUniqueId: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emit({ type: 'UnitMode', unitType: 'monster', id: 103, mode: 12, x: 5030, y: 5030, lifePct: 0 });
|
||||||
|
|
||||||
|
// Ignored #1: Mercenary (0x81 Merc assign)
|
||||||
|
emit({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 201, classId: 271, nameId: 1 });
|
||||||
|
emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 201,
|
||||||
|
classId: 271,
|
||||||
|
x: 5005,
|
||||||
|
y: 5005,
|
||||||
|
mode: 1,
|
||||||
|
lifePct: 128,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emit({ type: 'UnitMode', unitType: 'monster', id: 201, mode: 12, x: 5005, y: 5005, lifePct: 0 });
|
||||||
|
|
||||||
|
// Ignored #2: Player summon (ownerId = 1)
|
||||||
|
emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 202,
|
||||||
|
classId: 19,
|
||||||
|
x: 5006,
|
||||||
|
y: 5006,
|
||||||
|
mode: 1,
|
||||||
|
lifePct: 128,
|
||||||
|
ownerId: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emit({ type: 'UnitMode', unitType: 'monster', id: 202, mode: 12, x: 5006, y: 5006, lifePct: 0 });
|
||||||
|
|
||||||
|
// Ignored #3: Friendly Align !== 0 unit (classId 363 = necroskeleton)
|
||||||
|
emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 203,
|
||||||
|
classId: 363,
|
||||||
|
x: 5007,
|
||||||
|
y: 5007,
|
||||||
|
mode: 1,
|
||||||
|
lifePct: 128,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emit({ type: 'UnitMode', unitType: 'monster', id: 203, mode: 12, x: 5007, y: 5007, lifePct: 0 });
|
||||||
|
|
||||||
|
// Party Kill #4: Add another player to roster, then kill Fallen (id 104, classId 19)
|
||||||
|
emit({
|
||||||
|
type: 'Party',
|
||||||
|
kind: 'roster',
|
||||||
|
unitId: 2,
|
||||||
|
charName: 'PartyPaladin',
|
||||||
|
charClass: 3,
|
||||||
|
level: 80,
|
||||||
|
partyId: 1,
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 104,
|
||||||
|
classId: 19,
|
||||||
|
x: 5040,
|
||||||
|
y: 5040,
|
||||||
|
mode: 1,
|
||||||
|
lifePct: 128,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emit({ type: 'UnitMode', unitType: 'monster', id: 104, mode: 12, x: 5040, y: 5040, lifePct: 0 });
|
||||||
|
|
||||||
|
await app.killStats.flushNow();
|
||||||
|
})()`)
|
||||||
|
|
||||||
|
// 3. Open stats.html in a second tab in the same browser context
|
||||||
|
const statsTarget = await openNewPageTarget(cdpPort, statsUrl)
|
||||||
|
statsCdp = await CdpSession.connect(statsTarget.webSocketDebuggerUrl)
|
||||||
|
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
await sleep(150)
|
||||||
|
const ready = await statsCdp.evaluate<boolean>('Boolean(window.__d2StatsApp && window.__d2StatsApp.ready)')
|
||||||
|
if (ready) break
|
||||||
|
}
|
||||||
|
const statsReady = await statsCdp.evaluate<boolean>('Boolean(window.__d2StatsApp && window.__d2StatsApp.ready)')
|
||||||
|
assert(statsReady, 'stats.html __d2StatsApp failed to reach ready=true')
|
||||||
|
|
||||||
|
// 4. Verify isolated scroll layout, sidebar tree, summary cards, Boss/SU board, and Monster Type table
|
||||||
|
const initialSnap = await statsCdp.evaluate<{
|
||||||
|
outerOverflow: string
|
||||||
|
sidebarOverflowY: string
|
||||||
|
mainOverflowY: string
|
||||||
|
sidebarText: string
|
||||||
|
hasHeroSorc: boolean
|
||||||
|
valNormal: string
|
||||||
|
valNightmare: string
|
||||||
|
valHell: string
|
||||||
|
valTotal: string
|
||||||
|
andarielTotal: string
|
||||||
|
pindleskinTotal: string
|
||||||
|
fallenTotal: string
|
||||||
|
}>(`(() => {
|
||||||
|
const outer = document.querySelector('.d2-stats-shell') || document.getElementById('d2-stats-app');
|
||||||
|
const sidebar = document.querySelector('.d2-stats__sidebar');
|
||||||
|
const main = document.querySelector('.d2-stats__main');
|
||||||
|
const andarielRow = document.querySelector('tr[data-canonical-id="andariel"]');
|
||||||
|
const pindleskinRow = document.querySelector('tr[data-canonical-id="Pindleskin"]');
|
||||||
|
const fallenRow = document.querySelector('tr[data-monster-hcidx="19"]');
|
||||||
|
return {
|
||||||
|
outerOverflow: outer ? getComputedStyle(outer).overflow : '',
|
||||||
|
sidebarOverflowY: sidebar ? getComputedStyle(sidebar).overflowY : '',
|
||||||
|
mainOverflowY: main ? getComputedStyle(main).overflowY : '',
|
||||||
|
sidebarText: sidebar?.textContent ?? '',
|
||||||
|
hasHeroSorc: Boolean(document.querySelector('.d2-stats__char-btn[data-char-name="HeroSorc"]')),
|
||||||
|
valNormal: document.getElementById('d2-stats-val-normal')?.textContent?.trim() ?? '',
|
||||||
|
valNightmare: document.getElementById('d2-stats-val-nightmare')?.textContent?.trim() ?? '',
|
||||||
|
valHell: document.getElementById('d2-stats-val-hell')?.textContent?.trim() ?? '',
|
||||||
|
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
|
||||||
|
andarielTotal: andarielRow?.querySelector('td[data-col="total"]')?.textContent?.trim() ?? '',
|
||||||
|
pindleskinTotal: pindleskinRow?.querySelector('td[data-col="total"]')?.textContent?.trim() ?? '',
|
||||||
|
fallenTotal: fallenRow?.querySelector('td[data-col="total"]')?.textContent?.trim() ?? '',
|
||||||
|
};
|
||||||
|
})()`)
|
||||||
|
|
||||||
|
assert(initialSnap.outerOverflow === 'hidden', `Expected .d2-stats overflow: hidden, got ${initialSnap.outerOverflow}`)
|
||||||
|
assert(initialSnap.sidebarOverflowY === 'auto', `Expected .d2-stats__sidebar overflow-y: auto, got ${initialSnap.sidebarOverflowY}`)
|
||||||
|
assert(initialSnap.mainOverflowY === 'auto', `Expected .d2-stats__main overflow-y: auto, got ${initialSnap.mainOverflowY}`)
|
||||||
|
assert(initialSnap.sidebarText.includes('myacct'), `Sidebar must include account name myacct: ${initialSnap.sidebarText}`)
|
||||||
|
assert(initialSnap.hasHeroSorc, 'Sidebar must include character button for HeroSorc')
|
||||||
|
assert(initialSnap.valNormal === '0', `Expected Normal=0, got ${initialSnap.valNormal}`)
|
||||||
|
assert(initialSnap.valNightmare === '0', `Expected Nightmare=0, got ${initialSnap.valNightmare}`)
|
||||||
|
assert(initialSnap.valHell === '4', `Expected Hell=4, got ${initialSnap.valHell}`)
|
||||||
|
assert(initialSnap.valTotal === '4', `Expected Total=4, got ${initialSnap.valTotal}`)
|
||||||
|
assert(initialSnap.andarielTotal === '1', `Expected Andariel total=1, got ${initialSnap.andarielTotal}`)
|
||||||
|
assert(initialSnap.pindleskinTotal === '1', `Expected Pindleskin total=1, got ${initialSnap.pindleskinTotal}`)
|
||||||
|
assert(initialSnap.fallenTotal === '2', `Expected Fallen (hcIdx=19) total=2, got ${initialSnap.fallenTotal}`)
|
||||||
|
|
||||||
|
const shot1 = join(outDir, '01-stats-overview-zh.png')
|
||||||
|
const shot1Bytes = await statsCdp.captureScreenshot(shot1)
|
||||||
|
console.log(`[verify-kill-stats] Captured ${shot1} (${shot1Bytes} bytes) — Total=4 (Andariel=1, Pindleskin=1, Fallen=2)`)
|
||||||
|
|
||||||
|
// 5. Toggle Solo-Only filter -> expect Total = 3 (Fallen = 1)
|
||||||
|
await statsCdp.evaluate(`(() => {
|
||||||
|
const cb = document.getElementById('d2-stats-solo-only');
|
||||||
|
cb.click();
|
||||||
|
})()`)
|
||||||
|
const soloSnap = await statsCdp.evaluate<{ valTotal: string; fallenTotal: string }>(`(() => {
|
||||||
|
const fallenRow = document.querySelector('tr[data-monster-hcidx="19"]');
|
||||||
|
return {
|
||||||
|
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
|
||||||
|
fallenTotal: fallenRow?.querySelector('td[data-col="total"]')?.textContent?.trim() ?? '',
|
||||||
|
};
|
||||||
|
})()`)
|
||||||
|
assert(soloSnap.valTotal === '3', `Expected Solo-Only Total=3, got ${soloSnap.valTotal}`)
|
||||||
|
assert(soloSnap.fallenTotal === '1', `Expected Solo-Only Fallen=1, got ${soloSnap.fallenTotal}`)
|
||||||
|
|
||||||
|
const shot2 = join(outDir, '02-stats-solo-only.png')
|
||||||
|
const shot2Bytes = await statsCdp.captureScreenshot(shot2)
|
||||||
|
console.log(`[verify-kill-stats] Captured ${shot2} (${shot2Bytes} bytes) — Solo-Only Total=3`)
|
||||||
|
|
||||||
|
// Uncheck Solo-Only back to All Kills
|
||||||
|
await statsCdp.evaluate(`(() => {
|
||||||
|
const cb = document.getElementById('d2-stats-solo-only');
|
||||||
|
cb.click();
|
||||||
|
})()`)
|
||||||
|
|
||||||
|
// 6. Toggle Language (zh -> en -> zh)
|
||||||
|
await statsCdp.evaluate(`(() => {
|
||||||
|
const btn = document.getElementById('d2-stats-lang-toggle');
|
||||||
|
btn.click();
|
||||||
|
})()`)
|
||||||
|
const enSnap = await statsCdp.evaluate<{
|
||||||
|
titleText: string
|
||||||
|
andarielName: string
|
||||||
|
pindleskinName: string
|
||||||
|
}>(`(() => {
|
||||||
|
const andarielRow = document.querySelector('tr[data-canonical-id="andariel"]');
|
||||||
|
const pindleskinRow = document.querySelector('tr[data-canonical-id="Pindleskin"]');
|
||||||
|
return {
|
||||||
|
titleText: document.getElementById('d2-stats-page-title')?.textContent ?? '',
|
||||||
|
andarielName: andarielRow?.textContent ?? '',
|
||||||
|
pindleskinName: pindleskinRow?.textContent ?? '',
|
||||||
|
};
|
||||||
|
})()`)
|
||||||
|
assert(enSnap.titleText.includes('Kill Statistics'), `Expected English title, got ${enSnap.titleText}`)
|
||||||
|
assert(enSnap.andarielName.includes('Andariel'), `Expected English Andariel row, got ${enSnap.andarielName}`)
|
||||||
|
assert(enSnap.pindleskinName.includes('Pindleskin'), `Expected English Pindleskin row, got ${enSnap.pindleskinName}`)
|
||||||
|
|
||||||
|
const shot3 = join(outDir, '03-stats-overview-en.png')
|
||||||
|
const shot3Bytes = await statsCdp.captureScreenshot(shot3)
|
||||||
|
console.log(`[verify-kill-stats] Captured ${shot3} (${shot3Bytes} bytes) — English locale verified`)
|
||||||
|
|
||||||
|
// Toggle back to Chinese
|
||||||
|
await statsCdp.evaluate(`(() => {
|
||||||
|
const btn = document.getElementById('d2-stats-lang-toggle');
|
||||||
|
btn.click();
|
||||||
|
})()`)
|
||||||
|
|
||||||
|
// 7. Real-time cross-tab BroadcastChannel update: emit 1 more Fallen kill in playCdp + flushNow()
|
||||||
|
await playCdp.evaluate(`(async () => {
|
||||||
|
const emit = window.__d2EmitTestServerEvent;
|
||||||
|
emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: {
|
||||||
|
unitType: 'monster',
|
||||||
|
id: 105,
|
||||||
|
classId: 19,
|
||||||
|
x: 5050,
|
||||||
|
y: 5050,
|
||||||
|
mode: 1,
|
||||||
|
lifePct: 128,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emit({ type: 'UnitMode', unitType: 'monster', id: 105, mode: 12, x: 5050, y: 5050, lifePct: 0 });
|
||||||
|
await window.__d2PlayApp.killStats.flushNow();
|
||||||
|
})()`)
|
||||||
|
|
||||||
|
let liveTotal = ''
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await sleep(100)
|
||||||
|
liveTotal = await statsCdp.evaluate<string>(
|
||||||
|
`document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? ''`,
|
||||||
|
)
|
||||||
|
if (liveTotal === '5') break
|
||||||
|
}
|
||||||
|
assert(liveTotal === '5', `Expected live BroadcastChannel update to Total=5 without reload, got ${liveTotal}`)
|
||||||
|
|
||||||
|
const shot4 = join(outDir, '04-stats-live-broadcast-update.png')
|
||||||
|
const shot4Bytes = await statsCdp.captureScreenshot(shot4)
|
||||||
|
console.log(`[verify-kill-stats] Captured ${shot4} (${shot4Bytes} bytes) — Live BroadcastChannel auto-refresh Total=5`)
|
||||||
|
|
||||||
|
// 8. Export JSON & idempotent re-import via DOM.setFileInputFiles
|
||||||
|
await statsCdp.evaluate(`(() => {
|
||||||
|
const btn = document.getElementById('d2-stats-export-btn');
|
||||||
|
btn.click();
|
||||||
|
})()`)
|
||||||
|
|
||||||
|
let exportedJson = ''
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await sleep(100)
|
||||||
|
exportedJson = await statsCdp.evaluate<string>(
|
||||||
|
`String(window.__d2LastExportedKillStatsJson ?? '')`,
|
||||||
|
)
|
||||||
|
if (exportedJson.length > 10) break
|
||||||
|
}
|
||||||
|
assert(exportedJson.includes('"format": "d2web-kill-stats"'), 'Exported JSON must contain d2web-kill-stats format')
|
||||||
|
|
||||||
|
const validExportPath = join(tmpImportDir, 'valid-kill-stats.json')
|
||||||
|
writeFileSync(validExportPath, exportedJson, 'utf8')
|
||||||
|
await statsCdp.setFileInputFiles('#d2-stats-import-input', [validExportPath])
|
||||||
|
await sleep(300)
|
||||||
|
|
||||||
|
const afterIdempotentImport = await statsCdp.evaluate<{
|
||||||
|
valTotal: string
|
||||||
|
infoVisible: boolean
|
||||||
|
infoText: string
|
||||||
|
errorVisible: boolean
|
||||||
|
}>(`(() => {
|
||||||
|
const info = document.getElementById('d2-stats-info-banner');
|
||||||
|
const err = document.getElementById('d2-stats-error-banner');
|
||||||
|
return {
|
||||||
|
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
|
||||||
|
infoVisible: Boolean(info && !info.hidden),
|
||||||
|
infoText: info?.textContent ?? '',
|
||||||
|
errorVisible: Boolean(err && !err.hidden),
|
||||||
|
};
|
||||||
|
})()`)
|
||||||
|
assert(
|
||||||
|
afterIdempotentImport.valTotal === '5',
|
||||||
|
`Idempotent re-import must keep Total=5, got ${afterIdempotentImport.valTotal}`,
|
||||||
|
)
|
||||||
|
assert(afterIdempotentImport.infoVisible, 'Info banner must be visible after valid import')
|
||||||
|
assert(!afterIdempotentImport.errorVisible, 'Error banner must be hidden after valid import')
|
||||||
|
console.log(`[verify-kill-stats] Idempotent JSON re-import verified: "${afterIdempotentImport.infoText}"`)
|
||||||
|
|
||||||
|
// 9. Tampered JSON import rejection (soloCount > count) -> [role="alert"] error banner
|
||||||
|
const tamperedObj = JSON.parse(exportedJson)
|
||||||
|
tamperedObj.tallies[0].soloCount = 999
|
||||||
|
const tamperedExportPath = join(tmpImportDir, 'tampered-kill-stats.json')
|
||||||
|
writeFileSync(tamperedExportPath, JSON.stringify(tamperedObj, null, 2), 'utf8')
|
||||||
|
|
||||||
|
await statsCdp.setFileInputFiles('#d2-stats-import-input', [tamperedExportPath])
|
||||||
|
await sleep(300)
|
||||||
|
|
||||||
|
const afterTamperedImport = await statsCdp.evaluate<{
|
||||||
|
errorVisible: boolean
|
||||||
|
errorRole: string
|
||||||
|
errorText: string
|
||||||
|
valTotal: string
|
||||||
|
}>(`(() => {
|
||||||
|
const err = document.getElementById('d2-stats-error-banner');
|
||||||
|
return {
|
||||||
|
errorVisible: Boolean(err && !err.hidden),
|
||||||
|
errorRole: err?.getAttribute('role') ?? '',
|
||||||
|
errorText: err?.textContent ?? '',
|
||||||
|
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
|
||||||
|
};
|
||||||
|
})()`)
|
||||||
|
assert(afterTamperedImport.errorVisible, 'Error banner must be visible on tampered JSON import')
|
||||||
|
assert(afterTamperedImport.errorRole === 'alert', `Expected role="alert", got ${afterTamperedImport.errorRole}`)
|
||||||
|
assert(
|
||||||
|
afterTamperedImport.errorText.includes('soloCount'),
|
||||||
|
`Expected error message to mention soloCount, got: ${afterTamperedImport.errorText}`,
|
||||||
|
)
|
||||||
|
assert(afterTamperedImport.valTotal === '5', 'Tampered import must not corrupt existing Total=5')
|
||||||
|
|
||||||
|
const shot5 = join(outDir, '05-stats-tampered-import-error.png')
|
||||||
|
const shot5Bytes = await statsCdp.captureScreenshot(shot5)
|
||||||
|
console.log(`[verify-kill-stats] Captured ${shot5} (${shot5Bytes} bytes) — Tampered JSON rejected with role="alert"`)
|
||||||
|
|
||||||
|
// 10. Select HeroSorc character and click Delete Character (auto-accepting confirm dialog)
|
||||||
|
await statsCdp.evaluate(`(() => {
|
||||||
|
const charBtn = document.querySelector('.d2-stats__char-btn[data-char-name="HeroSorc"]');
|
||||||
|
charBtn?.click();
|
||||||
|
})()`)
|
||||||
|
await sleep(150)
|
||||||
|
await statsCdp.evaluate(`(() => {
|
||||||
|
const delBtn = document.getElementById('d2-stats-delete-char-btn');
|
||||||
|
delBtn?.click();
|
||||||
|
})()`)
|
||||||
|
await sleep(400)
|
||||||
|
|
||||||
|
const afterDeleteSnap = await statsCdp.evaluate<{
|
||||||
|
valTotal: string
|
||||||
|
emptyVisible: boolean
|
||||||
|
hasHeroSorc: boolean
|
||||||
|
}>(`(() => {
|
||||||
|
const empty = document.getElementById('d2-stats-empty-state');
|
||||||
|
return {
|
||||||
|
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
|
||||||
|
emptyVisible: Boolean(empty && !empty.hidden),
|
||||||
|
hasHeroSorc: Boolean(document.querySelector('.d2-stats__char-btn[data-char-name="HeroSorc"]')),
|
||||||
|
};
|
||||||
|
})()`)
|
||||||
|
assert(afterDeleteSnap.valTotal === '0', `Expected Total=0 after deleting character, got ${afterDeleteSnap.valTotal}`)
|
||||||
|
assert(afterDeleteSnap.emptyVisible, 'Empty state must be visible after deleting character')
|
||||||
|
assert(!afterDeleteSnap.hasHeroSorc, 'HeroSorc button must be removed after deletion')
|
||||||
|
assert(statsCdp.dialogsOpened.length >= 1, 'Expected window.confirm dialog to be opened on delete')
|
||||||
|
|
||||||
|
const shot6 = join(outDir, '06-stats-deleted-empty-state.png')
|
||||||
|
const shot6Bytes = await statsCdp.captureScreenshot(shot6)
|
||||||
|
console.log(`[verify-kill-stats] Captured ${shot6} (${shot6Bytes} bytes) — Character deleted & empty state verified`)
|
||||||
|
|
||||||
|
// 11. Assert zero console errors and zero uncaught page exceptions across both tabs
|
||||||
|
assert(
|
||||||
|
playCdp.consoleErrors.length === 0,
|
||||||
|
`play.html had console errors: ${JSON.stringify(playCdp.consoleErrors)}`,
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
playCdp.pageExceptions.length === 0,
|
||||||
|
`play.html had page exceptions: ${JSON.stringify(playCdp.pageExceptions)}`,
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
statsCdp.consoleErrors.length === 0,
|
||||||
|
`stats.html had console errors: ${JSON.stringify(statsCdp.consoleErrors)}`,
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
statsCdp.pageExceptions.length === 0,
|
||||||
|
`stats.html had page exceptions: ${JSON.stringify(statsCdp.pageExceptions)}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('[verify-kill-stats] ALL E2E BROWSER VERIFICATION CHECKS PASSED (0 console errors, 0 exceptions)')
|
||||||
|
} finally {
|
||||||
|
await statsCdp?.close()
|
||||||
|
await playCdp?.close()
|
||||||
|
if (chromeProc) {
|
||||||
|
chromeProc.kill('SIGKILL')
|
||||||
|
}
|
||||||
|
if (viteServer) {
|
||||||
|
await viteServer.close()
|
||||||
|
}
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true })
|
||||||
|
rmSync(tmpImportDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error('[verify-kill-stats] FAILED:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
@ -5,7 +5,7 @@ import { defineConfig } from 'vite'
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite configuration for the unified Diablo II web client (`play.html`).
|
* Vite configuration for the Diablo II web client (`play.html` and `stats.html`).
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
@ -51,6 +51,7 @@ export default defineConfig({
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
play: resolve(__dirname, 'play.html'),
|
play: resolve(__dirname, 'play.html'),
|
||||||
|
stats: resolve(__dirname, 'stats.html'),
|
||||||
sw: resolve(__dirname, 'src/client/sw/sw.ts'),
|
sw: resolve(__dirname, 'src/client/sw/sw.ts'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue