Add per-account/character monster kill statistics page (stats.html)

This commit is contained in:
troytt 2026-10-03 02:58:04 +00:00
parent 0897bf6114
commit 8f48c4b1ec
28 changed files with 8006 additions and 21 deletions

View File

@ -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) |

View File

@ -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 清单的离线资产增量缓存

View File

@ -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': {

View File

@ -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,

View File

@ -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,

View File

@ -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,
},
}
}

View File

@ -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'))
})
}
}

View File

@ -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,
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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'

View File

@ -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
}

433
stats.html Normal file
View File

@ -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>

View File

@ -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)

View File

@ -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)
})
}
})

View File

@ -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()
})
})

View File

@ -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])
})
})

View File

@ -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: [],
}
}
}

View File

@ -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()
})
})

View File

@ -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')
}) })

View File

@ -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),
)
})
})
})

View File

@ -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)
} }

View File

@ -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)),

View File

@ -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'),
) )
}) })
}) })

View File

@ -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)
})

View File

@ -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: {