diff --git a/README.md b/README.md index 5385568..014a8bf 100644 --- a/README.md +++ b/README.md @@ -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/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/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) | diff --git a/src/client/README.md b/src/client/README.md index dd12bb5..1a9f60e 100644 --- a/src/client/README.md +++ b/src/client/README.md @@ -6,7 +6,9 @@ ## 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`)**: - `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。 - **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。 @@ -28,7 +30,8 @@ src/client/ ├── ui/ # 游戏内 HUD 渲染与交互 (HudManager, 底部状态栏, 小地图 Automap, 7 大面板, D2 点阵字体渲染) ├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑) ├── 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 地址与显示偏好) ├── inspector/ # 实时网络封包检查器 (PacketInspector) 与 .d2cap 录制/回放面板 ├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存 diff --git a/src/client/i18n/lang.ts b/src/client/i18n/lang.ts index 72e00ba..3d844c7 100644 --- a/src/client/i18n/lang.ts +++ b/src/client/i18n/lang.ts @@ -120,7 +120,12 @@ export function detectInitialLang(): GameLang { if (fromPath !== null) { 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 { const raw = window.localStorage?.getItem('d2web.settings.v1') if (raw) { @@ -274,6 +279,7 @@ export type ToolbarI18nKey = | 'toolbar.automap.act' | 'toolbar.viewport.label' | 'toolbar.inspector.toggle' + | 'toolbar.killStats.open' | 'toolbar.cache.clear' | 'toolbar.cache.cleared' | 'toolbar.diag.quotaExceeded' @@ -302,6 +308,7 @@ export const TOOLBAR_I18N: Readonly { }, }) sessionRef = session + const killStats = new KillTracker({ session }) // Mount PacketInspector inside #d2-inspector-container if available const inspector = session.packetInspector @@ -1015,6 +1018,7 @@ async function bootPlayApp(): Promise { settings: settingsStore, settingsStore, session, + killStats, toolbar, inspector, hudManager: session.hudManager, diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index e39481d..5be82e4 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -264,6 +264,7 @@ export class OnlineSession { private phaseValue: OnlineSessionPhase = 'idle' private usernameValue = '' + private loginHostValue: string | null = null private realmsValue: readonly RealmSummary[] = [] private selectedRealmValue: RealmSummary | null = null private charactersValue: readonly CharSummary[] = [] @@ -457,6 +458,10 @@ export class OnlineSession { return this.phaseValue } + get loginHost(): string | null { + return this.loginHostValue + } + get selectedChar(): string | null { return this.selectedCharValue } @@ -583,6 +588,7 @@ export class OnlineSession { this.lostFlow = null this.disconnectReasonValue = null this.usernameValue = params.username + this.loginHostValue = (params.host ?? this.defaultHost).trim() || this.defaultHost this.setPhase('connecting') const resolver = params.wsBridgeBaseUrl @@ -591,7 +597,7 @@ export class OnlineSession { const flow = this.flowFactory({ resolver, - bnetHost: params.host ?? this.defaultHost, + bnetHost: this.loginHostValue, bnetPort: params.bncsPort ?? this.defaultBncsPort, itemTables: this.itemTables, tap: this.packetTap, diff --git a/src/client/stats/kill-stats-model.ts b/src/client/stats/kill-stats-model.ts new file mode 100644 index 0000000..a6f7cce --- /dev/null +++ b/src/client/stats/kill-stats-model.ts @@ -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() + 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() + 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 { + 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() + 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() + 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() + 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() + 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, + }, + } +} diff --git a/src/client/stats/kill-stats-store.ts b/src/client/stats/kill-stats-store.ts new file mode 100644 index 0000000..26498bb --- /dev/null +++ b/src/client/stats/kill-stats-store.ts @@ -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 + applyBatch(batch: KillStatsBatchInput): Promise + applyBatch( + character: KillCharacterProfile, + deltas: readonly KillTallyDelta[], + at?: number, + ): Promise + applyBatch( + replicaId: string, + character: KillCharacterProfile, + deltas: readonly KillTallyDelta[], + at?: number, + ): Promise + exportPayload(exportedAt?: number): Promise + importPayload(raw: unknown): Promise + deleteCharacter(charKey: string): Promise + clearAll(): Promise + 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 | 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 { + 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((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 { + const db = await this.openDb() + return await new Promise((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 + async applyBatch( + character: KillCharacterProfile, + deltas: readonly KillTallyDelta[], + at?: number, + ): Promise + async applyBatch( + replicaId: string, + character: KillCharacterProfile, + deltas: readonly KillTallyDelta[], + at?: number, + ): Promise + async applyBatch( + arg0: KillStatsBatchInput | KillCharacterProfile | string, + arg1?: readonly KillTallyDelta[] | KillCharacterProfile, + arg2?: number | readonly KillTallyDelta[], + arg3?: number, + ): Promise { + const { explicitReplicaId, character, deltas, at } = normalizeApplyBatchArgs( + arg0, + arg1, + arg2, + arg3, + ) + const db = await this.openDb() + const effectiveAt = at ?? this.now() + + await new Promise((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 { + const snapshot = await this.loadAll() + return buildExportPayload(snapshot, exportedAt ?? this.now()) + } + + async importPayload(raw: unknown): Promise { + // Strictly validate the entire payload before opening any write transaction + const validated = validateImportPayload(raw) + const db = await this.openDb() + + return await new Promise((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 { + const db = await this.openDb() + await new Promise((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 { + const db = await this.openDb() + await new Promise((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')) + }) + } +} diff --git a/src/client/stats/kill-stats-view.ts b/src/client/stats/kill-stats-view.ts new file mode 100644 index 0000000..6a7e460 --- /dev/null +++ b/src/client/stats/kill-stats-view.ts @@ -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() + 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() + 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, + 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() + 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() + 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() + const bossAccByHcIdx = new Map() + const suAccByIdx = new Map() + + 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() + 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, + } +} diff --git a/src/client/stats/kill-tracker.ts b/src/client/stats/kill-tracker.ts new file mode 100644 index 0000000..c2a6a92 --- /dev/null +++ b/src/client/stats/kill-tracker.ts @@ -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 +} + +/** + * 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), +} + +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() + private readonly owned = new Set() + private warnedMissingGameFlags = false + private warnedMissingIdentity = false + private lastPhase: string + + private readonly pendingByChar = new Map() + private flushTimer: unknown = null + private retryAttempts = 0 + private lastFlushAt: number | null = null + private lastError: string | null = null + private inFlightFlush: Promise | 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(), + } + 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(), + } + 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 { + this.clearFlushTimer() + if (this.inFlightFlush) { + await this.inFlightFlush + } + if (this.pendingByChar.size === 0) { + return + } + + const run = async (): Promise => { + 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 + } + } + } +} diff --git a/src/client/stats/stats-i18n.ts b/src/client/stats/stats-i18n.ts new file mode 100644 index 0000000..fc717aa --- /dev/null +++ b/src/client/stats/stats-i18n.ts @@ -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 +> = { + '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 +} diff --git a/src/client/stats/stats-main.ts b/src/client/stats/stats-main.ts new file mode 100644 index 0000000..627b976 --- /dev/null +++ b/src/client/stats/stats-main.ts @@ -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 + getViewModel(): KillStatsViewModel | null + getLastExportJson(): string | null +} + +declare global { + interface Window { + __d2StatsApp?: StatsAppController + } +} + +async function bootStatsApp(): Promise { + initLangFromBrowser() + const settingsStore = getGlobalSettingsStore() + const root = + document.getElementById('d2-stats-app') ?? + (() => { + const el = document.createElement('div') + el.id = 'd2-stats-app' + document.body.appendChild(el) + return el + })() + + const page = new KillStatsPage({ + root, + settingsStore, + }) + + await page.init() + + const controller: StatsAppController = { + ready: true, + page, + repository: page.repository, + refresh: () => page.refresh(), + getViewModel: () => page.getViewModel(), + getLastExportJson: () => page.getLastExportJson(), + } + + window.__d2StatsApp = controller +} + +void bootStatsApp() diff --git a/src/client/stats/stats-page.ts b/src/client/stats/stats-page.ts new file mode 100644 index 0000000..79968c7 --- /dev/null +++ b/src/client/stats/stats-page.ts @@ -0,0 +1,1250 @@ +/** + * DOM controller and interactive UI for `stats.html`. + * + * Security & Fidelity Invariants: + * - All user/server/file strings are rendered exclusively via `textContent` (never `innerHTML`). + * - Synchronizes language (`zh` / `en`) with `getGlobalSettingsStore()` (`d2web.settings.v1`). + * - Subscribes to `BroadcastChannel('d2web-kill-stats')` with 300ms debounced auto-refresh. + * - Displays `role="alert"` error banners on IndexedDB errors or `KillStatsImportError` violations. + */ + +import { getGameLang, setGameLang, type GameLang } from '../i18n/lang.ts' +import { getGlobalSettingsStore, type SettingsStore } from '../settings/settings-store.ts' +import { + KILL_STATS_CHANNEL_NAME, + KillStatsImportError, + type KillStatsImportSummary, + type KillStatsSnapshot, +} from './kill-stats-model.ts' +import { + IndexedDbKillStatsRepository, + type KillStatsRepository, +} from './kill-stats-store.ts' +import { + buildKillStatsViewModel, + computeAccountKey, + type KillStatsCharacterNode, + type KillStatsScope, + type KillStatsViewModel, + type MonsterSortColumn, + type MonsterTableSort, +} from './kill-stats-view.ts' +import { tStats } from './stats-i18n.ts' + +export interface KillStatsPageOptions { + readonly root: HTMLElement + readonly repository?: KillStatsRepository | undefined + readonly settingsStore?: SettingsStore | undefined + readonly confirmFn?: ((message: string) => boolean) | undefined + readonly refreshDebounceMs?: number | undefined +} + +interface BuiltDomRefs { + readonly pageTitleEl: HTMLElement + readonly backToGameLink: HTMLAnchorElement + readonly sidebarTitleEl: HTMLElement + readonly allAccountsBtn: HTMLButtonElement + readonly allAccountsLabelEl: HTMLElement + readonly allAccountsCountEl: HTMLElement + readonly accountTreeEl: HTMLElement + readonly errorBannerEl: HTMLElement + readonly infoBannerEl: HTMLElement + readonly cardNormalLabelEl: HTMLElement + readonly cardNormalValEl: HTMLElement + readonly cardNightmareLabelEl: HTMLElement + readonly cardNightmareValEl: HTMLElement + readonly cardHellLabelEl: HTMLElement + readonly cardHellValEl: HTMLElement + readonly cardTotalLabelEl: HTMLElement + readonly cardTotalValEl: HTMLElement + readonly soloOnlyInput: HTMLInputElement + readonly soloOnlyLabelEl: HTMLElement + readonly showUnkilledInput: HTMLInputElement + readonly showUnkilledLabelEl: HTMLElement + readonly langToggleBtn: HTMLButtonElement + readonly exportBtn: HTMLButtonElement + readonly importBtn: HTMLButtonElement + readonly importInput: HTMLInputElement + readonly deleteCharBtn: HTMLButtonElement + readonly clearAllBtn: HTMLButtonElement + readonly emptyStateEl: HTMLElement + readonly emptyStateTitleEl: HTMLElement + readonly emptyStateBodyEl: HTMLElement + readonly boardTitleEl: HTMLElement + readonly boardSubtitleEl: HTMLElement + readonly boardCountBadgeEl: HTMLElement + readonly boardColNameEl: HTMLElement + readonly boardColKindEl: HTMLElement + readonly boardColNormalEl: HTMLElement + readonly boardColNightmareEl: HTMLElement + readonly boardColHellEl: HTMLElement + readonly boardColTotalEl: HTMLElement + readonly boardColLastAtEl: HTMLElement + readonly boardTbodyEl: HTMLTableSectionElement + readonly monstersTitleEl: HTMLElement + readonly monstersCountBadgeEl: HTMLElement + readonly monsterSearchInput: HTMLInputElement + readonly monstersColHcIdxBtn: HTMLButtonElement + readonly monstersColNameBtn: HTMLButtonElement + readonly monstersColNormalBtn: HTMLButtonElement + readonly monstersColNightmareBtn: HTMLButtonElement + readonly monstersColHellBtn: HTMLButtonElement + readonly monstersColTotalBtn: HTMLButtonElement + readonly monstersTbodyEl: HTMLTableSectionElement + readonly footnoteEl: HTMLElement +} + +function formatTimestamp(ms: number | null, lang: GameLang): string { + if (ms === null || !Number.isFinite(ms) || ms <= 0) { + return '—' + } + try { + const d = new Date(ms) + return d.toLocaleString(lang === 'en' ? 'en-US' : 'zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + } catch { + return String(ms) + } +} + +export class KillStatsPage { + readonly repository: KillStatsRepository + private readonly root: HTMLElement + private readonly doc: Document + private readonly settingsStore: SettingsStore + private readonly confirmFn: (message: string) => boolean + private readonly refreshDebounceMs: number + private readonly dom: BuiltDomRefs + + private snapshot: KillStatsSnapshot = { + replicaId: '', + characters: [], + tallies: [], + } + private scope: KillStatsScope = { kind: 'all' } + private soloOnly = false + private showUnkilled = true + private lang: GameLang + private monsterSearch = '' + private monsterSort: MonsterTableSort = { + column: 'total', + direction: 'desc', + } + private lastViewModel: KillStatsViewModel | null = null + private lastExportJson: string | null = null + private errorMessage: string | null = null + private infoMessage: string | null = null + + private channel: BroadcastChannel | null = null + private refreshTimer: ReturnType | null = null + private unsubSettings: (() => void) | null = null + + constructor(options: KillStatsPageOptions) { + this.root = options.root + this.doc = options.root.ownerDocument + this.settingsStore = options.settingsStore ?? getGlobalSettingsStore() + this.lang = this.settingsStore.get().lang ?? getGameLang() + setGameLang(this.lang) + this.confirmFn = + options.confirmFn ?? + ((msg: string) => + typeof window !== 'undefined' && typeof window.confirm === 'function' + ? window.confirm(msg) + : true) + this.refreshDebounceMs = options.refreshDebounceMs ?? 300 + + this.repository = + options.repository ?? + new IndexedDbKillStatsRepository({ + onBlocked: () => { + this.showError(tStats('stats.error.idbBlocked', this.lang)) + }, + onVersionChange: () => { + this.showError(tStats('stats.error.idbVersionChange', this.lang)) + }, + }) + + this.dom = this.mountDom() + this.bindEvents() + } + + async init(): Promise { + this.setupBroadcastChannel() + this.unsubSettings = this.settingsStore.subscribe((next) => { + if (next.lang !== this.lang) { + this.lang = next.lang + setGameLang(this.lang) + this.render() + } + }) + await this.refresh() + } + + destroy(): void { + if (this.refreshTimer !== null) { + clearTimeout(this.refreshTimer) + this.refreshTimer = null + } + this.unsubSettings?.() + this.unsubSettings = null + try { + this.channel?.close() + } catch { + // Ignore BroadcastChannel close errors + } + this.channel = null + this.repository.close?.() + } + + getViewModel(): KillStatsViewModel | null { + return this.lastViewModel + } + + getLastExportJson(): string | null { + return this.lastExportJson + } + + private setupBroadcastChannel(): void { + if (typeof globalThis.BroadcastChannel !== 'function') { + return + } + try { + this.channel = new globalThis.BroadcastChannel(KILL_STATS_CHANNEL_NAME) + this.channel.onmessage = (ev: MessageEvent) => { + const data = ev.data as { type?: unknown } | null + if (data && data.type === 'updated') { + this.scheduleDebouncedRefresh() + } + } + } catch { + this.channel = null + } + } + + private broadcastUpdated(): void { + try { + this.channel?.postMessage({ type: 'updated', at: Date.now() }) + } catch { + // Ignore broadcast errors + } + } + + private scheduleDebouncedRefresh(): void { + if (this.refreshTimer !== null) { + clearTimeout(this.refreshTimer) + } + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null + void this.refresh() + }, this.refreshDebounceMs) + } + + async refresh(): Promise { + try { + this.snapshot = await this.repository.loadAll() + this.render() + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + this.showError(`${tStats('stats.error.idbUnavailable', this.lang)} (${detail})`) + this.render() + } + } + + showError(message: string): void { + this.errorMessage = message + this.dom.errorBannerEl.textContent = message + this.dom.errorBannerEl.hidden = false + } + + clearError(): void { + this.errorMessage = null + this.dom.errorBannerEl.textContent = '' + this.dom.errorBannerEl.hidden = true + } + + private showInfo(message: string): void { + this.infoMessage = message + this.dom.infoBannerEl.textContent = message + this.dom.infoBannerEl.hidden = false + } + + private clearInfo(): void { + this.infoMessage = null + this.dom.infoBannerEl.textContent = '' + this.dom.infoBannerEl.hidden = true + } + + private resolveTargetDeleteCharKey(vm: KillStatsViewModel): string | null { + if (vm.selectedCharacter) { + return vm.selectedCharacter.charKey + } + if (vm.scope.kind === 'account') { + const acctKey = computeAccountKey( + vm.scope.host, + vm.scope.realm, + vm.scope.account, + ) + const acct = vm.accounts.find((a) => a.accountKey === acctKey) + if (acct && acct.characters.length === 1) { + return acct.characters[0]!.charKey + } + return null + } + if (this.snapshot.characters.length === 1) { + return this.snapshot.characters[0]!.charKey + } + return null + } + + async handleExportJson(): Promise { + this.clearError() + const payload = await this.repository.exportPayload() + const json = JSON.stringify(payload, null, 2) + this.lastExportJson = json + if (typeof window !== 'undefined') { + ;(window as unknown as { __d2LastExportedKillStatsJson?: string }).__d2LastExportedKillStatsJson = + json + } + + if ( + typeof Blob !== 'undefined' && + typeof URL !== 'undefined' && + typeof URL.createObjectURL === 'function' + ) { + try { + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = this.doc.createElement('a') + a.href = url + a.download = `d2web-kill-stats-${payload.exportedAt}.json` + a.style.display = 'none' + this.doc.body.appendChild(a) + a.click() + this.doc.body.removeChild(a) + setTimeout(() => URL.revokeObjectURL(url), 1000) + } catch { + // Ignore download anchor errors in headless test environments + } + } + return json + } + + async handleImportJsonText(rawText: string): Promise { + this.clearError() + this.clearInfo() + try { + const summary = await this.repository.importPayload(rawText) + await this.refresh() + this.broadcastUpdated() + const summaryText = + `${tStats('stats.import.successSummary', this.lang)}: ` + + `+${summary.addedCharacters}/~${summary.updatedCharacters} chars, ` + + `+${summary.addedTallies}/~${summary.updatedTallies}/=${summary.unchangedTallies} tallies` + this.showInfo(summaryText) + return summary + } catch (err) { + if (err instanceof KillStatsImportError) { + this.showError( + `${tStats('stats.error.importFailedPrefix', this.lang)}${err.path} — ${err.message}`, + ) + } else { + const detail = err instanceof Error ? err.message : String(err) + this.showError(`${tStats('stats.error.importFailedPrefix', this.lang)}${detail}`) + } + return null + } + } + + async handleDeleteSelectedCharacter(): Promise { + const vm = + this.lastViewModel ?? + buildKillStatsViewModel(this.snapshot, { + scope: this.scope, + soloOnly: this.soloOnly, + showUnkilled: this.showUnkilled, + lang: this.lang, + monsterSearch: this.monsterSearch, + monsterSort: this.monsterSort, + }) + const targetCharKey = this.resolveTargetDeleteCharKey(vm) + if (!targetCharKey) { + return false + } + const confirmed = this.confirmFn(tStats('stats.confirm.deleteCharacter', this.lang)) + if (!confirmed) { + return false + } + this.clearError() + this.clearInfo() + try { + await this.repository.deleteCharacter(targetCharKey) + if (this.scope.kind === 'character' && this.scope.charKey === targetCharKey) { + this.scope = { kind: 'all' } + } + await this.refresh() + this.broadcastUpdated() + return true + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + this.showError(detail) + return false + } + } + + async handleClearAll(): Promise { + const confirmed = this.confirmFn(tStats('stats.confirm.clearAll', this.lang)) + if (!confirmed) { + return false + } + this.clearError() + this.clearInfo() + try { + await this.repository.clearAll() + this.scope = { kind: 'all' } + await this.refresh() + this.broadcastUpdated() + return true + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + this.showError(detail) + return false + } + } + + toggleLanguage(): GameLang { + const nextLang: GameLang = this.lang === 'en' ? 'zh' : 'en' + this.lang = nextLang + setGameLang(nextLang) + this.settingsStore.set({ lang: nextLang }) + this.render() + return nextLang + } + + private bindEvents(): void { + this.dom.allAccountsBtn.addEventListener('click', () => { + this.scope = { kind: 'all' } + this.render() + }) + + this.dom.soloOnlyInput.addEventListener('change', () => { + this.soloOnly = this.dom.soloOnlyInput.checked + this.render() + }) + + this.dom.showUnkilledInput.addEventListener('change', () => { + this.showUnkilled = this.dom.showUnkilledInput.checked + this.render() + }) + + this.dom.langToggleBtn.addEventListener('click', () => { + this.toggleLanguage() + }) + + this.dom.exportBtn.addEventListener('click', () => { + void this.handleExportJson() + }) + + this.dom.importBtn.addEventListener('click', () => { + this.dom.importInput.value = '' + this.dom.importInput.click() + }) + + this.dom.importInput.addEventListener('change', () => { + const file = this.dom.importInput.files?.[0] + if (!file) return + void file.text().then((text) => { + void this.handleImportJsonText(text) + }) + }) + + this.dom.deleteCharBtn.addEventListener('click', () => { + void this.handleDeleteSelectedCharacter() + }) + + this.dom.clearAllBtn.addEventListener('click', () => { + void this.handleClearAll() + }) + + this.dom.monsterSearchInput.addEventListener('input', () => { + this.monsterSearch = this.dom.monsterSearchInput.value + this.render() + }) + + const bindSortBtn = (btn: HTMLButtonElement, column: MonsterSortColumn): void => { + btn.addEventListener('click', () => { + if (this.monsterSort.column === column) { + this.monsterSort = { + column, + direction: this.monsterSort.direction === 'asc' ? 'desc' : 'asc', + } + } else { + this.monsterSort = { + column, + direction: column === 'hcIdx' || column === 'name' ? 'asc' : 'desc', + } + } + this.render() + }) + } + + bindSortBtn(this.dom.monstersColHcIdxBtn, 'hcIdx') + bindSortBtn(this.dom.monstersColNameBtn, 'name') + bindSortBtn(this.dom.monstersColNormalBtn, 'normal') + bindSortBtn(this.dom.monstersColNightmareBtn, 'nightmare') + bindSortBtn(this.dom.monstersColHellBtn, 'hell') + bindSortBtn(this.dom.monstersColTotalBtn, 'total') + } + + render(): void { + const vm = buildKillStatsViewModel(this.snapshot, { + scope: this.scope, + soloOnly: this.soloOnly, + showUnkilled: this.showUnkilled, + lang: this.lang, + monsterSearch: this.monsterSearch, + monsterSort: this.monsterSort, + }) + this.lastViewModel = vm + this.scope = vm.scope + + const lang = vm.lang + if (this.doc.documentElement) { + this.doc.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN' + } + this.doc.title = tStats('stats.page.title', lang) + + this.dom.pageTitleEl.textContent = tStats('stats.page.title', lang) + this.dom.backToGameLink.textContent = tStats('stats.action.backToGame', lang) + this.dom.sidebarTitleEl.textContent = tStats('stats.sidebar.title', lang) + this.dom.allAccountsLabelEl.textContent = tStats('stats.sidebar.allAccounts', lang) + this.dom.allAccountsCountEl.textContent = String(vm.totalAllAccountsKills) + this.dom.allAccountsBtn.classList.toggle( + 'd2-stats__nav-item--active', + vm.scope.kind === 'all', + ) + + this.renderSidebarTree(vm) + + // Summary cards + this.dom.cardNormalLabelEl.textContent = tStats('stats.summary.normal', lang) + this.dom.cardNormalValEl.textContent = String(vm.summary.normal) + this.dom.cardNightmareLabelEl.textContent = tStats('stats.summary.nightmare', lang) + this.dom.cardNightmareValEl.textContent = String(vm.summary.nightmare) + this.dom.cardHellLabelEl.textContent = tStats('stats.summary.hell', lang) + this.dom.cardHellValEl.textContent = String(vm.summary.hell) + this.dom.cardTotalLabelEl.textContent = tStats('stats.summary.total', lang) + this.dom.cardTotalValEl.textContent = String(vm.summary.total) + + // Filter & action controls + this.dom.soloOnlyInput.checked = vm.soloOnly + this.dom.soloOnlyLabelEl.textContent = tStats('stats.filter.soloOnly', lang) + this.dom.showUnkilledInput.checked = vm.showUnkilled + this.dom.showUnkilledLabelEl.textContent = tStats('stats.filter.showUnkilled', lang) + this.dom.langToggleBtn.textContent = + lang === 'en' ? 'Lang: EN (切换中文)' : '语言: 中文 (Switch EN)' + this.dom.exportBtn.textContent = tStats('stats.action.exportJson', lang) + this.dom.importBtn.textContent = tStats('stats.action.importJson', lang) + this.dom.deleteCharBtn.textContent = tStats('stats.action.deleteCharacter', lang) + this.dom.deleteCharBtn.disabled = this.resolveTargetDeleteCharKey(vm) === null + this.dom.clearAllBtn.textContent = tStats('stats.action.clearAll', lang) + + // Empty state + const isCompletelyEmpty = + this.snapshot.characters.length === 0 && this.snapshot.tallies.length === 0 + this.dom.emptyStateEl.hidden = !isCompletelyEmpty + this.dom.emptyStateTitleEl.textContent = tStats('stats.emptyState.title', lang) + this.dom.emptyStateBodyEl.textContent = tStats('stats.emptyState.body', lang) + + // Boss & SuperUnique Kill Board + this.dom.boardTitleEl.textContent = tStats('stats.board.title', lang) + this.dom.boardSubtitleEl.textContent = tStats('stats.board.subtitle', lang) + this.dom.boardCountBadgeEl.textContent = `${vm.boardRows.length} / ${vm.totalBoardCatalogRows}` + this.dom.boardColNameEl.textContent = tStats('stats.board.col.name', lang) + this.dom.boardColKindEl.textContent = tStats('stats.board.col.kind', lang) + this.dom.boardColNormalEl.textContent = tStats('stats.board.col.normal', lang) + this.dom.boardColNightmareEl.textContent = tStats('stats.board.col.nightmare', lang) + this.dom.boardColHellEl.textContent = tStats('stats.board.col.hell', lang) + this.dom.boardColTotalEl.textContent = tStats('stats.board.col.total', lang) + this.dom.boardColLastAtEl.textContent = tStats('stats.board.col.lastAt', lang) + + this.renderBoardRows(vm) + + // Monster Type × Difficulty Table + this.dom.monstersTitleEl.textContent = tStats('stats.monsters.title', lang) + this.dom.monstersCountBadgeEl.textContent = String(vm.monsterRows.length) + this.dom.monsterSearchInput.placeholder = tStats( + 'stats.monsters.searchPlaceholder', + lang, + ) + + const sortArrow = (col: MonsterSortColumn): string => { + if (vm.monsterSort.column !== col) return '' + return vm.monsterSort.direction === 'asc' ? ' ▲' : ' ▼' + } + this.dom.monstersColHcIdxBtn.textContent = + tStats('stats.monsters.col.hcIdx', lang) + sortArrow('hcIdx') + this.dom.monstersColNameBtn.textContent = + tStats('stats.monsters.col.name', lang) + sortArrow('name') + this.dom.monstersColNormalBtn.textContent = + tStats('stats.monsters.col.normal', lang) + sortArrow('normal') + this.dom.monstersColNightmareBtn.textContent = + tStats('stats.monsters.col.nightmare', lang) + sortArrow('nightmare') + this.dom.monstersColHellBtn.textContent = + tStats('stats.monsters.col.hell', lang) + sortArrow('hell') + this.dom.monstersColTotalBtn.textContent = + tStats('stats.monsters.col.total', lang) + sortArrow('total') + + this.renderMonsterRows(vm) + + this.dom.footnoteEl.textContent = tStats('stats.footnote.attribution', lang) + } + + private renderSidebarTree(vm: KillStatsViewModel): void { + const container = this.dom.accountTreeEl + while (container.firstChild) { + container.removeChild(container.firstChild) + } + + if (vm.accounts.length === 0) { + const emptyEl = this.doc.createElement('div') + emptyEl.className = 'd2-stats__sidebar-empty' + emptyEl.textContent = tStats('stats.sidebar.emptyAccounts', vm.lang) + container.appendChild(emptyEl) + return + } + + for (const acct of vm.accounts) { + const groupEl = this.doc.createElement('div') + groupEl.className = 'd2-stats__account-group' + groupEl.dataset.accountKey = acct.accountKey + + const acctBtn = this.doc.createElement('button') + acctBtn.type = 'button' + acctBtn.className = 'd2-stats__account-btn' + const isAcctActive = + vm.scope.kind === 'account' && + computeAccountKey(vm.scope.host, vm.scope.realm, vm.scope.account) === + acct.accountKey + if (isAcctActive) { + acctBtn.classList.add('d2-stats__nav-item--active') + } + + const acctLabelSpan = this.doc.createElement('span') + acctLabelSpan.className = 'd2-stats__account-label' + acctLabelSpan.textContent = acct.label + + const acctKillsSpan = this.doc.createElement('span') + acctKillsSpan.className = 'd2-stats__nav-count' + acctKillsSpan.textContent = String(acct.totalKills) + + acctBtn.append(acctLabelSpan, acctKillsSpan) + acctBtn.addEventListener('click', () => { + this.scope = { + kind: 'account', + host: acct.host, + realm: acct.realm, + account: acct.account, + } + this.render() + }) + groupEl.appendChild(acctBtn) + + const charListEl = this.doc.createElement('div') + charListEl.className = 'd2-stats__char-list' + + for (const ch of acct.characters) { + charListEl.appendChild(this.createCharacterButton(ch, vm)) + } + + groupEl.appendChild(charListEl) + container.appendChild(groupEl) + } + } + + private createCharacterButton( + ch: KillStatsCharacterNode, + vm: KillStatsViewModel, + ): HTMLButtonElement { + const btn = this.doc.createElement('button') + btn.type = 'button' + btn.className = 'd2-stats__char-btn' + btn.dataset.charKey = ch.charKey + btn.dataset.charName = ch.charName + if (vm.scope.kind === 'character' && vm.scope.charKey === ch.charKey) { + btn.classList.add('d2-stats__nav-item--active') + } + + const topRow = this.doc.createElement('div') + topRow.className = 'd2-stats__char-top' + + const nameSpan = this.doc.createElement('span') + nameSpan.className = 'd2-stats__char-name' + nameSpan.textContent = ch.charName + + const killsSpan = this.doc.createElement('span') + killsSpan.className = 'd2-stats__nav-count' + killsSpan.textContent = String(ch.totalKills) + + topRow.append(nameSpan, killsSpan) + + const metaRow = this.doc.createElement('div') + metaRow.className = 'd2-stats__char-meta' + + const classLevelSpan = this.doc.createElement('span') + classLevelSpan.className = 'd2-stats__char-class' + classLevelSpan.textContent = `${ch.className} Lv.${ch.level}` + metaRow.appendChild(classLevelSpan) + + const modeBadge = this.doc.createElement('span') + modeBadge.className = ch.hardcore + ? 'd2-stats__badge d2-stats__badge--hc' + : 'd2-stats__badge' + modeBadge.textContent = ch.hardcore + ? tStats('stats.badge.hc', vm.lang) + : tStats('stats.badge.sc', vm.lang) + metaRow.appendChild(modeBadge) + + if (ch.ladder) { + const ladderBadge = this.doc.createElement('span') + ladderBadge.className = 'd2-stats__badge d2-stats__badge--ladder' + ladderBadge.textContent = tStats('stats.badge.ladder', vm.lang) + metaRow.appendChild(ladderBadge) + } + + const expBadge = this.doc.createElement('span') + expBadge.className = 'd2-stats__badge' + expBadge.textContent = ch.expansion + ? tStats('stats.badge.expansion', vm.lang) + : tStats('stats.badge.classic', vm.lang) + metaRow.appendChild(expBadge) + + btn.append(topRow, metaRow) + btn.addEventListener('click', () => { + this.scope = { kind: 'character', charKey: ch.charKey } + this.render() + }) + return btn + } + + private renderBoardRows(vm: KillStatsViewModel): void { + const tbody = this.dom.boardTbodyEl + while (tbody.firstChild) { + tbody.removeChild(tbody.firstChild) + } + + if (vm.boardRows.length === 0) { + const tr = this.doc.createElement('tr') + const td = this.doc.createElement('td') + td.colSpan = 7 + td.className = 'd2-stats__table-empty' + td.textContent = tStats('stats.board.empty', vm.lang) + tr.appendChild(td) + tbody.appendChild(tr) + return + } + + for (const row of vm.boardRows) { + const tr = this.doc.createElement('tr') + tr.dataset.boardKey = row.key + tr.dataset.canonicalId = row.canonicalId + if (row.total > 0) { + tr.classList.add('d2-stats__row--killed') + } + + const nameTd = this.doc.createElement('td') + const nameSpan = this.doc.createElement('span') + nameSpan.className = 'd2-stats__entity-name' + nameSpan.textContent = row.name + nameTd.appendChild(nameSpan) + if (row.subtitle !== null) { + const subSpan = this.doc.createElement('span') + subSpan.className = 'd2-stats__entity-subtitle' + subSpan.textContent = row.subtitle + nameTd.appendChild(subSpan) + } + + const kindTd = this.doc.createElement('td') + const kindBadge = this.doc.createElement('span') + kindBadge.className = + row.rowKind === 'boss' + ? 'd2-stats__badge d2-stats__badge--boss' + : 'd2-stats__badge d2-stats__badge--su' + kindBadge.textContent = + row.rowKind === 'boss' + ? tStats('stats.board.kind.boss', vm.lang) + : tStats('stats.board.kind.superunique', vm.lang) + kindTd.appendChild(kindBadge) + + const normalTd = this.doc.createElement('td') + normalTd.dataset.col = 'normal' + normalTd.className = 'd2-stats__num' + normalTd.textContent = String(row.normal) + + const nightmareTd = this.doc.createElement('td') + nightmareTd.dataset.col = 'nightmare' + nightmareTd.className = 'd2-stats__num' + nightmareTd.textContent = String(row.nightmare) + + const hellTd = this.doc.createElement('td') + hellTd.dataset.col = 'hell' + hellTd.className = 'd2-stats__num' + hellTd.textContent = String(row.hell) + + const totalTd = this.doc.createElement('td') + totalTd.dataset.col = 'total' + totalTd.className = 'd2-stats__num d2-stats__num--total' + totalTd.textContent = String(row.total) + + const lastAtTd = this.doc.createElement('td') + lastAtTd.dataset.col = 'lastAt' + lastAtTd.className = 'd2-stats__time' + lastAtTd.textContent = formatTimestamp(row.lastAt, vm.lang) + + tr.append(nameTd, kindTd, normalTd, nightmareTd, hellTd, totalTd, lastAtTd) + tbody.appendChild(tr) + } + } + + private renderMonsterRows(vm: KillStatsViewModel): void { + const tbody = this.dom.monstersTbodyEl + while (tbody.firstChild) { + tbody.removeChild(tbody.firstChild) + } + + if (vm.monsterRows.length === 0) { + const tr = this.doc.createElement('tr') + const td = this.doc.createElement('td') + td.colSpan = 6 + td.className = 'd2-stats__table-empty' + td.textContent = tStats('stats.monsters.empty', vm.lang) + tr.appendChild(td) + tbody.appendChild(tr) + return + } + + for (const row of vm.monsterRows) { + const tr = this.doc.createElement('tr') + tr.dataset.monsterHcidx = String(row.hcIdx) + tr.dataset.monsterId = row.id + + const hcIdxTd = this.doc.createElement('td') + hcIdxTd.dataset.col = 'hcIdx' + hcIdxTd.className = 'd2-stats__num' + hcIdxTd.textContent = String(row.hcIdx) + + const nameTd = this.doc.createElement('td') + nameTd.dataset.col = 'name' + const nameSpan = this.doc.createElement('span') + nameSpan.className = 'd2-stats__entity-name' + nameSpan.textContent = row.name + const idSpan = this.doc.createElement('span') + idSpan.className = 'd2-stats__entity-subtitle' + idSpan.textContent = row.id + nameTd.append(nameSpan, idSpan) + + const normalTd = this.doc.createElement('td') + normalTd.dataset.col = 'normal' + normalTd.className = 'd2-stats__num' + normalTd.textContent = String(row.normal) + + const nightmareTd = this.doc.createElement('td') + nightmareTd.dataset.col = 'nightmare' + nightmareTd.className = 'd2-stats__num' + nightmareTd.textContent = String(row.nightmare) + + const hellTd = this.doc.createElement('td') + hellTd.dataset.col = 'hell' + hellTd.className = 'd2-stats__num' + hellTd.textContent = String(row.hell) + + const totalTd = this.doc.createElement('td') + totalTd.dataset.col = 'total' + totalTd.className = 'd2-stats__num d2-stats__num--total' + totalTd.textContent = String(row.total) + + tr.append(hcIdxTd, nameTd, normalTd, nightmareTd, hellTd, totalTd) + tbody.appendChild(tr) + } + } + + private mountDom(): BuiltDomRefs { + while (this.root.firstChild) { + this.root.removeChild(this.root.firstChild) + } + this.root.classList.add('d2-stats-shell') + + // Left Fixed Sidebar (isolated scroll) + const sidebar = this.doc.createElement('aside') + sidebar.id = 'd2-stats-sidebar' + sidebar.className = 'd2-stats__sidebar' + + const sidebarHeader = this.doc.createElement('div') + sidebarHeader.className = 'd2-stats__sidebar-header' + + const pageTitleEl = this.doc.createElement('h1') + pageTitleEl.id = 'd2-stats-page-title' + pageTitleEl.className = 'd2-stats__title' + + const backToGameLink = this.doc.createElement('a') + backToGameLink.id = 'd2-stats-back-link' + backToGameLink.className = 'd2-stats__btn d2-stats__btn--link' + backToGameLink.href = 'play.html' + + sidebarHeader.append(pageTitleEl, backToGameLink) + + const sidebarTitleEl = this.doc.createElement('div') + sidebarTitleEl.className = 'd2-stats__sidebar-section-title' + + const allAccountsBtn = this.doc.createElement('button') + allAccountsBtn.type = 'button' + allAccountsBtn.id = 'd2-stats-scope-all' + allAccountsBtn.className = 'd2-stats__all-btn' + + const allAccountsLabelEl = this.doc.createElement('span') + const allAccountsCountEl = this.doc.createElement('span') + allAccountsCountEl.id = 'd2-stats-scope-all-count' + allAccountsCountEl.className = 'd2-stats__nav-count' + allAccountsBtn.append(allAccountsLabelEl, allAccountsCountEl) + + const accountTreeEl = this.doc.createElement('div') + accountTreeEl.id = 'd2-stats-account-tree' + accountTreeEl.className = 'd2-stats__account-tree' + + sidebar.append(sidebarHeader, sidebarTitleEl, allAccountsBtn, accountTreeEl) + + // Right Main Content Area (isolated scroll) + const main = this.doc.createElement('main') + main.id = 'd2-stats-main' + main.className = 'd2-stats__main' + + const errorBannerEl = this.doc.createElement('div') + errorBannerEl.id = 'd2-stats-error-banner' + errorBannerEl.className = 'd2-stats__banner d2-stats__banner--error' + errorBannerEl.setAttribute('role', 'alert') + errorBannerEl.hidden = true + + const infoBannerEl = this.doc.createElement('div') + infoBannerEl.id = 'd2-stats-info-banner' + infoBannerEl.className = 'd2-stats__banner d2-stats__banner--info' + infoBannerEl.setAttribute('role', 'status') + infoBannerEl.hidden = true + + // Summary Cards Grid + const summaryGrid = this.doc.createElement('section') + summaryGrid.id = 'd2-stats-summary-grid' + summaryGrid.className = 'd2-stats__summary-grid' + + const makeSummaryCard = ( + idSuffix: string, + ): { card: HTMLElement; label: HTMLElement; val: HTMLElement } => { + const card = this.doc.createElement('div') + card.id = `d2-stats-card-${idSuffix}` + card.className = `d2-stats__card d2-stats__card--${idSuffix}` + const label = this.doc.createElement('div') + label.className = 'd2-stats__card-label' + const val = this.doc.createElement('div') + val.id = `d2-stats-val-${idSuffix}` + val.className = 'd2-stats__card-val' + card.append(label, val) + return { card, label, val } + } + + const normalCard = makeSummaryCard('normal') + const nightmareCard = makeSummaryCard('nightmare') + const hellCard = makeSummaryCard('hell') + const totalCard = makeSummaryCard('total') + summaryGrid.append( + normalCard.card, + nightmareCard.card, + hellCard.card, + totalCard.card, + ) + + // Filter & Actions Toolbar + const controlsBar = this.doc.createElement('section') + controlsBar.id = 'd2-stats-controls' + controlsBar.className = 'd2-stats__controls' + + const filtersGroup = this.doc.createElement('div') + filtersGroup.className = 'd2-stats__controls-group' + + const soloOnlyWrap = this.doc.createElement('label') + soloOnlyWrap.className = 'd2-stats__checkbox-label' + const soloOnlyInput = this.doc.createElement('input') + soloOnlyInput.type = 'checkbox' + soloOnlyInput.id = 'd2-stats-solo-only' + const soloOnlyLabelEl = this.doc.createElement('span') + soloOnlyLabelEl.id = 'd2-stats-solo-only-label' + soloOnlyWrap.append(soloOnlyInput, soloOnlyLabelEl) + + const showUnkilledWrap = this.doc.createElement('label') + showUnkilledWrap.className = 'd2-stats__checkbox-label' + const showUnkilledInput = this.doc.createElement('input') + showUnkilledInput.type = 'checkbox' + showUnkilledInput.id = 'd2-stats-show-unkilled' + showUnkilledInput.checked = true + const showUnkilledLabelEl = this.doc.createElement('span') + showUnkilledLabelEl.id = 'd2-stats-show-unkilled-label' + showUnkilledWrap.append(showUnkilledInput, showUnkilledLabelEl) + + filtersGroup.append(soloOnlyWrap, showUnkilledWrap) + + const actionsGroup = this.doc.createElement('div') + actionsGroup.className = 'd2-stats__controls-group' + + const langToggleBtn = this.doc.createElement('button') + langToggleBtn.type = 'button' + langToggleBtn.id = 'd2-stats-lang-toggle' + langToggleBtn.className = 'd2-stats__btn' + + const exportBtn = this.doc.createElement('button') + exportBtn.type = 'button' + exportBtn.id = 'd2-stats-export-btn' + exportBtn.className = 'd2-stats__btn' + + const importBtn = this.doc.createElement('button') + importBtn.type = 'button' + importBtn.id = 'd2-stats-import-btn' + importBtn.className = 'd2-stats__btn' + + const importInput = this.doc.createElement('input') + importInput.type = 'file' + importInput.id = 'd2-stats-import-input' + importInput.accept = '.json,application/json' + importInput.style.display = 'none' + + const deleteCharBtn = this.doc.createElement('button') + deleteCharBtn.type = 'button' + deleteCharBtn.id = 'd2-stats-delete-char-btn' + deleteCharBtn.className = 'd2-stats__btn d2-stats__btn--danger' + + const clearAllBtn = this.doc.createElement('button') + clearAllBtn.type = 'button' + clearAllBtn.id = 'd2-stats-clear-all-btn' + clearAllBtn.className = 'd2-stats__btn d2-stats__btn--danger' + + actionsGroup.append( + langToggleBtn, + exportBtn, + importBtn, + importInput, + deleteCharBtn, + clearAllBtn, + ) + controlsBar.append(filtersGroup, actionsGroup) + + // Empty State + const emptyStateEl = this.doc.createElement('section') + emptyStateEl.id = 'd2-stats-empty-state' + emptyStateEl.className = 'd2-stats__empty-state' + const emptyStateTitleEl = this.doc.createElement('h2') + emptyStateTitleEl.className = 'd2-stats__empty-title' + const emptyStateBodyEl = this.doc.createElement('p') + emptyStateBodyEl.className = 'd2-stats__empty-body' + emptyStateEl.append(emptyStateTitleEl, emptyStateBodyEl) + + // Section 1: 86-Row Boss & SuperUnique Kill Board + const boardSection = this.doc.createElement('section') + boardSection.id = 'd2-stats-board-section' + boardSection.className = 'd2-stats__section' + + const boardHeader = this.doc.createElement('div') + boardHeader.className = 'd2-stats__section-header' + const boardTitleWrap = this.doc.createElement('div') + const boardTitleEl = this.doc.createElement('h2') + boardTitleEl.id = 'd2-stats-board-title' + boardTitleEl.className = 'd2-stats__section-title' + const boardSubtitleEl = this.doc.createElement('div') + boardSubtitleEl.className = 'd2-stats__section-subtitle' + boardTitleWrap.append(boardTitleEl, boardSubtitleEl) + + const boardCountBadgeEl = this.doc.createElement('span') + boardCountBadgeEl.id = 'd2-stats-board-count' + boardCountBadgeEl.className = 'd2-stats__section-badge' + boardHeader.append(boardTitleWrap, boardCountBadgeEl) + + const boardTableWrap = this.doc.createElement('div') + boardTableWrap.className = 'd2-stats__table-wrap' + const boardTable = this.doc.createElement('table') + boardTable.id = 'd2-stats-board-table' + boardTable.className = 'd2-stats__table' + + const boardThead = this.doc.createElement('thead') + const boardHeadTr = this.doc.createElement('tr') + const boardColNameEl = this.doc.createElement('th') + const boardColKindEl = this.doc.createElement('th') + const boardColNormalEl = this.doc.createElement('th') + boardColNormalEl.className = 'd2-stats__num' + const boardColNightmareEl = this.doc.createElement('th') + boardColNightmareEl.className = 'd2-stats__num' + const boardColHellEl = this.doc.createElement('th') + boardColHellEl.className = 'd2-stats__num' + const boardColTotalEl = this.doc.createElement('th') + boardColTotalEl.className = 'd2-stats__num' + const boardColLastAtEl = this.doc.createElement('th') + boardHeadTr.append( + boardColNameEl, + boardColKindEl, + boardColNormalEl, + boardColNightmareEl, + boardColHellEl, + boardColTotalEl, + boardColLastAtEl, + ) + boardThead.appendChild(boardHeadTr) + + const boardTbodyEl = this.doc.createElement('tbody') + boardTbodyEl.id = 'd2-stats-board-tbody' + boardTable.append(boardThead, boardTbodyEl) + boardTableWrap.appendChild(boardTable) + boardSection.append(boardHeader, boardTableWrap) + + // Section 2: Monster Type × Difficulty Table + const monstersSection = this.doc.createElement('section') + monstersSection.id = 'd2-stats-monsters-section' + monstersSection.className = 'd2-stats__section' + + const monstersHeader = this.doc.createElement('div') + monstersHeader.className = 'd2-stats__section-header' + const monstersTitleWrap = this.doc.createElement('div') + monstersTitleWrap.className = 'd2-stats__title-row' + const monstersTitleEl = this.doc.createElement('h2') + monstersTitleEl.id = 'd2-stats-monsters-title' + monstersTitleEl.className = 'd2-stats__section-title' + const monstersCountBadgeEl = this.doc.createElement('span') + monstersCountBadgeEl.id = 'd2-stats-monsters-count' + monstersCountBadgeEl.className = 'd2-stats__section-badge' + monstersTitleWrap.append(monstersTitleEl, monstersCountBadgeEl) + + const monsterSearchInput = this.doc.createElement('input') + monsterSearchInput.type = 'search' + monsterSearchInput.id = 'd2-stats-monster-search' + monsterSearchInput.className = 'd2-stats__search-input' + + monstersHeader.append(monstersTitleWrap, monsterSearchInput) + + const monstersTableWrap = this.doc.createElement('div') + monstersTableWrap.className = 'd2-stats__table-wrap' + const monstersTable = this.doc.createElement('table') + monstersTable.id = 'd2-stats-monster-table' + monstersTable.className = 'd2-stats__table' + + const monstersThead = this.doc.createElement('thead') + const monstersHeadTr = this.doc.createElement('tr') + + const makeSortHeader = ( + col: MonsterSortColumn, + isNumeric: boolean, + ): { th: HTMLTableCellElement; btn: HTMLButtonElement } => { + const th = this.doc.createElement('th') + if (isNumeric) th.className = 'd2-stats__num' + const btn = this.doc.createElement('button') + btn.type = 'button' + btn.id = `d2-stats-sort-${col}` + btn.dataset.sortCol = col + btn.className = 'd2-stats__sort-btn' + th.appendChild(btn) + return { th, btn } + } + + const colHcIdx = makeSortHeader('hcIdx', true) + const colName = makeSortHeader('name', false) + const colNormal = makeSortHeader('normal', true) + const colNightmare = makeSortHeader('nightmare', true) + const colHell = makeSortHeader('hell', true) + const colTotal = makeSortHeader('total', true) + monstersHeadTr.append( + colHcIdx.th, + colName.th, + colNormal.th, + colNightmare.th, + colHell.th, + colTotal.th, + ) + monstersThead.appendChild(monstersHeadTr) + + const monstersTbodyEl = this.doc.createElement('tbody') + monstersTbodyEl.id = 'd2-stats-monster-tbody' + monstersTable.append(monstersThead, monstersTbodyEl) + monstersTableWrap.appendChild(monstersTable) + monstersSection.append(monstersHeader, monstersTableWrap) + + const footnoteEl = this.doc.createElement('footer') + footnoteEl.id = 'd2-stats-footnote' + footnoteEl.className = 'd2-stats__footnote' + + main.append( + errorBannerEl, + infoBannerEl, + summaryGrid, + controlsBar, + emptyStateEl, + boardSection, + monstersSection, + footnoteEl, + ) + + this.root.append(sidebar, main) + + return { + pageTitleEl, + backToGameLink, + sidebarTitleEl, + allAccountsBtn, + allAccountsLabelEl, + allAccountsCountEl, + accountTreeEl, + errorBannerEl, + infoBannerEl, + cardNormalLabelEl: normalCard.label, + cardNormalValEl: normalCard.val, + cardNightmareLabelEl: nightmareCard.label, + cardNightmareValEl: nightmareCard.val, + cardHellLabelEl: hellCard.label, + cardHellValEl: hellCard.val, + cardTotalLabelEl: totalCard.label, + cardTotalValEl: totalCard.val, + soloOnlyInput, + soloOnlyLabelEl, + showUnkilledInput, + showUnkilledLabelEl, + langToggleBtn, + exportBtn, + importBtn, + importInput, + deleteCharBtn, + clearAllBtn, + emptyStateEl, + emptyStateTitleEl, + emptyStateBodyEl, + boardTitleEl, + boardSubtitleEl, + boardCountBadgeEl, + boardColNameEl, + boardColKindEl, + boardColNormalEl, + boardColNightmareEl, + boardColHellEl, + boardColTotalEl, + boardColLastAtEl, + boardTbodyEl, + monstersTitleEl, + monstersCountBadgeEl, + monsterSearchInput, + monstersColHcIdxBtn: colHcIdx.btn, + monstersColNameBtn: colName.btn, + monstersColNormalBtn: colNormal.btn, + monstersColNightmareBtn: colNightmare.btn, + monstersColHellBtn: colHell.btn, + monstersColTotalBtn: colTotal.btn, + monstersTbodyEl, + footnoteEl, + } + } +} diff --git a/src/client/toolbar/toolbar.ts b/src/client/toolbar/toolbar.ts index 44982b1..ddb2779 100644 --- a/src/client/toolbar/toolbar.ts +++ b/src/client/toolbar/toolbar.ts @@ -121,6 +121,7 @@ export class Toolbar { private viewportLabelEl: HTMLElement | null = null private viewportSelectEl: HTMLSelectElement | null = null private inspectorBtnEl: HTMLButtonElement | null = null + private killStatsLinkEl: HTMLAnchorElement | null = null private cacheClearBtnEl: HTMLButtonElement | null = null private diagBannerEl: HTMLElement | null = null @@ -488,17 +489,20 @@ export class Toolbar { border-color: #c7b377; color: #fff2c6; } -.d2-toolbar__btn { +.d2-toolbar__btn, +a.d2-toolbar__btn { display: inline-flex; align-items: center; justify-content: center; height: 22px; padding: 0 8px; + box-sizing: border-box; background: linear-gradient(180deg, #2b2316 0%, #17120b 100%); border: 1px solid #574629; border-radius: 2px; color: #dfc991; font-size: 11px; + text-decoration: none; cursor: pointer; } .d2-toolbar__btn--active { @@ -563,8 +567,16 @@ export class Toolbar { 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(inspectorBtn) + statusGroup.appendChild(killStatsLink) // Group 2: Language Toggle (中 / EN) const langGroup = doc.createElement('div') @@ -676,6 +688,7 @@ export class Toolbar { this.viewportLabelEl = viewportLabel this.viewportSelectEl = viewportSelect this.inspectorBtnEl = inspectorBtn + this.killStatsLinkEl = killStatsLink this.cacheClearBtnEl = cacheClearBtn this.diagBannerEl = diagBanner @@ -702,6 +715,9 @@ export class Toolbar { ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn' } + if (this.killStatsLinkEl) { + this.killStatsLinkEl.textContent = tToolbar('toolbar.killStats.open', lang) + } if (this.langBtnZhEl) { this.langBtnZhEl.className = lang === 'zh' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn' diff --git a/src/common/units/monster-kill-classes.ts b/src/common/units/monster-kill-classes.ts new file mode 100644 index 0000000..8298521 --- /dev/null +++ b/src/common/units/monster-kill-classes.ts @@ -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 = new Set([ + 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 = new Set([ + 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 +} diff --git a/stats.html b/stats.html new file mode 100644 index 0000000..a1e33b6 --- /dev/null +++ b/stats.html @@ -0,0 +1,433 @@ + + + + + + Diablo II: Lord of Destruction · 击杀统计 (Kill Statistics) + + + +
+ + + diff --git a/tests/arch/boundaries.test.ts b/tests/arch/boundaries.test.ts index b063bf3..cbbcf1c 100644 --- a/tests/arch/boundaries.test.ts +++ b/tests/arch/boundaries.test.ts @@ -178,15 +178,27 @@ describe('Architectural Boundaries & 5-Module Isolation (R3, F9 — Zero Whiteli 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) const playHtml = readFileSync(resolve(ROOT_DIR, 'play.html'), 'utf8') expect(playHtml).toContain('/src/client/main.ts') expect(playHtml).toContain('D2Exocet') expect(playHtml).toContain('D2Formal436') + expect(existsSync(resolve(ROOT_DIR, 'stats.html'))).toBe(true) + const statsHtml = readFileSync(resolve(ROOT_DIR, 'stats.html'), 'utf8') + expect(statsHtml).toContain('/src/client/stats/stats-main.ts') + expect(statsHtml).toContain('D2Exocet') + expect(statsHtml).toContain('D2Formal436') + const viteCfg = readFileSync(resolve(ROOT_DIR, 'vite.config.ts'), 'utf8') expect(viteCfg).toContain('play.html') + expect(viteCfg).toContain('stats.html') for (const legacyHtml of LEGACY_HTML_FILES) { expect(existsSync(resolve(ROOT_DIR, legacyHtml))).toBe(false) diff --git a/tests/client/kill-stats-model.test.ts b/tests/client/kill-stats-model.test.ts new file mode 100644 index 0000000..6390ee8 --- /dev/null +++ b/tests/client/kill-stats-model.test.ts @@ -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 { + 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 { + const profile = makeCharProfile(overrides) + return { + ...profile, + firstSeenAt: overrides.firstSeenAt ?? 1000, + lastSeenAt: overrides.lastSeenAt ?? 2000, + } +} + +function makeTallyRecord( + overrides: Partial & { 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) + }) + } +}) diff --git a/tests/client/kill-stats-online.test.ts b/tests/client/kill-stats-online.test.ts new file mode 100644 index 0000000..2262288 --- /dev/null +++ b/tests/client/kill-stats-online.test.ts @@ -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() + }) +}) diff --git a/tests/client/kill-stats-view.test.ts b/tests/client/kill-stats-view.test.ts new file mode 100644 index 0000000..04494e0 --- /dev/null +++ b/tests/client/kill-stats-view.test.ts @@ -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 & { + 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]) + }) +}) diff --git a/tests/client/kill-stats/memory-repository.ts b/tests/client/kill-stats/memory-repository.ts new file mode 100644 index 0000000..21736c1 --- /dev/null +++ b/tests/client/kill-stats/memory-repository.ts @@ -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 { + return this.state + } + + async applyBatch(batch: KillStatsBatchInput): Promise + async applyBatch( + character: KillCharacterProfile, + deltas: readonly KillTallyDelta[], + at?: number, + ): Promise + async applyBatch( + replicaId: string, + character: KillCharacterProfile, + deltas: readonly KillTallyDelta[], + at?: number, + ): Promise + async applyBatch( + arg0: KillStatsBatchInput | KillCharacterProfile | string, + arg1?: readonly KillTallyDelta[] | KillCharacterProfile, + arg2?: number | readonly KillTallyDelta[], + arg3?: number, + ): Promise { + 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 { + return buildExportPayload(this.state, exportedAt ?? this.now()) + } + + async importPayload(raw: unknown): Promise { + 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 { + 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 { + const nextReplicaId = this.rotateReplicaId() + this.state = { + replicaId: nextReplicaId, + characters: [], + tallies: [], + } + } +} diff --git a/tests/client/kill-tracker.test.ts b/tests/client/kill-tracker.test.ts new file mode 100644 index 0000000..1f1b50e --- /dev/null +++ b/tests/client/kill-tracker.test.ts @@ -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 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 void>>() + private readonly docListeners = new Map 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() + }) +}) diff --git a/tests/client/settings-toolbar-view.test.ts b/tests/client/settings-toolbar-view.test.ts index 1e15d13..e670aeb 100644 --- a/tests/client/settings-toolbar-view.test.ts +++ b/tests/client/settings-toolbar-view.test.ts @@ -280,6 +280,10 @@ describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => { expect(store.get().inspectorOpen).toBe(true) expect(inspectorSpy).toHaveBeenCalledWith(true) + // Kill Stats link i18n + expect(tToolbar('toolbar.killStats.open', 'zh')).toBe('击杀统计') + expect(tToolbar('toolbar.killStats.open', 'en')).toBe('Kill Stats') + toolbar.destroy() setLang('zh') }) diff --git a/tests/common/monster-kill-classes.test.ts b/tests/common/monster-kill-classes.test.ts new file mode 100644 index 0000000..762af03 --- /dev/null +++ b/tests/common/monster-kill-classes.test.ts @@ -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() + const mpqNeverCount = new Set() + 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), + ) + }) + }) +}) diff --git a/tests/e2e-module-split/tier1-feature-coverage.test.ts b/tests/e2e-module-split/tier1-feature-coverage.test.ts index 921a195..f7d505c 100644 --- a/tests/e2e-module-split/tier1-feature-coverage.test.ts +++ b/tests/e2e-module-split/tier1-feature-coverage.test.ts @@ -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') expect(viteCfg).toContain('play.html') + expect(viteCfg).toContain('stats.html') for (const legacyHtml of LEGACY_HTML_FILES) { expect(viteCfg).not.toContain(legacyHtml) } diff --git a/tests/e2e-module-split/tier4-real-world-scenarios.test.ts b/tests/e2e-module-split/tier4-real-world-scenarios.test.ts index 6176f7d..9d86a6b 100644 --- a/tests/e2e-module-split/tier4-real-world-scenarios.test.ts +++ b/tests/e2e-module-split/tier4-real-world-scenarios.test.ts @@ -218,7 +218,7 @@ describe('Tier 4: Real-World End-to-End Application Scenarios', () => { } }, 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-')) try { 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, 'stats.html'))).toBe(true) for (const legacyHtml of LEGACY_HTML_FILES) { expect( existsSync(join(tmpOutDir, legacyHtml)), diff --git a/tests/issue-504-exocet-fonts.test.ts b/tests/issue-504-exocet-fonts.test.ts index 0b01ffc..386661b 100644 --- a/tests/issue-504-exocet-fonts.test.ts +++ b/tests/issue-504-exocet-fonts.test.ts @@ -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', () => { - const htmlFiles = ['play.html'] + const htmlFiles = ['play.html', 'stats.html'] for (const file of htmlFiles) { const content = readFileSync(join(process.cwd(), file), 'utf8') expect(content, `${file} must declare D2Exocet @font-face`).toMatch(/font-family:\s*['"]D2Exocet['"]/) @@ -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) const compactLabels = [ - 'Left Attack', - 'Right Attack', + 'Attack', 'Damage', 'Stat Points', 'Remaining', - 'Fire Resistance', - 'Cold Resistance', + 'Fire', + 'Cold', 'Lightning', + 'Poison', 'Resistance', - 'Poison Resistance', ] for (const labelText of compactLabels) { 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 === 'Experience')?.x).toBe(80 + 124) 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 Resistance')?.align).toBe('center') + expect(drawnLabels.find(d => d.text === 'Fire')?.x).toBe(80 + 222) + 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 === 'Dexterity')?.y).toBe(60 + 156) 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, ) - for (const zhRes of ['火焰抗性', '冰冷抗性', '闪电抗性', '毒素抗性']) { + for (const zhRes of ['火焰抵抗力', '冰冷抵抗力', '閃電抵抗力', '毒素抵抗力']) { const match = drawnLabels.find(d => d.text === zhRes) expect(match?.x).toBe(80 + 222) 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'), ) 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 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('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) - // 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(drawImageCalls.length).toBeGreaterThan(150) expect(worldPanels.lastTooltipRect).not.toBeNull() 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'), ) }) }) diff --git a/tools/verify-kill-stats-browser.ts b/tools/verify-kill-stats-browser.ts new file mode 100644 index 0000000..0523c15 --- /dev/null +++ b/tools/verify-kill-stats-browser.ts @@ -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 { + 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 { + const ws = new WebSocket(wsUrl) + await new Promise((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 = {}): Promise { + 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(expression: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 | 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(window.__d2PlayApp && window.__d2PlayApp.ready)') + if (ready) break + } + const playReady = await playCdp.evaluate('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(window.__d2StatsApp && window.__d2StatsApp.ready)') + if (ready) break + } + const statsReady = await statsCdp.evaluate('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( + `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(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) +}) diff --git a/vite.config.ts b/vite.config.ts index 2b6292c..7bb0f5f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from 'vite' 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({ plugins: [ @@ -51,6 +51,7 @@ export default defineConfig({ rollupOptions: { input: { play: resolve(__dirname, 'play.html'), + stats: resolve(__dirname, 'stats.html'), sw: resolve(__dirname, 'src/client/sw/sw.ts'), }, output: {