806 lines
22 KiB
TypeScript
806 lines
22 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
|
import {
|
|
computeCharKey,
|
|
type KillCharacterProfile,
|
|
type KillStatsBroadcastMessage,
|
|
} from '../../src/client/stats/kill-stats-model.ts'
|
|
import {
|
|
KillTracker,
|
|
resolveSessionIdentity,
|
|
type BroadcastChannelLike,
|
|
type KillTrackerScheduler,
|
|
type KillTrackerWindowLike,
|
|
} from '../../src/client/stats/kill-tracker.ts'
|
|
import type {
|
|
ClientCommand,
|
|
CloseReason,
|
|
GameServerAdapter,
|
|
GameServerStats,
|
|
ServerEvent,
|
|
} from '../../src/netproto/index.ts'
|
|
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts'
|
|
|
|
class FakeGameServerAdapter implements GameServerAdapter {
|
|
state: 'loading' | 'ingame' | 'closed' = 'ingame'
|
|
readonly sent: ClientCommand[] = []
|
|
private readonly eventListeners = new Set<(ev: ServerEvent) => void>()
|
|
private readonly closeListeners = new Set<(reason: CloseReason) => void>()
|
|
|
|
readonly stats: GameServerStats = {
|
|
unknownPackets: 0,
|
|
opaquePackets: 0,
|
|
rttMs: 25,
|
|
bytesIn: 0,
|
|
bytesOut: 0,
|
|
}
|
|
|
|
send(cmd: ClientCommand): void {
|
|
this.sent.push(cmd)
|
|
}
|
|
|
|
onEvent(cb: (ev: ServerEvent) => void): () => void {
|
|
this.eventListeners.add(cb)
|
|
return () => {
|
|
this.eventListeners.delete(cb)
|
|
}
|
|
}
|
|
|
|
onClose(cb: (reason: CloseReason) => void): () => void {
|
|
this.closeListeners.add(cb)
|
|
return () => {
|
|
this.closeListeners.delete(cb)
|
|
}
|
|
}
|
|
|
|
emit(ev: ServerEvent): void {
|
|
for (const cb of this.eventListeners) {
|
|
cb(ev)
|
|
}
|
|
}
|
|
|
|
leave(): void {
|
|
this.state = 'closed'
|
|
for (const cb of this.closeListeners) {
|
|
cb({ kind: 'local' })
|
|
}
|
|
}
|
|
}
|
|
|
|
class FakeScheduler implements KillTrackerScheduler {
|
|
private nextHandle = 1
|
|
readonly timers = new Map<number, { readonly cb: () => void; readonly delayMs: number }>()
|
|
|
|
setTimeout(cb: () => void, delayMs: number): unknown {
|
|
const id = this.nextHandle++
|
|
this.timers.set(id, { cb, delayMs })
|
|
return id
|
|
}
|
|
|
|
clearTimeout(handle: unknown): void {
|
|
if (typeof handle === 'number') {
|
|
this.timers.delete(handle)
|
|
}
|
|
}
|
|
|
|
pendingDelays(): number[] {
|
|
return [...this.timers.values()].map((t) => t.delayMs)
|
|
}
|
|
|
|
fireNext(): boolean {
|
|
const first = this.timers.entries().next()
|
|
if (first.done) return false
|
|
const [id, entry] = first.value
|
|
this.timers.delete(id)
|
|
entry.cb()
|
|
return true
|
|
}
|
|
}
|
|
|
|
class FakeWindow implements KillTrackerWindowLike {
|
|
private readonly winListeners = new Map<string, Set<() => void>>()
|
|
private readonly docListeners = new Map<string, Set<() => void>>()
|
|
visibilityState = 'visible'
|
|
|
|
readonly document = {
|
|
get visibilityState(): string {
|
|
return this.outer.visibilityState
|
|
},
|
|
addEventListener: (type: string, listener: () => void): void => {
|
|
let set = this.docListeners.get(type)
|
|
if (!set) {
|
|
set = new Set()
|
|
this.docListeners.set(type, set)
|
|
}
|
|
set.add(listener)
|
|
},
|
|
removeEventListener: (type: string, listener: () => void): void => {
|
|
this.docListeners.get(type)?.delete(listener)
|
|
},
|
|
outer: this,
|
|
}
|
|
|
|
addEventListener(type: string, listener: () => void): void {
|
|
let set = this.winListeners.get(type)
|
|
if (!set) {
|
|
set = new Set()
|
|
this.winListeners.set(type, set)
|
|
}
|
|
set.add(listener)
|
|
}
|
|
|
|
removeEventListener(type: string, listener: () => void): void {
|
|
this.winListeners.get(type)?.delete(listener)
|
|
}
|
|
|
|
dispatchVisibilityChange(nextState: 'visible' | 'hidden'): void {
|
|
this.visibilityState = nextState
|
|
for (const cb of this.docListeners.get('visibilitychange') ?? []) {
|
|
cb()
|
|
}
|
|
}
|
|
|
|
dispatchPageHide(): void {
|
|
for (const cb of this.winListeners.get('pagehide') ?? []) {
|
|
cb()
|
|
}
|
|
}
|
|
}
|
|
|
|
function makeDefaultProfile(): KillCharacterProfile {
|
|
const host = 'www.laiseek.xyz'
|
|
const realm = 'D2CS'
|
|
const account = 'testacct'
|
|
const charName = 'TestSorc'
|
|
return {
|
|
charKey: computeCharKey(host, realm, account, charName),
|
|
host,
|
|
realm,
|
|
account,
|
|
charName,
|
|
charClass: 1,
|
|
level: 85,
|
|
hardcore: true,
|
|
expansion: true,
|
|
ladder: true,
|
|
}
|
|
}
|
|
|
|
function setupHarness(options?: {
|
|
readonly identity?: (() => KillCharacterProfile | null) | undefined
|
|
}) {
|
|
let nowMs = 10000
|
|
const session = new OnlineSession()
|
|
const adapter = new FakeGameServerAdapter()
|
|
const repository = new MemoryKillStatsRepository({
|
|
initialReplicaId: '11111111-1111-4111-8111-111111111111',
|
|
now: () => nowMs,
|
|
})
|
|
const scheduler = new FakeScheduler()
|
|
const windowLike = new FakeWindow()
|
|
const broadcastMessages: KillStatsBroadcastMessage[] = []
|
|
const channel: BroadcastChannelLike = {
|
|
postMessage(msg) {
|
|
broadcastMessages.push(msg as KillStatsBroadcastMessage)
|
|
},
|
|
close() {},
|
|
}
|
|
const defaultProfile = makeDefaultProfile()
|
|
|
|
const tracker = new KillTracker({
|
|
session,
|
|
repository,
|
|
resolveIdentity: options?.identity ?? (() => defaultProfile),
|
|
now: () => nowMs,
|
|
scheduler,
|
|
channelFactory: () => channel,
|
|
windowLike,
|
|
})
|
|
|
|
session.attachGameServer(adapter)
|
|
|
|
const sendGameFlags = (difficulty: 0 | 1 | 2 = 2): void => {
|
|
adapter.emit({
|
|
type: 'GameFlags',
|
|
difficulty,
|
|
hardcore: true,
|
|
expansion: true,
|
|
ladder: true,
|
|
})
|
|
adapter.emit({
|
|
type: 'UnitAssign',
|
|
isSelf: true,
|
|
unit: {
|
|
unitType: 'player',
|
|
id: 1,
|
|
classId: 1,
|
|
name: defaultProfile.charName,
|
|
x: 5000,
|
|
y: 5000,
|
|
},
|
|
})
|
|
}
|
|
|
|
return {
|
|
session,
|
|
adapter,
|
|
repository,
|
|
scheduler,
|
|
windowLike,
|
|
broadcastMessages,
|
|
tracker,
|
|
defaultProfile,
|
|
advanceTime: (delta: number): void => {
|
|
nowMs += delta
|
|
},
|
|
sendGameFlags,
|
|
}
|
|
}
|
|
|
|
describe('KillTracker: Per-GUID State Machine, Exclusions & Revives', () => {
|
|
it('counts 1 kill across back-to-back 0x08 dying (mode 0) + 0x09 dead (mode 12) and ignores corpse UnitAssign', async () => {
|
|
const h = setupHarness()
|
|
h.sendGameFlags(2)
|
|
|
|
// 1. Live Fallen (hcIdx = 19) spawns, takes 0x0C damage (mode: undefined), then dies (mode 0 -> mode 12)
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 1,
|
|
id: 100,
|
|
classId: 19,
|
|
x: 5010,
|
|
y: 5010,
|
|
lifePct: 128,
|
|
mode: 1,
|
|
},
|
|
})
|
|
// 0x0C NPCGetHit emits UnitMode with mode === undefined -> must not transition or count
|
|
h.adapter.emit({
|
|
type: 'UnitMode',
|
|
unitType: 1,
|
|
id: 100,
|
|
lifePct: 64,
|
|
hitClass: 2,
|
|
})
|
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
|
|
|
// 0x08 dying -> mode = 0 (counts once)
|
|
h.adapter.emit({
|
|
type: 'UnitMode',
|
|
unitType: 1,
|
|
id: 100,
|
|
mode: 0,
|
|
lifePct: 0,
|
|
})
|
|
// 0x09 dead -> mode = 12 (already dead, must not double-count)
|
|
h.adapter.emit({
|
|
type: 'UnitMode',
|
|
unitType: 1,
|
|
id: 100,
|
|
mode: 12,
|
|
lifePct: 0,
|
|
})
|
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
|
|
|
// 2. Pre-existing corpse UnitAssign (lifePct = 0, mode = 12) followed by UnitMode(12) -> 0 count
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 1,
|
|
id: 101,
|
|
classId: 19,
|
|
x: 5020,
|
|
y: 5020,
|
|
lifePct: 0,
|
|
mode: 12,
|
|
},
|
|
})
|
|
h.adapter.emit({
|
|
type: 'UnitMode',
|
|
unitType: 1,
|
|
id: 101,
|
|
mode: 12,
|
|
lifePct: 0,
|
|
})
|
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
|
|
|
await h.tracker.flushNow()
|
|
const snap = await h.repository.loadAll()
|
|
expect(snap.tallies).toHaveLength(1)
|
|
expect(snap.tallies[0]?.subject).toBe(19)
|
|
expect(snap.tallies[0]?.count).toBe(1)
|
|
expect(snap.tallies[0]?.soloCount).toBe(1)
|
|
h.tracker.destroy()
|
|
})
|
|
|
|
it('re-arms dead monsters when revived (alive mode 1..15 != 12) and counts a second kill on re-death, and forgets on UnitRemove', async () => {
|
|
const h = setupHarness()
|
|
h.sendGameFlags(1)
|
|
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 200,
|
|
classId: 19, // fallen1
|
|
x: 5000,
|
|
y: 5000,
|
|
lifePct: 128,
|
|
mode: 1,
|
|
},
|
|
})
|
|
// First kill
|
|
h.adapter.emit({
|
|
type: 'UnitMode',
|
|
unitType: 'monster',
|
|
id: 200,
|
|
mode: 0,
|
|
})
|
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
|
|
|
// Shaman revives Fallen -> mode = 1 (NU)
|
|
h.adapter.emit({
|
|
type: 'UnitMode',
|
|
unitType: 'monster',
|
|
id: 200,
|
|
mode: 1,
|
|
lifePct: 128,
|
|
})
|
|
// Killed a second time -> counts 2
|
|
h.advanceTime(500)
|
|
h.adapter.emit({
|
|
type: 'UnitMode',
|
|
unitType: 'monster',
|
|
id: 200,
|
|
mode: 12,
|
|
})
|
|
expect(h.tracker.getStatus().pendingKills).toBe(2)
|
|
|
|
// Unit 201 spawns, leaves view via UnitRemove, then a late UnitMode arrives -> not counted
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 201,
|
|
classId: 19,
|
|
x: 5000,
|
|
y: 5000,
|
|
lifePct: 128,
|
|
mode: 1,
|
|
},
|
|
})
|
|
h.adapter.emit({
|
|
type: 'UnitRemove',
|
|
unitType: 'monster',
|
|
id: 201,
|
|
})
|
|
h.adapter.emit({
|
|
type: 'UnitMode',
|
|
unitType: 'monster',
|
|
id: 201,
|
|
mode: 0,
|
|
})
|
|
expect(h.tracker.getStatus().pendingKills).toBe(2)
|
|
|
|
await h.tracker.flushNow()
|
|
const snap = await h.repository.loadAll()
|
|
expect(snap.tallies).toHaveLength(1)
|
|
expect(snap.tallies[0]?.difficulty).toBe(1)
|
|
expect(snap.tallies[0]?.count).toBe(2)
|
|
h.tracker.destroy()
|
|
})
|
|
|
|
it('excludes mercs (0x81), summons (0x7A), 0xAC ownerId units, Align !== 0 (363, 338), and neverCount (340)', async () => {
|
|
const h = setupHarness()
|
|
h.sendGameFlags(2)
|
|
|
|
// 1. 0x81 AssignMerc (mercId = 300)
|
|
h.adapter.emit({
|
|
type: 'Merc',
|
|
kind: 'assign',
|
|
ownerId: 1,
|
|
mercId: 300,
|
|
classId: 338,
|
|
})
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 1,
|
|
id: 300,
|
|
classId: 19, // even if classId were countable, Merc ownership excludes GUID 300
|
|
x: 5000,
|
|
y: 5000,
|
|
lifePct: 128,
|
|
mode: 1,
|
|
},
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 300, mode: 0 })
|
|
|
|
// 2. 0x7A SummonAction (mercId = 301, sticky even after unsummon action = 0)
|
|
h.adapter.emit({
|
|
type: 'Merc',
|
|
kind: 'assign',
|
|
ownerId: 1,
|
|
mercId: 301,
|
|
classId: 363,
|
|
isSummonAction: true,
|
|
summonAction: 1,
|
|
})
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 1,
|
|
id: 301,
|
|
classId: 19,
|
|
x: 5000,
|
|
y: 5000,
|
|
lifePct: 128,
|
|
mode: 1,
|
|
},
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 301, mode: 0 })
|
|
|
|
// 3. 0xAC UnitAssign with ownerId (id = 302)
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 1,
|
|
id: 302,
|
|
classId: 19,
|
|
x: 5000,
|
|
y: 5000,
|
|
lifePct: 128,
|
|
mode: 1,
|
|
ownerId: 1,
|
|
},
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 302, mode: 0 })
|
|
|
|
// 4. Align !== 0 (363 = necroskeleton, 338 = act2hire) and neverCount !== 0 (340 = boneprison1)
|
|
for (const [idx, classId] of [
|
|
[303, 363],
|
|
[304, 338],
|
|
[305, 340],
|
|
] as const) {
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 1,
|
|
id: idx,
|
|
classId,
|
|
x: 5000,
|
|
y: 5000,
|
|
lifePct: 128,
|
|
mode: 1,
|
|
},
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: idx, mode: 0 })
|
|
}
|
|
|
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
|
await h.tracker.flushNow()
|
|
const snap = await h.repository.loadAll()
|
|
expect(snap.tallies).toHaveLength(0)
|
|
h.tracker.destroy()
|
|
})
|
|
})
|
|
|
|
describe('KillTracker: Boss / SuperUnique Classification, Solo vs Party, Guards & Flush Retry', () => {
|
|
it('classifies Boss (monster + boss), SuperUnique (monster + superunique), and logs out-of-range superUniqueId', async () => {
|
|
const h = setupHarness()
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
h.sendGameFlags(2)
|
|
|
|
// 1. Andariel (hcIdx = 156, boss = true) -> emits monster/156 + boss/156
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 1,
|
|
id: 400,
|
|
classId: 156,
|
|
x: 5000,
|
|
y: 5000,
|
|
lifePct: 128,
|
|
mode: 1,
|
|
},
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 400, mode: 0 })
|
|
|
|
// 2. Pindleskin (hcIdx = 440 reanimatedhorde5, isSuperUnique = true, superUniqueId = 52) -> monster/440 + superunique/52
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 1,
|
|
id: 401,
|
|
classId: 440,
|
|
x: 5000,
|
|
y: 5000,
|
|
lifePct: 128,
|
|
mode: 1,
|
|
isSuperUnique: true,
|
|
superUniqueId: 52,
|
|
},
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 401, mode: 0 })
|
|
|
|
// 3. Radament (hcIdx = 229 boss=true AND isSuperUnique=true, superUniqueId = 10) -> monster/229 + superunique/10 (NOT boss/229)
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 1,
|
|
id: 402,
|
|
classId: 229,
|
|
x: 5000,
|
|
y: 5000,
|
|
lifePct: 128,
|
|
mode: 1,
|
|
isSuperUnique: true,
|
|
superUniqueId: 10,
|
|
},
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 402, mode: 0 })
|
|
|
|
// 4. Out-of-range superUniqueId (99) -> emits monster/440 only and logs console.error
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 1,
|
|
id: 403,
|
|
classId: 440,
|
|
x: 5000,
|
|
y: 5000,
|
|
lifePct: 128,
|
|
mode: 1,
|
|
isSuperUnique: true,
|
|
superUniqueId: 99,
|
|
},
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 403, mode: 0 })
|
|
expect(errSpy).toHaveBeenCalled()
|
|
expect(h.tracker.getStatus().lastError).toContain('99')
|
|
|
|
await h.tracker.flushNow()
|
|
const snap = await h.repository.loadAll()
|
|
const summary = snap.tallies.map((t) => `${t.kind}:${t.subject}=${t.count}`).sort()
|
|
expect(summary).toEqual([
|
|
'boss:156=1',
|
|
'monster:156=1',
|
|
'monster:229=1',
|
|
'monster:440=2',
|
|
'superunique:10=1',
|
|
'superunique:52=1',
|
|
])
|
|
|
|
errSpy.mockRestore()
|
|
h.tracker.destroy()
|
|
})
|
|
|
|
it('tracks soloCount accurately from world.party (0x5B PlayerInGame / 0x5C PlayerLeftGame)', async () => {
|
|
const h = setupHarness()
|
|
h.sendGameFlags(2)
|
|
|
|
// Self in roster (unitId = 1) -> still solo
|
|
h.adapter.emit({
|
|
type: 'Party',
|
|
kind: 'roster',
|
|
unitId: 1,
|
|
charName: 'TestSorc',
|
|
charClass: 1,
|
|
charLevel: 85,
|
|
partyId: 0xffff,
|
|
})
|
|
|
|
// Kill #1 while solo -> count = 1, soloCount = 1
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 500, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 500, mode: 0 })
|
|
|
|
// Second player enters game (0x5B, unitId = 2) -> multiplayer
|
|
h.adapter.emit({
|
|
type: 'Party',
|
|
kind: 'roster',
|
|
unitId: 2,
|
|
charName: 'TeammatePal',
|
|
charClass: 3,
|
|
charLevel: 90,
|
|
partyId: 1,
|
|
})
|
|
|
|
// Kill #2 while another player is in game -> count = 2, soloCount = 1
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 501, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 501, mode: 0 })
|
|
|
|
// Second player leaves game (0x5C, unitId = 2, partyId = 0xffff, no charName) -> solo again
|
|
h.adapter.emit({
|
|
type: 'Party',
|
|
kind: 'roster',
|
|
unitId: 2,
|
|
partyId: 0xffff,
|
|
})
|
|
|
|
// Kill #3 after other player left -> count = 3, soloCount = 2
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 502, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 502, mode: 0 })
|
|
|
|
await h.tracker.flushNow()
|
|
const snap = await h.repository.loadAll()
|
|
expect(snap.tallies).toHaveLength(1)
|
|
expect(snap.tallies[0]?.count).toBe(3)
|
|
expect(snap.tallies[0]?.soloCount).toBe(2)
|
|
h.tracker.destroy()
|
|
})
|
|
|
|
it('skips counting before GameFlags, when identity is incomplete, or during replay phase, and resets per-game', async () => {
|
|
let currentIdentity: KillCharacterProfile | null = makeDefaultProfile()
|
|
const h = setupHarness({ identity: () => currentIdentity })
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
// 1. Before GameFlags -> warns once and skips
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 600, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 600, mode: 0 })
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 601, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 601, mode: 0 })
|
|
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
|
|
|
// 2. GameFlags arrives, but identity is null -> warns once and skips
|
|
currentIdentity = null
|
|
h.sendGameFlags(0)
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 602, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 602, mode: 0 })
|
|
expect(warnSpy).toHaveBeenCalledTimes(2)
|
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
|
|
|
// 3. New GameFlags resets units from previous game so old unit 603 doesn't carry over
|
|
currentIdentity = makeDefaultProfile()
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 603, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
h.sendGameFlags(1) // new game resets units
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 603, mode: 0 })
|
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
|
|
|
// 4. Replay phase is ignored
|
|
h.session.loadD2CapReplay('')
|
|
expect(h.session.phase).toBe('replay')
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 604, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 604, mode: 0 })
|
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
|
|
|
warnSpy.mockRestore()
|
|
h.tracker.destroy()
|
|
})
|
|
|
|
it('flushes on 2000ms timer, visibilitychange(hidden), pagehide, leaving ingame, retries with exponential backoff, and isolates listener errors', async () => {
|
|
let throwInIdentity = false
|
|
const defaultProfile = makeDefaultProfile()
|
|
const h = setupHarness({
|
|
identity: () => {
|
|
if (throwInIdentity) {
|
|
throw new Error('Injected identity failure')
|
|
}
|
|
return defaultProfile
|
|
},
|
|
})
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
h.sendGameFlags(2)
|
|
|
|
// 1. Schedule 2000ms timer on first kill
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 700, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 700, mode: 0 })
|
|
expect(h.scheduler.pendingDelays()).toEqual([2000])
|
|
|
|
// Simulate 2 repository write failures -> backoff 2000ms then 4000ms
|
|
h.repository.failNextApplyCount = 2
|
|
h.scheduler.fireNext()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
|
expect(h.scheduler.pendingDelays()).toEqual([2000])
|
|
|
|
h.scheduler.fireNext()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
|
expect(h.scheduler.pendingDelays()).toEqual([4000])
|
|
|
|
// Third attempt succeeds and broadcasts 'updated'
|
|
h.scheduler.fireNext()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
|
expect(h.broadcastMessages).toHaveLength(1)
|
|
expect(h.broadcastMessages[0]?.type).toBe('updated')
|
|
|
|
// 2. visibilitychange('hidden') triggers immediate flush
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 701, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 701, mode: 0 })
|
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
|
h.windowLike.dispatchVisibilityChange('hidden')
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
|
expect(h.broadcastMessages).toHaveLength(2)
|
|
|
|
// 3. pagehide triggers immediate flush
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 702, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 702, mode: 0 })
|
|
h.windowLike.dispatchPageHide()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
|
expect(h.broadcastMessages).toHaveLength(3)
|
|
|
|
// 4. Listener error isolation: throwing inside KillTracker never breaks subsequent session listeners
|
|
let downstreamListenerCalled = false
|
|
h.session.onServerEvent(() => {
|
|
downstreamListenerCalled = true
|
|
})
|
|
throwInIdentity = true
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 703, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
expect(() => {
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 703, mode: 0 })
|
|
}).not.toThrow()
|
|
expect(downstreamListenerCalled).toBe(true)
|
|
expect(h.tracker.getStatus().lastError).toContain('Injected identity failure')
|
|
|
|
// 5. Leaving ingame flushes any remaining kills
|
|
throwInIdentity = false
|
|
h.adapter.emit({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 704, classId: 19, x: 5000, y: 5000, lifePct: 128, mode: 1 },
|
|
})
|
|
h.adapter.emit({ type: 'UnitMode', unitType: 1, id: 704, mode: 0 })
|
|
expect(h.tracker.getStatus().pendingKills).toBe(1)
|
|
await h.session.disconnect('test exit')
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(h.tracker.getStatus().pendingKills).toBe(0)
|
|
|
|
const snap = await h.repository.loadAll()
|
|
expect(snap.tallies[0]?.count).toBe(4)
|
|
|
|
errSpy.mockRestore()
|
|
h.tracker.destroy()
|
|
})
|
|
|
|
it('resolveSessionIdentity returns null when any of (host, realm, account, charName) is missing', () => {
|
|
const session = new OnlineSession()
|
|
expect(resolveSessionIdentity(session)).toBeNull()
|
|
})
|
|
})
|