diablo2-web/tests/client/kill-stats-online.test.ts

289 lines
9.1 KiB
TypeScript

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