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

842 lines
23 KiB
TypeScript

/**
* Pure data model, key normalization, G-counter merge, export builder, and strict import
* validation for per-account, per-character monster kill statistics.
*/
import type { CharClassId } from '../../src/netproto/index.ts'
import {
isCountableBossClass,
isCountableKillClass,
} from './monster-kill-classes.ts'
export const KILL_STATS_FORMAT = 'd2web-kill-stats' as const
export const KILL_STATS_VERSION = 1 as const
export const KILL_STATS_DB_NAME = 'd2web-kill-stats' as const
export const KILL_STATS_DB_VERSION = 1 as const
export const KILL_STATS_CHANNEL_NAME = 'd2web-kill-stats' as const
export const SUPERUNIQUE_COUNT = 66 as const
export type KillDifficulty = 0 | 1 | 2
export type KillTallyKind = 'monster' | 'boss' | 'superunique'
export interface KillCharacterProfile {
readonly charKey: string
readonly host: string
readonly realm: string
readonly account: string
readonly charName: string
readonly charClass: CharClassId
readonly level: number
readonly hardcore: boolean
readonly expansion: boolean
readonly ladder: boolean
}
export interface KillCharacterRecord extends KillCharacterProfile {
readonly firstSeenAt: number
readonly lastSeenAt: number
}
export interface KillTallyDelta {
readonly charKey: string
readonly kind: KillTallyKind
readonly difficulty: KillDifficulty
readonly subject: number
readonly count: number
readonly soloCount: number
readonly firstAt: number
readonly lastAt: number
}
export interface KillTallyRecord extends KillTallyDelta {
readonly id: string
readonly replicaId: string
}
export interface KillStatsSnapshot {
readonly replicaId: string
readonly characters: readonly KillCharacterRecord[]
readonly tallies: readonly KillTallyRecord[]
}
export interface KillStatsExportPayload {
readonly format: typeof KILL_STATS_FORMAT
readonly version: typeof KILL_STATS_VERSION
readonly exportedAt: number
readonly replicaId: string
readonly characters: readonly KillCharacterRecord[]
readonly tallies: readonly KillTallyRecord[]
}
export interface KillStatsImportSummary {
readonly addedCharacters: number
readonly updatedCharacters: number
readonly addedTallies: number
readonly updatedTallies: number
readonly unchangedTallies: number
}
export interface KillStatsBroadcastMessage {
readonly type: 'updated'
readonly at: number
}
export class KillStatsImportError extends Error {
readonly path: string
constructor(path: string, message: string) {
super(`${path}: ${message}`)
this.name = 'KillStatsImportError'
this.path = path
}
}
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function isValidUuid(value: unknown): value is string {
return typeof value === 'string' && UUID_REGEX.test(value)
}
/**
* Generates a RFC 4122 v4 UUID using `crypto.getRandomValues` so it works in insecure `http://`
* contexts where `crypto.randomUUID` is unavailable.
*/
export function generateReplicaId(
getRandomValues?: (array: Uint8Array) => Uint8Array,
): string {
const bytes = new Uint8Array(16)
if (getRandomValues) {
getRandomValues(bytes)
} else if (
typeof globalThis.crypto !== 'undefined' &&
typeof globalThis.crypto.getRandomValues === 'function'
) {
globalThis.crypto.getRandomValues(bytes)
} else {
throw new Error('crypto.getRandomValues is required to generate replicaId')
}
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex: string[] = []
for (let i = 0; i < 16; i++) {
hex.push(bytes[i]!.toString(16).padStart(2, '0'))
}
return (
`${hex[0]}${hex[1]}${hex[2]}${hex[3]}-` +
`${hex[4]}${hex[5]}-` +
`${hex[6]}${hex[7]}-` +
`${hex[8]}${hex[9]}-` +
`${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`
)
}
/**
* Computes canonical character key:
* `JSON.stringify([host, realm, account, charName].map(s => s.trim().toLowerCase()))`
*/
export function computeCharKey(
host: string,
realm: string,
account: string,
charName: string,
): string {
return JSON.stringify([host, realm, account, charName].map((s) => s.trim().toLowerCase()))
}
/**
* Computes canonical per-replica G-counter tally key:
* `JSON.stringify([replicaId, charKey, kind, difficulty, subject])`
*/
export function computeTallyId(
replicaId: string,
charKey: string,
kind: KillTallyKind,
difficulty: KillDifficulty,
subject: number,
): string {
return JSON.stringify([replicaId, charKey, kind, difficulty, subject])
}
/**
* Computes replica-independent delta aggregation key:
* `JSON.stringify([charKey, kind, difficulty, subject])`
*/
export function computeDeltaKey(
charKey: string,
kind: KillTallyKind,
difficulty: KillDifficulty,
subject: number,
): string {
return JSON.stringify([charKey, kind, difficulty, subject])
}
/**
* G-counter merge of two tally records with the same `id`:
* - `count`: `Math.max(a.count, b.count)`
* - `soloCount`: `Math.max(a.soloCount, b.soloCount)`
* - `firstAt`: `Math.min(a.firstAt, b.firstAt)`
* - `lastAt`: `Math.max(a.lastAt, b.lastAt)`
*/
export function mergeTallyRecords(a: KillTallyRecord, b: KillTallyRecord): KillTallyRecord {
if (a.id !== b.id) {
throw new Error(`Cannot merge tally records with different ids: ${a.id} vs ${b.id}`)
}
const count = Math.max(a.count, b.count)
const soloCount = Math.min(count, Math.max(a.soloCount, b.soloCount))
return {
id: a.id,
replicaId: a.replicaId,
charKey: a.charKey,
kind: a.kind,
difficulty: a.difficulty,
subject: a.subject,
count,
soloCount,
firstAt: Math.min(a.firstAt, b.firstAt),
lastAt: Math.max(a.lastAt, b.lastAt),
}
}
function characterTieBreakKey(c: KillCharacterRecord): string {
return JSON.stringify([
c.level,
c.charClass,
c.hardcore ? 1 : 0,
c.expansion ? 1 : 0,
c.ladder ? 1 : 0,
c.host,
c.realm,
c.account,
c.charName,
])
}
/**
* Last-writer-wins (LWW) merge of two character records with the same `charKey` by `lastSeenAt`
* (with deterministic tie-breaking on equal `lastSeenAt` and `Math.min` on `firstSeenAt`).
*/
export function mergeCharacterRecords(
a: KillCharacterRecord,
b: KillCharacterRecord,
): KillCharacterRecord {
if (a.charKey !== b.charKey) {
throw new Error(
`Cannot merge character records with different charKeys: ${a.charKey} vs ${b.charKey}`,
)
}
let winner: KillCharacterRecord
if (b.lastSeenAt > a.lastSeenAt) {
winner = b
} else if (a.lastSeenAt > b.lastSeenAt) {
winner = a
} else {
winner = characterTieBreakKey(b) > characterTieBreakKey(a) ? b : a
}
return {
charKey: winner.charKey,
host: winner.host,
realm: winner.realm,
account: winner.account,
charName: winner.charName,
charClass: winner.charClass,
level: winner.level,
hardcore: winner.hardcore,
expansion: winner.expansion,
ladder: winner.ladder,
firstSeenAt: Math.min(a.firstSeenAt, b.firstSeenAt),
lastSeenAt: Math.max(a.lastSeenAt, b.lastSeenAt),
}
}
export function upsertCharacterRecord(
existing: KillCharacterRecord | undefined,
profile: KillCharacterProfile,
at: number,
): KillCharacterRecord {
const incoming: KillCharacterRecord = {
...profile,
firstSeenAt: at,
lastSeenAt: at,
}
if (!existing) return incoming
if (at >= existing.lastSeenAt) {
return {
...profile,
firstSeenAt: Math.min(existing.firstSeenAt, at),
lastSeenAt: Math.max(existing.lastSeenAt, at),
}
}
return mergeCharacterRecords(existing, incoming)
}
export function applyDeltaToTallyRecord(
existing: KillTallyRecord | undefined,
replicaId: string,
delta: KillTallyDelta,
): KillTallyRecord {
const id = computeTallyId(
replicaId,
delta.charKey,
delta.kind,
delta.difficulty,
delta.subject,
)
if (!existing) {
return {
id,
replicaId,
charKey: delta.charKey,
kind: delta.kind,
difficulty: delta.difficulty,
subject: delta.subject,
count: delta.count,
soloCount: delta.soloCount,
firstAt: delta.firstAt,
lastAt: delta.lastAt,
}
}
const count = existing.count + delta.count
const soloCount = Math.min(count, existing.soloCount + delta.soloCount)
return {
id,
replicaId,
charKey: delta.charKey,
kind: delta.kind,
difficulty: delta.difficulty,
subject: delta.subject,
count,
soloCount,
firstAt: Math.min(existing.firstAt, delta.firstAt),
lastAt: Math.max(existing.lastAt, delta.lastAt),
}
}
/**
* Pure state transition applying a batch of in-game kill deltas for `character` onto `state`.
*/
export function applyDeltasToState(
state: KillStatsSnapshot,
character: KillCharacterProfile,
deltas: readonly KillTallyDelta[],
at?: number,
): KillStatsSnapshot {
const charMap = new Map<string, KillCharacterRecord>()
for (const c of state.characters) {
charMap.set(c.charKey, c)
}
let latestDeltaAt = at ?? 0
for (const d of deltas) {
if (d.lastAt > latestDeltaAt) latestDeltaAt = d.lastAt
}
const effectiveAt =
at !== undefined ? at : latestDeltaAt > 0 ? latestDeltaAt : 0
charMap.set(
character.charKey,
upsertCharacterRecord(charMap.get(character.charKey), character, effectiveAt),
)
const tallyMap = new Map<string, KillTallyRecord>()
for (const t of state.tallies) {
tallyMap.set(t.id, t)
}
for (const delta of deltas) {
if (delta.count <= 0) continue
const id = computeTallyId(
state.replicaId,
delta.charKey,
delta.kind,
delta.difficulty,
delta.subject,
)
const next = applyDeltaToTallyRecord(tallyMap.get(id), state.replicaId, delta)
tallyMap.set(id, next)
}
return {
replicaId: state.replicaId,
characters: [...charMap.values()].sort((a, b) =>
a.charKey < b.charKey ? -1 : a.charKey > b.charKey ? 1 : 0,
),
tallies: [...tallyMap.values()].sort((a, b) =>
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
),
}
}
/**
* Builds a deterministically sorted export payload (`characters` by `charKey`, `tallies` by `id`).
*/
export function buildExportPayload(
snapshot: KillStatsSnapshot,
exportedAt: number,
): KillStatsExportPayload {
const characters = [...snapshot.characters].sort((a, b) =>
a.charKey < b.charKey ? -1 : a.charKey > b.charKey ? 1 : 0,
)
const tallies = [...snapshot.tallies].sort((a, b) =>
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
)
return {
format: KILL_STATS_FORMAT,
version: KILL_STATS_VERSION,
exportedAt,
replicaId: snapshot.replicaId,
characters,
tallies,
}
}
function isRecordObject(val: unknown): val is Record<string, unknown> {
return typeof val === 'object' && val !== null && !Array.isArray(val)
}
function isValidCharClass(val: unknown): val is CharClassId {
return (
typeof val === 'number' &&
Number.isInteger(val) &&
val >= 0 &&
val <= 6
)
}
function isValidDifficulty(val: unknown): val is KillDifficulty {
return val === 0 || val === 1 || val === 2
}
function isValidTallyKind(val: unknown): val is KillTallyKind {
return val === 'monster' || val === 'boss' || val === 'superunique'
}
/**
* Strictly validates an entire import payload before any state mutation occurs.
* Throws `KillStatsImportError` with the exact offending field path on any violation.
*/
export function validateImportPayload(rawInput: unknown): KillStatsExportPayload {
let raw: unknown = rawInput
if (typeof rawInput === 'string') {
try {
raw = JSON.parse(rawInput)
} catch {
throw new KillStatsImportError('$', 'Invalid JSON syntax')
}
}
if (!isRecordObject(raw)) {
throw new KillStatsImportError('$', 'Import payload must be a JSON object')
}
if (raw.format !== KILL_STATS_FORMAT) {
throw new KillStatsImportError(
'format',
`Expected format "${KILL_STATS_FORMAT}", got ${JSON.stringify(raw.format)}`,
)
}
if (raw.version !== KILL_STATS_VERSION) {
throw new KillStatsImportError(
'version',
`Expected version ${KILL_STATS_VERSION}, got ${JSON.stringify(raw.version)}`,
)
}
if (
typeof raw.exportedAt !== 'number' ||
!Number.isFinite(raw.exportedAt) ||
raw.exportedAt < 0
) {
throw new KillStatsImportError(
'exportedAt',
'exportedAt must be a non-negative finite number',
)
}
if (!isValidUuid(raw.replicaId)) {
throw new KillStatsImportError(
'replicaId',
'replicaId must be a valid UUID string',
)
}
if (!Array.isArray(raw.characters)) {
throw new KillStatsImportError('characters', 'characters must be an array')
}
if (!Array.isArray(raw.tallies)) {
throw new KillStatsImportError('tallies', 'tallies must be an array')
}
const seenCharKeys = new Set<string>()
const validatedCharacters: KillCharacterRecord[] = []
for (let i = 0; i < raw.characters.length; i++) {
const c = raw.characters[i]
const basePath = `characters[${i}]`
if (!isRecordObject(c)) {
throw new KillStatsImportError(basePath, 'Character entry must be an object')
}
if (typeof c.host !== 'string' || c.host.trim().length === 0) {
throw new KillStatsImportError(`${basePath}.host`, 'host must be a non-empty string')
}
if (typeof c.realm !== 'string' || c.realm.trim().length === 0) {
throw new KillStatsImportError(`${basePath}.realm`, 'realm must be a non-empty string')
}
if (typeof c.account !== 'string' || c.account.trim().length === 0) {
throw new KillStatsImportError(`${basePath}.account`, 'account must be a non-empty string')
}
if (typeof c.charName !== 'string' || c.charName.trim().length === 0) {
throw new KillStatsImportError(`${basePath}.charName`, 'charName must be a non-empty string')
}
const expectedCharKey = computeCharKey(c.host, c.realm, c.account, c.charName)
if (typeof c.charKey !== 'string' || c.charKey !== expectedCharKey) {
throw new KillStatsImportError(
`${basePath}.charKey`,
`charKey mismatch: expected ${expectedCharKey}, got ${JSON.stringify(c.charKey)}`,
)
}
if (seenCharKeys.has(c.charKey)) {
throw new KillStatsImportError(
`${basePath}.charKey`,
`Duplicate charKey ${c.charKey}`,
)
}
seenCharKeys.add(c.charKey)
if (!isValidCharClass(c.charClass)) {
throw new KillStatsImportError(
`${basePath}.charClass`,
'charClass must be an integer in 0..6',
)
}
if (
typeof c.level !== 'number' ||
!Number.isInteger(c.level) ||
c.level < 1 ||
c.level > 99
) {
throw new KillStatsImportError(
`${basePath}.level`,
'level must be an integer in 1..99',
)
}
if (typeof c.hardcore !== 'boolean') {
throw new KillStatsImportError(`${basePath}.hardcore`, 'hardcore must be a boolean')
}
if (typeof c.expansion !== 'boolean') {
throw new KillStatsImportError(`${basePath}.expansion`, 'expansion must be a boolean')
}
if (typeof c.ladder !== 'boolean') {
throw new KillStatsImportError(`${basePath}.ladder`, 'ladder must be a boolean')
}
if (
typeof c.firstSeenAt !== 'number' ||
!Number.isFinite(c.firstSeenAt) ||
c.firstSeenAt < 0
) {
throw new KillStatsImportError(
`${basePath}.firstSeenAt`,
'firstSeenAt must be a non-negative finite number',
)
}
if (
typeof c.lastSeenAt !== 'number' ||
!Number.isFinite(c.lastSeenAt) ||
c.lastSeenAt < 0
) {
throw new KillStatsImportError(
`${basePath}.lastSeenAt`,
'lastSeenAt must be a non-negative finite number',
)
}
if (c.firstSeenAt > c.lastSeenAt) {
throw new KillStatsImportError(
`${basePath}.firstSeenAt`,
'firstSeenAt cannot be greater than lastSeenAt',
)
}
validatedCharacters.push({
charKey: c.charKey,
host: c.host.trim(),
realm: c.realm.trim(),
account: c.account.trim(),
charName: c.charName.trim(),
charClass: c.charClass,
level: c.level,
hardcore: c.hardcore,
expansion: c.expansion,
ladder: c.ladder,
firstSeenAt: c.firstSeenAt,
lastSeenAt: c.lastSeenAt,
})
}
const seenTallyIds = new Set<string>()
const validatedTallies: KillTallyRecord[] = []
for (let i = 0; i < raw.tallies.length; i++) {
const t = raw.tallies[i]
const basePath = `tallies[${i}]`
if (!isRecordObject(t)) {
throw new KillStatsImportError(basePath, 'Tally entry must be an object')
}
if (!isValidUuid(t.replicaId)) {
throw new KillStatsImportError(
`${basePath}.replicaId`,
'replicaId must be a valid UUID string',
)
}
if (typeof t.charKey !== 'string' || !seenCharKeys.has(t.charKey)) {
throw new KillStatsImportError(
`${basePath}.charKey`,
`charKey ${JSON.stringify(t.charKey)} does not reference a character in characters[]`,
)
}
if (!isValidTallyKind(t.kind)) {
throw new KillStatsImportError(
`${basePath}.kind`,
'kind must be "monster", "boss", or "superunique"',
)
}
if (!isValidDifficulty(t.difficulty)) {
throw new KillStatsImportError(
`${basePath}.difficulty`,
'difficulty must be 0, 1, or 2',
)
}
if (typeof t.subject !== 'number' || !Number.isInteger(t.subject)) {
throw new KillStatsImportError(
`${basePath}.subject`,
'subject must be an integer',
)
}
if (t.kind === 'monster') {
if (!isCountableKillClass(t.subject)) {
throw new KillStatsImportError(
`${basePath}.subject`,
`hcIdx ${t.subject} is not a countable monster class`,
)
}
} else if (t.kind === 'boss') {
if (!isCountableBossClass(t.subject)) {
throw new KillStatsImportError(
`${basePath}.subject`,
`hcIdx ${t.subject} is not a countable boss class`,
)
}
} else if (t.kind === 'superunique') {
if (t.subject < 0 || t.subject >= SUPERUNIQUE_COUNT) {
throw new KillStatsImportError(
`${basePath}.subject`,
`superUniqueId ${t.subject} is out of range 0..65`,
)
}
}
const expectedId = computeTallyId(
t.replicaId,
t.charKey,
t.kind,
t.difficulty,
t.subject,
)
if (typeof t.id !== 'string' || t.id !== expectedId) {
throw new KillStatsImportError(
`${basePath}.id`,
`id mismatch: expected ${expectedId}, got ${JSON.stringify(t.id)}`,
)
}
if (seenTallyIds.has(t.id)) {
throw new KillStatsImportError(`${basePath}.id`, `Duplicate tally id ${t.id}`)
}
seenTallyIds.add(t.id)
if (typeof t.count !== 'number' || !Number.isInteger(t.count) || t.count < 1) {
throw new KillStatsImportError(
`${basePath}.count`,
'count must be an integer >= 1',
)
}
if (
typeof t.soloCount !== 'number' ||
!Number.isInteger(t.soloCount) ||
t.soloCount < 0 ||
t.soloCount > t.count
) {
throw new KillStatsImportError(
`${basePath}.soloCount`,
'soloCount must be an integer in 0..count',
)
}
if (
typeof t.firstAt !== 'number' ||
!Number.isFinite(t.firstAt) ||
t.firstAt < 0
) {
throw new KillStatsImportError(
`${basePath}.firstAt`,
'firstAt must be a non-negative finite number',
)
}
if (
typeof t.lastAt !== 'number' ||
!Number.isFinite(t.lastAt) ||
t.lastAt < 0
) {
throw new KillStatsImportError(
`${basePath}.lastAt`,
'lastAt must be a non-negative finite number',
)
}
if (t.firstAt > t.lastAt) {
throw new KillStatsImportError(
`${basePath}.firstAt`,
'firstAt cannot be greater than lastAt',
)
}
validatedTallies.push({
id: t.id,
replicaId: t.replicaId,
charKey: t.charKey,
kind: t.kind,
difficulty: t.difficulty,
subject: t.subject,
count: t.count,
soloCount: t.soloCount,
firstAt: t.firstAt,
lastAt: t.lastAt,
})
}
return {
format: KILL_STATS_FORMAT,
version: KILL_STATS_VERSION,
exportedAt: raw.exportedAt,
replicaId: raw.replicaId,
characters: validatedCharacters,
tallies: validatedTallies,
}
}
function areCharacterRecordsEqual(a: KillCharacterRecord, b: KillCharacterRecord): boolean {
return (
a.charKey === b.charKey &&
a.host === b.host &&
a.realm === b.realm &&
a.account === b.account &&
a.charName === b.charName &&
a.charClass === b.charClass &&
a.level === b.level &&
a.hardcore === b.hardcore &&
a.expansion === b.expansion &&
a.ladder === b.ladder &&
a.firstSeenAt === b.firstSeenAt &&
a.lastSeenAt === b.lastSeenAt
)
}
function areTallyRecordsEqual(a: KillTallyRecord, b: KillTallyRecord): boolean {
return (
a.id === b.id &&
a.count === b.count &&
a.soloCount === b.soloCount &&
a.firstAt === b.firstAt &&
a.lastAt === b.lastAt
)
}
/**
* Merges a validated `KillStatsExportPayload` into `current` state using G-counter and LWW rules.
*/
export function mergeImportPayload(
current: KillStatsSnapshot,
payload: KillStatsExportPayload,
): {
readonly next: KillStatsSnapshot
readonly summary: KillStatsImportSummary
} {
const charMap = new Map<string, KillCharacterRecord>()
for (const c of current.characters) {
charMap.set(c.charKey, c)
}
let addedCharacters = 0
let updatedCharacters = 0
for (const incoming of payload.characters) {
const existing = charMap.get(incoming.charKey)
if (!existing) {
charMap.set(incoming.charKey, incoming)
addedCharacters++
} else {
const merged = mergeCharacterRecords(existing, incoming)
charMap.set(incoming.charKey, merged)
if (!areCharacterRecordsEqual(existing, merged)) {
updatedCharacters++
}
}
}
const tallyMap = new Map<string, KillTallyRecord>()
for (const t of current.tallies) {
tallyMap.set(t.id, t)
}
let addedTallies = 0
let updatedTallies = 0
let unchangedTallies = 0
for (const incoming of payload.tallies) {
const existing = tallyMap.get(incoming.id)
if (!existing) {
tallyMap.set(incoming.id, incoming)
addedTallies++
} else {
const merged = mergeTallyRecords(existing, incoming)
tallyMap.set(incoming.id, merged)
if (areTallyRecordsEqual(existing, merged)) {
unchangedTallies++
} else {
updatedTallies++
}
}
}
const next: KillStatsSnapshot = {
replicaId: current.replicaId,
characters: [...charMap.values()].sort((a, b) =>
a.charKey < b.charKey ? -1 : a.charKey > b.charKey ? 1 : 0,
),
tallies: [...tallyMap.values()].sort((a, b) =>
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
),
}
return {
next,
summary: {
addedCharacters,
updatedCharacters,
addedTallies,
updatedTallies,
unchangedTallies,
},
}
}