diablo2-web/plugins/kill-stats/kill-tracker.ts

801 lines
23 KiB
TypeScript

/**
* 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 type {
PluginLogger,
PluginSessionView,
} from '../../src/client/plugin-host/api.ts'
import { getMonsterKindByClassId } from '../../src/common/units/monsters-data.ts'
import {
unitTypeFromId,
type CharClassId,
type ServerEvent,
} from '../../src/netproto/index.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'
import { isCountableKillClass } from './monster-kill-classes.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 type SessionIdentityResolution =
| { readonly ok: true; readonly profile: KillCharacterProfile }
| { readonly ok: false; readonly reason: string }
export interface KillTrackerStatus {
readonly pendingKills: number
readonly pendingDeltasCount: number
readonly lastFlushAt: number | null
readonly lastError: string | null
readonly lastSkipReason: string | null
readonly difficulty: KillDifficulty | null
}
export interface KillTrackerOptions {
readonly session: PluginSessionView
readonly repository?: KillStatsRepository | undefined
readonly resolveIdentity?:
| ((session: PluginSessionView) => KillCharacterProfile | SessionIdentityResolution | null)
| undefined
readonly log?: PluginLogger | undefined
readonly onStatusChange?: (() => void) | undefined
readonly now?: (() => number) | undefined
readonly scheduler?: KillTrackerScheduler | undefined
readonly channelFactory?: ((name: string) => BroadcastChannelLike | null) | undefined
readonly windowLike?: KillTrackerWindowLike | null | undefined
readonly flushDelayMs?: number | undefined
readonly maxBackoffMs?: number | undefined
}
interface TrackedMonsterUnit {
readonly classId: number
readonly isSuperUnique: boolean
readonly superUniqueId: number | undefined
state: 'armed' | 'dead'
}
interface PendingCharacterBatch {
profile: KillCharacterProfile
readonly deltas: Map<string, KillTallyDelta>
}
/**
* Resolves the active 4-tuple character identity `(host, realm, account, charName)` and
* character profile metadata from `PluginSessionView`.
*
* Fail-fast invariant: NEVER default `charClass` to `0`, `level` to `1`, or
* `hardcore`/`expansion`/`ladder` to guessed values. Requires `hasGameFlags === true`,
* valid integer `charClass` in `0..6`, and finite integer `level >= 1`.
*/
export function resolveSessionIdentity(
session: PluginSessionView,
hasGameFlags = true,
): SessionIdentityResolution {
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 {
ok: false,
reason: 'incomplete character identity (host, realm, account, charName)',
}
}
if (!hasGameFlags) {
return {
ok: false,
reason: 'GameFlags (0x01) difficulty has not been received in this game',
}
}
const lowerName = charName.toLowerCase()
const matchedChar = snap.characters.find(
(c) => c.name.trim().toLowerCase() === lowerName,
)
const selfClass = session.world.self.charClass
let charClass: CharClassId | null = null
if (
typeof selfClass === 'number' &&
Number.isInteger(selfClass) &&
selfClass >= 0 &&
selfClass <= 6
) {
charClass = selfClass as CharClassId
} else if (
matchedChar &&
typeof matchedChar.charClass === 'number' &&
Number.isInteger(matchedChar.charClass) &&
matchedChar.charClass >= 0 &&
matchedChar.charClass <= 6
) {
charClass = matchedChar.charClass as CharClassId
}
if (charClass === null) {
return {
ok: false,
reason: 'missing or invalid character class (expected integer 0..6)',
}
}
const selfLevel = session.world.self.level
let rawLevel: number | null = null
if (
typeof selfLevel === 'number' &&
Number.isFinite(selfLevel) &&
Math.trunc(selfLevel) >= 1
) {
rawLevel = Math.trunc(selfLevel)
} else if (
matchedChar &&
typeof matchedChar.level === 'number' &&
Number.isFinite(matchedChar.level) &&
Math.trunc(matchedChar.level) >= 1
) {
rawLevel = Math.trunc(matchedChar.level)
}
if (rawLevel === null) {
return {
ok: false,
reason: 'missing or invalid character level (expected finite integer >= 1)',
}
}
const level = Math.min(99, rawLevel)
if (
typeof session.world.hardcore !== 'boolean' ||
typeof session.world.expansion !== 'boolean' ||
typeof session.world.ladder !== 'boolean'
) {
return {
ok: false,
reason: 'missing authoritative world mode flags (hardcore, expansion, ladder)',
}
}
const charKey = computeCharKey(host, realm, account, charName)
return {
ok: true,
profile: {
charKey,
host,
realm,
account,
charName,
charClass,
level,
hardcore: session.world.hardcore,
expansion: session.world.expansion,
ladder: session.world.ladder,
},
}
}
const DEFAULT_SCHEDULER: KillTrackerScheduler = {
setTimeout: (cb, delayMs) => globalThis.setTimeout(cb, delayMs),
clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),
}
const DEFAULT_LOGGER: PluginLogger = {
info: (msg, ...args) => console.info(`[KillTracker] ${msg}`, ...args),
warn: (msg, ...args) => console.warn(`[KillTracker] ${msg}`, ...args),
error: (msg, ...args) => console.error(`[KillTracker] ${msg}`, ...args),
}
function defaultChannelFactory(name: string): BroadcastChannelLike | null {
if (typeof globalThis.BroadcastChannel !== 'function') return null
try {
return new globalThis.BroadcastChannel(name)
} catch {
return null
}
}
export class KillTracker {
readonly repository: KillStatsRepository
private readonly session: PluginSessionView
private readonly resolveIdentityFn: (
session: PluginSessionView,
) => KillCharacterProfile | SessionIdentityResolution | null
private readonly log: PluginLogger
private readonly onStatusChange: (() => void) | undefined
private readonly now: () => number
private readonly scheduler: KillTrackerScheduler
private readonly channel: BroadcastChannelLike | null
private readonly windowLike: KillTrackerWindowLike | null
private readonly flushDelayMs: number
private readonly maxBackoffMs: number
private difficulty: KillDifficulty | null = null
private readonly units = new Map<number, TrackedMonsterUnit>()
private readonly owned = new Set<number>()
private warnedMissingGameFlags = false
private warnedMissingIdentity = false
private lastSkipReason: string | null = null
private lastPhase: string
private readonly pendingByChar = new Map<string, PendingCharacterBatch>()
private flushTimer: unknown = null
private retryAttempts = 0
private lastFlushAt: number | null = null
private lastError: string | null = null
private inFlightFlush: Promise<void> | null = null
private readonly unsubServerEvent: () => void
private readonly unsubStateChange: () => void
private readonly onVisibilityChangeBound: () => void
private readonly onPageHideBound: () => void
constructor(options: KillTrackerOptions) {
this.session = options.session
this.now = options.now ?? (() => Date.now())
this.repository =
options.repository ?? new IndexedDbKillStatsRepository({ now: this.now })
this.resolveIdentityFn =
options.resolveIdentity ??
((s) => resolveSessionIdentity(s, this.difficulty !== null))
this.log = options.log ?? DEFAULT_LOGGER
this.onStatusChange = options.onStatusChange
this.scheduler = options.scheduler ?? DEFAULT_SCHEDULER
const createChannel = options.channelFactory ?? defaultChannelFactory
this.channel = createChannel(KILL_STATS_CHANNEL_NAME)
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,
lastSkipReason: this.lastSkipReason,
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}`
this.log.error(`${prefix}:`, err)
this.onStatusChange?.()
}
private resetPerGameState(): void {
this.difficulty = null
this.units.clear()
this.owned.clear()
this.warnedMissingGameFlags = false
this.warnedMissingIdentity = false
this.lastSkipReason = null
}
private handlePhaseChange(nextPhase: string): void {
const prevPhase = this.lastPhase
this.lastPhase = nextPhase
if (prevPhase !== 'ingame' && nextPhase === 'ingame') {
this.resetPerGameState()
this.onStatusChange?.()
} else if (prevPhase === 'ingame' && nextPhase !== 'ingame') {
this.resetPerGameState()
this.onStatusChange?.()
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
}
this.onStatusChange?.()
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 evaluateIdentity(): SessionIdentityResolution {
const raw = this.resolveIdentityFn(this.session)
if (!raw) {
return {
ok: false,
reason: 'incomplete character identity (host, realm, account, charName)',
}
}
if ('ok' in raw) {
return raw
}
if (
!raw.host.trim() ||
!raw.realm.trim() ||
!raw.account.trim() ||
!raw.charName.trim()
) {
return {
ok: false,
reason: 'incomplete character identity (host, realm, account, charName)',
}
}
return { ok: true, profile: raw }
}
private recordKill(unit: TrackedMonsterUnit): void {
if (this.difficulty === null) {
const reason = 'GameFlags (0x01) difficulty has not been received in this game'
const changed = this.lastSkipReason !== reason
this.lastSkipReason = reason
if (!this.warnedMissingGameFlags) {
this.warnedMissingGameFlags = true
this.log.warn(`Skipping monster kill count: ${reason}`)
}
if (changed) {
this.onStatusChange?.()
}
return
}
const identityRes = this.evaluateIdentity()
if (!identityRes.ok) {
const changed = this.lastSkipReason !== identityRes.reason
this.lastSkipReason = identityRes.reason
if (!this.warnedMissingIdentity) {
this.warnedMissingIdentity = true
this.log.warn(`Skipping monster kill count: ${identityRes.reason}`)
}
if (changed) {
this.onStatusChange?.()
}
return
}
const profile = identityRes.profile
if (this.lastSkipReason !== null) {
this.lastSkipReason = null
this.onStatusChange?.()
}
if (!isCountableKillClass(unit.classId)) {
return
}
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
this.log.error(msg)
this.onStatusChange?.()
}
} else if (kind.boss === true) {
this.enqueueDelta(profile, 'boss', difficulty, unit.classId, isSolo, at)
}
if (this.flushTimer === null) {
this.scheduleFlush(this.flushDelayMs)
}
}
private enqueueDelta(
profile: KillCharacterProfile,
kind: KillTallyKind,
difficulty: KillDifficulty,
subject: number,
isSolo: boolean,
at: number,
): void {
let charBatch = this.pendingByChar.get(profile.charKey)
if (!charBatch) {
charBatch = {
profile,
deltas: new Map<string, KillTallyDelta>(),
}
this.pendingByChar.set(profile.charKey, charBatch)
} else {
charBatch.profile = profile
}
const deltaKey = computeDeltaKey(profile.charKey, kind, difficulty, subject)
const existing = charBatch.deltas.get(deltaKey)
if (!existing) {
charBatch.deltas.set(deltaKey, {
charKey: profile.charKey,
kind,
difficulty,
subject,
count: 1,
soloCount: isSolo ? 1 : 0,
firstAt: at,
lastAt: at,
})
} else {
charBatch.deltas.set(deltaKey, {
charKey: profile.charKey,
kind,
difficulty,
subject,
count: existing.count + 1,
soloCount: existing.soloCount + (isSolo ? 1 : 0),
firstAt: Math.min(existing.firstAt, at),
lastAt: Math.max(existing.lastAt, at),
})
}
}
private mergeBatchBackOnFailure(failedBatch: PendingCharacterBatch): void {
let currentBatch = this.pendingByChar.get(failedBatch.profile.charKey)
if (!currentBatch) {
currentBatch = {
profile: failedBatch.profile,
deltas: new Map<string, KillTallyDelta>(),
}
this.pendingByChar.set(failedBatch.profile.charKey, currentBatch)
}
for (const [deltaKey, failedDelta] of failedBatch.deltas.entries()) {
const existing = currentBatch.deltas.get(deltaKey)
if (!existing) {
currentBatch.deltas.set(deltaKey, failedDelta)
} else {
currentBatch.deltas.set(deltaKey, {
charKey: failedDelta.charKey,
kind: failedDelta.kind,
difficulty: failedDelta.difficulty,
subject: failedDelta.subject,
count: failedDelta.count + existing.count,
soloCount: failedDelta.soloCount + existing.soloCount,
firstAt: Math.min(failedDelta.firstAt, existing.firstAt),
lastAt: Math.max(failedDelta.lastAt, existing.lastAt),
})
}
}
}
private clearFlushTimer(): void {
if (this.flushTimer !== null) {
this.scheduler.clearTimeout(this.flushTimer)
this.flushTimer = null
}
}
private scheduleFlush(delayMs: number): void {
this.clearFlushTimer()
this.flushTimer = this.scheduler.setTimeout(() => {
this.flushTimer = null
void this.flushNow()
}, delayMs)
}
async flushNow(): Promise<void> {
this.clearFlushTimer()
if (this.inFlightFlush) {
await this.inFlightFlush
}
if (this.pendingByChar.size === 0) {
return
}
const run = async (): Promise<void> => {
const batches = [...this.pendingByChar.values()]
this.pendingByChar.clear()
const flushAt = this.now()
let anySucceeded = false
let failedError: unknown = null
for (let i = 0; i < batches.length; i++) {
const batch = batches[i]!
const deltas = [...batch.deltas.values()]
if (deltas.length === 0) continue
try {
await this.repository.applyBatch({
character: batch.profile,
deltas,
at: flushAt,
})
anySucceeded = true
} catch (err) {
failedError = err
for (let j = i; j < batches.length; j++) {
this.mergeBatchBackOnFailure(batches[j]!)
}
break
}
}
if (anySucceeded) {
this.lastFlushAt = flushAt
try {
const msg: KillStatsBroadcastMessage = {
type: 'updated',
at: flushAt,
}
this.channel?.postMessage(msg)
} catch {
// Ignore BroadcastChannel errors
}
}
if (failedError !== null) {
this.retryAttempts++
const msg =
failedError instanceof Error ? failedError.message : String(failedError)
this.lastError = msg
this.log.error('Failed to flush kill stats batch:', failedError)
this.onStatusChange?.()
const backoffMs = Math.min(
this.maxBackoffMs,
this.flushDelayMs * Math.pow(2, this.retryAttempts - 1),
)
this.scheduleFlush(backoffMs)
} else {
this.retryAttempts = 0
if (anySucceeded) {
this.onStatusChange?.()
}
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
}
}
}
}