diablo2-web/tests/client/hud-session-play.test.ts

770 lines
26 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { AutomapView } from '../../src/client/automap/automap-view.ts'
import { CommandMapper, ViewportCamera } from '../../src/client/input/command-mapper.ts'
import { LocalMovementPredictor } from '../../src/client/motion/predictor.ts'
import {
OnlineSession,
decodeCapturedGameS2cPacket,
} from '../../src/client/session/online-session.ts'
import { createSettingsStore } from '../../src/client/settings/settings-store.ts'
import { HudModel } from '../../src/client/ui-model/hud-model.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { D2StatId } from '../../src/client/world/stat-list.ts'
import {
formatD2CapEntry,
formatD2CapHeader,
type ClientCommand,
type CloseReason,
type D2OnlineFlow,
type D2OnlineState,
type GameServerAdapter,
type ServerEvent,
} from '../../src/netproto/index.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
type ItemEventItem = Extract<ServerEvent, { type: 'ItemEvent' }>['item']
/** Minimal Healing Potion (hp4) in the given belt slot, as decoded from a 0x9C ItemEvent. */
function makeBeltPotionItem(id: number, slot: number): ItemEventItem {
return {
id,
itemId: id,
opcode: 0x9c,
action: 0x0e,
category: 0,
flags: 0,
rawFlags: 0,
version: 101,
location: 2,
container: 'belt',
bodyLoc: 0,
equippedSlot: 0,
gridX: slot,
gridY: 0,
storePage: 0,
storagePage: 0,
worldX: 0,
worldY: 0,
x: slot,
y: 0,
code: 'hp4',
rawCode: 'hp4 ',
compact: true,
simple: true,
identified: true,
socketed: false,
ethereal: false,
runeword: false,
personalized: false,
isEar: false,
newItem: false,
starterItem: false,
broken: false,
inSocket: false,
equipped: false,
socketedChildCount: 0,
usedSockets: 0,
totalSockets: 0,
quality: 2,
invWidth: 1,
invHeight: 1,
stats: [],
setBonusStats: [],
runewordStats: [],
} as ItemEventItem
}
describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
describe('HudModel (F16)', () => {
it('synchronizes ClientWorld vitals, stats, skills, containers, quests, waypoints, and monster target into HudManager', () => {
const world = new ClientWorld()
const hudManager = new HudManager()
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
const automapView = new AutomapView()
const commands: ClientCommand[] = []
const hudModel = new HudModel({
world,
hudManager,
predictor,
automap: automapView,
onCommand: (cmd) => commands.push(cmd),
})
// Apply authoritative player assignment & stats
world.apply({
type: 'UnitAssign',
unit: {
unitType: 0,
id: 1,
classId: 1, // Sorceress
name: 'Jaina',
x: 120,
y: 140,
},
isSelf: true,
})
world.apply({ type: 'StatSet', stat: D2StatId.Level, value: 30 })
world.apply({ type: 'StatSet', stat: D2StatId.Experience, value: 4_500_000 })
world.apply({ type: 'StatSet', stat: D2StatId.Strength, value: 60 })
world.apply({ type: 'StatSet', stat: D2StatId.Dexterity, value: 40 })
world.apply({ type: 'StatSet', stat: D2StatId.Vitality, value: 120 })
world.apply({ type: 'StatSet', stat: D2StatId.Energy, value: 150 })
world.apply({ type: 'StatSet', stat: D2StatId.StatPts, value: 15 })
world.apply({ type: 'StatSet', stat: D2StatId.NewSkills, value: 3 })
world.apply({ type: 'StatSet', stat: D2StatId.Gold, value: 25_000 })
world.apply({ type: 'StatSet', stat: D2StatId.GoldBank, value: 150_000 })
world.apply({ type: 'StatSet', stat: D2StatId.FireResist, value: 75 })
world.apply({ type: 'StatSet', stat: D2StatId.ColdResist, value: 60 })
// Pool stats 6..11 are 8.8 fixed point on the 1.13c wire (ItemStatCost ValShift = 8)
world.apply({ type: 'StatSet', stat: D2StatId.MaxHp, value: 420 << 8 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxMana, value: 500 << 8 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 220 << 8 })
world.apply({
type: 'LifeMana',
life: 380,
mana: 450,
stamina: 200,
at: { x: 120, y: 140 },
})
world.apply({
type: 'SkillList',
unitId: 1,
skills: [
{ skillId: 36, level: 10, bonus: 2 }, // Fire Bolt
{ skillId: 54, level: 1, bonus: 2 }, // Teleport
],
})
world.apply({
type: 'ActiveSkill',
unitId: 1,
hand: 'left',
skill: 36,
itemId: 0xffffffff,
})
world.apply({
type: 'ActiveSkill',
unitId: 1,
hand: 'right',
skill: 54,
itemId: 0xffffffff,
})
world.apply({
type: 'WaypointMenu',
unitId: 77,
open: [1, 3, 4, 5, 6, 40],
})
// Add a belt potion and an inventory item
world.apply({
type: 'ItemEvent',
action: 0x0e, // put in belt
container: 'belt',
item: {
id: 501,
itemId: 501,
opcode: 0x9c,
action: 0x0e,
category: 0,
flags: 0,
rawFlags: 0,
version: 101,
location: 2,
container: 'belt',
bodyLoc: 0,
equippedSlot: 0,
gridX: 0,
gridY: 0,
storePage: 0,
storagePage: 0,
worldX: 0,
worldY: 0,
x: 0,
y: 0,
code: 'hp4',
rawCode: 'hp4 ',
compact: true,
simple: true,
identified: true,
socketed: false,
ethereal: false,
runeword: false,
personalized: false,
isEar: false,
newItem: false,
starterItem: false,
broken: false,
inSocket: false,
equipped: false,
socketedChildCount: 0,
usedSockets: 0,
totalSockets: 0,
quality: 2,
invWidth: 1,
invHeight: 1,
stats: [],
setBonusStats: [],
runewordStats: [],
},
})
world.apply({
type: 'ItemEvent',
action: 0x04, // put in inventory
container: 'inventory',
item: {
id: 601,
itemId: 601,
opcode: 0x9c,
action: 0x04,
category: 0,
flags: 0,
rawFlags: 0,
version: 101,
location: 0,
container: 'inventory',
bodyLoc: 0,
equippedSlot: 0,
gridX: 2,
gridY: 1,
storePage: 0,
storagePage: 0,
worldX: 0,
worldY: 0,
x: 2,
y: 1,
code: 'cap',
rawCode: 'cap ',
compact: false,
simple: false,
identified: true,
socketed: true,
ethereal: false,
runeword: false,
personalized: false,
isEar: false,
newItem: false,
starterItem: false,
broken: false,
inSocket: false,
equipped: false,
socketedChildCount: 0,
usedSockets: 0,
totalSockets: 1,
quality: 7,
invWidth: 2,
invHeight: 2,
stats: [],
setBonusStats: [],
runewordStats: [],
},
})
// Spawn a unique monster and hover it
world.apply({
type: 'UnitAssign',
unit: {
unitType: 1,
id: 900,
classId: 1,
name: 'Bishibosh',
x: 125,
y: 142,
lifePct: 96,
isSuperUnique: true,
bossMods: [5, 8],
},
})
hudModel.setHoveredMonster(900)
const snap = hudModel.getSnapshot()
expect(snap.charName).toBe('Jaina')
expect(snap.classCode).toBe('sor')
expect(snap.level).toBe(30)
expect(snap.hp).toBe(380)
expect(snap.maxHp).toBe(420)
expect(snap.mana).toBe(450)
expect(snap.maxMana).toBe(500)
expect(snap.str).toBe(60)
expect(snap.dex).toBe(40)
expect(snap.vit).toBe(120)
expect(snap.ene).toBe(150)
expect(snap.statPoints).toBe(15)
expect(snap.skillPoints).toBe(3)
expect(snap.gold).toBe(25_000)
expect(snap.stashGold).toBe(150_000)
expect(snap.leftSkillId).toBe(36)
expect(snap.rightSkillId).toBe(54)
expect(snap.beltCount).toBe(1)
expect(snap.inventoryCount).toBe(1)
expect(snap.unlockedWaypoints).toEqual([1, 3, 4, 5, 6, 40])
// Verify HudManager mirror
expect(hudManager.hp).toBe(380)
expect(hudManager.maxHp).toBe(420)
expect(hudManager.mana).toBe(450)
expect(hudManager.maxMana).toBe(500)
expect(hudManager.charSheet.attrs.name).toBe('Jaina')
expect(hudManager.charSheet.attrs.str).toBe(60)
expect(hudManager.charSheet.attrs.fireRes).toBe(75)
expect(hudManager.inventory.gold).toBe(25_000)
expect(hudManager.worldPanels.stashGold).toBe(150_000)
expect(hudManager.inventory.gridItems.length).toBe(1)
expect(hudManager.belt.grid[0]?.[0]?.code).toBe('hp4')
expect(hudManager.targetMonster?.name).toBe('Bishibosh')
expect(hudManager.targetMonster?.rank).toBe('superunique')
// Test outbound command helpers
expect(hudModel.allocateStat('str')).toBe(true)
expect(hudModel.allocateSkill(36)).toBe(true)
hudModel.selectSkill('right', 36)
hudModel.useBeltSlot(0, false)
hudModel.swapWeapons()
hudModel.teleportWaypoint(77, 40)
hudModel.pickupItemToCursor(601)
hudModel.placeCursorItem(601, 0, 0, 0)
hudModel.equipItem(601, 'helm')
hudModel.unequipItem('helm')
hudModel.dropItem(601)
hudModel.transmuteCube()
expect(commands.map((c) => c.type)).toEqual([
'AddStatPoint',
'AddSkillPoint',
'SelectSkill',
'BeltUse',
'SwapWeapons',
'WaypointGo',
'ItemToCursor',
'ItemPlace',
'ItemEquip',
'ItemUnequip',
'ItemDrop',
'ClickButton',
])
// BeltUse carries the real server item id of the bottom potion in column 0
expect(commands.find((c) => c.type === 'BeltUse')).toEqual({ type: 'BeltUse', itemId: 501, shift: false })
// Server authority: command helpers never mutate HUD / world state locally
expect(hudManager.charSheet.attrs.statPoints).toBe(15)
expect(hudManager.charSheet.attrs.str).toBe(60)
expect(hudManager.skillTree.unspentSkillPoints).toBe(3)
expect(hudManager.skillTree.getHardPoints(36)).toBe(10)
expect(world.self.rightSkill).toBe(54)
expect(hudManager.hotkeys.rightSkillId).toBe(54)
expect(world.self.weaponSet).toBe(0)
expect(hudManager.inventory.weaponSwapSet).toBe(0)
expect(hudManager.belt.grid[0]?.[0]?.code).toBe('hp4')
expect(hudManager.hp).toBe(380)
// No belt item in column 3 -> no BeltUse command
const before = commands.length
expect(hudModel.useBeltSlot(3, false)).toBe(false)
expect(commands.length).toBe(before)
hudModel.dispose()
})
})
describe('CommandMapper (F17)', () => {
it('translates viewport clicks into Move / SkillAt / SkillOn / ItemPickup / Interact and gates HUD clicks across 800x600 and 1024x768', () => {
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'Hero', x: 100, y: 100 },
isSelf: true,
})
const hudManager = new HudManager()
hudManager.resize(800, 600)
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
const automapView = new AutomapView()
automapView.setContext(0x11223344, 0, 1)
const camera = new ViewportCamera('800x600')
camera.snapTo(100, 100)
const dispatched: ClientCommand[] = []
const hudModel = new HudModel({
world,
hudManager,
predictor,
automap: automapView,
onCommand: (cmd) => dispatched.push(cmd),
})
const mapper = new CommandMapper({
world,
camera,
hudManager,
hudModel,
predictor,
automapView,
sendCommand: (cmd) => dispatched.push(cmd),
})
// 1. Click on bottom HUD control bar (y = 580 in 800x600) -> intercepted by HUD, 0 world commands
const hudClick = mapper.handlePointerDown(400, 580, 0, 1000)
expect(hudClick).toBeNull()
expect(dispatched.length).toBe(0)
// 2. Left-click in world space (center-right of screen: x=560, y=300) -> Move command + predictor path
const moveRec = mapper.handlePointerDown(560, 300, 0, 2000)
expect(moveRec).not.toBeNull()
expect(moveRec?.rateLimit.allowed).toBe(true)
expect(moveRec?.command.type).toBe('Move')
expect(predictor.getSnapshot().moving).toBe(true)
mapper.handlePointerUp(0)
// 3. Rapid second left-click within 40ms is coalesced by rate limiter and flushed after 120ms
const coalescedRec = mapper.handlePointerDown(592, 316, 0, 2040)
expect(coalescedRec?.rateLimit.allowed).toBe(false)
expect(coalescedRec?.rateLimit.coalesced).toBe(true)
mapper.handlePointerUp(0)
const flushed = mapper.tick(2170)
expect(flushed).not.toBeNull()
expect(flushed?.type).toBe('Move')
// 4. Right-click in world space -> SkillAt (hand: 'right')
const rightRec = mapper.handlePointerDown(464, 268, 2, 3000)
expect(rightRec?.command.type).toBe('SkillAt')
if (rightRec?.command.type === 'SkillAt') {
expect(rightRec.command.hand).toBe('right')
}
mapper.handlePointerUp(2)
// 5. Spawn ground item at subtile (104, 100) and click its screen projection
world.apply({
type: 'UnitAssign',
unit: { unitType: 4, id: 701, classId: 10, name: 'Gold', x: 104, y: 100 },
})
camera.snapTo(100, 100)
predictor.setPosition(100, 100)
const itemScreen = camera.subtileToScreen(104, 100)
const pickupRec = mapper.handlePointerDown(itemScreen.x, itemScreen.y, 0, 4000)
expect(pickupRec?.command).toEqual({
type: 'ItemPickup',
itemId: 701,
toCursor: false,
})
mapper.handlePointerUp(0)
// 5b. Spawn town NPC in Rogue Encampment (areaId = 1) with default mode 0 and click its screen projection
world.apply({ type: 'LoadAct', act: 0, mapSeed: 0x11223344, areaId: 1 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 801, classId: 148, name: 'Akara', x: 96, y: 100, lifePct: 128 },
})
camera.snapTo(100, 100)
predictor.setPosition(100, 100)
const npcBeforeCount = dispatched.length
const npcScreen = camera.subtileToScreen(96, 100)
const npcRec = mapper.handlePointerDown(npcScreen.x, npcScreen.y, 0, 4500)
expect(npcRec?.command.type).toBe('MoveToUnit')
expect(dispatched.slice(npcBeforeCount).map((c) => c.type)).toEqual([
'MoveToUnit',
'NpcInit',
'Interact',
])
mapper.handlePointerUp(0)
// 6. Resize to 1024x768 and verify HUD interception & keyboard hotkeys
hudManager.resize(1024, 768)
camera.resize('1024x768')
expect(mapper.isPointIntercepted(512, 740)).toBe(true) // bottom bar in 1024x768
expect(mapper.isPointIntercepted(512, 384)).toBe(false) // center play area
mapper.bindSkillHotkey('F1', { hand: 'right', skillId: 54 })
expect(mapper.handleKeyDown({ key: 'F1' }, 5000)).toBe(true)
// Server authority: F1 only requests SelectSkill; the slot changes when the server answers (0x23)
expect(dispatched[dispatched.length - 1]).toMatchObject({ type: 'SelectSkill', hand: 'right', skill: 54 })
expect(hudModel.rightSkillId).toBe(0)
world.apply({ type: 'ActiveSkill', unitId: 1, hand: 'right', skill: 54, itemId: 0xffffffff })
expect(hudModel.rightSkillId).toBe(54)
expect(hudManager.hotkeys.rightSkillId).toBe(54)
// Belt key '1' with an empty server belt emits nothing; once the server puts a potion there, BeltUse
// carries its real item id
expect(mapper.handleKeyDown({ key: '1' }, 6000)).toBe(true)
expect(dispatched.some((c) => c.type === 'BeltUse')).toBe(false)
world.apply({ type: 'ItemEvent', action: 0x0e, container: 'belt', item: makeBeltPotionItem(555, 0) })
expect(mapper.handleKeyDown({ key: '1' }, 6050)).toBe(true)
expect(dispatched.filter((c) => c.type === 'BeltUse')).toEqual([{ type: 'BeltUse', itemId: 555, shift: false }])
expect(mapper.handleKeyDown({ key: 'Tab' }, 6100)).toBe(true)
expect(automapView.getMode()).toBe('level')
expect(mapper.handleKeyDown({ key: 'Tab' }, 6200)).toBe(true)
expect(automapView.getMode()).toBe('act')
expect(mapper.handleKeyDown({ key: 'c' }, 6300)).toBe(true)
expect(hudManager.leftPanel).toBe('char')
expect(mapper.handleKeyDown({ key: 'Escape' }, 6400)).toBe(true)
expect(hudManager.leftPanel).toBe('none')
hudModel.dispose()
})
})
describe('OnlineSession (F16, F18, F20)', () => {
it('orchestrates D2OnlineFlow login -> character select -> game entry, server reconciliation, .d2cap replay, and graceful disconnect', async () => {
const eventCallbacks: ((ev: ServerEvent) => void)[] = []
const closeCallbacks: ((reason: CloseReason) => void)[] = []
const sentAdapterCommands: ClientCommand[] = []
const mockAdapter: GameServerAdapter = {
state: 'ingame',
stats: {
rttMs: 18,
bytesIn: 1024,
bytesOut: 256,
packetsIn: 20,
packetsOut: 5,
unknownPackets: 0,
opaquePackets: 0,
protocolErrors: 0,
},
onEvent(cb) {
eventCallbacks.push(cb)
return () => {}
},
onClose(cb) {
closeCallbacks.push(cb)
return () => {}
},
send(cmd) {
sentAdapterCommands.push(cmd)
},
leave: vi.fn(),
}
let flowState: D2OnlineState = 'idle'
const mockChars = [
{
name: 'webbottwo',
charClass: 1 as const,
classId: 1 as const,
level: 85,
hardcore: false,
expansion: true,
ladder: true,
dead: false,
act: 1,
difficulty: 0 as const,
expiresAt: 0,
equipmentGraphics: new Uint8Array(9),
rawStatString: new Uint8Array(33),
},
]
const mockFlow: D2OnlineFlow = {
get state() {
return flowState
},
onState: vi.fn(() => () => {}),
connectBnet: vi.fn(async () => {}),
connect: vi.fn(async () => {}),
login: vi.fn(async () => {
flowState = 'authenticated'
}),
loginOrRegister: vi.fn(async () => {
flowState = 'authenticated'
}),
createAccount: vi.fn(async () => {}),
listRealms: vi.fn(async () => [{ id: 1, title: 'D2Realm', description: 'PvPGN Realm' }]),
enterRealm: vi.fn(async () => {
flowState = 'realm_ready'
return mockChars
}),
realmLogon: vi.fn(async () => mockChars),
listCharacters: vi.fn(async () => mockChars),
listChars: vi.fn(async () => mockChars),
createCharacter: vi.fn(async () => {}),
createChar: vi.fn(async () => {}),
deleteCharacter: vi.fn(async () => {}),
deleteChar: vi.fn(async () => {}),
upgradeCharacter: vi.fn(async () => {}),
upgradeChar: vi.fn(async () => {}),
selectCharacter: vi.fn(async () => {
flowState = 'realm_ready'
}),
charLogon: vi.fn(async () => {}),
enterChat: vi.fn(async () => {}),
joinChannel: vi.fn(async () => {}),
sendChannelChat: vi.fn(async () => {}),
sendChat: vi.fn(async () => {}),
onChannelEvent: vi.fn(() => () => {}),
onChatEvent: vi.fn(() => () => {}),
listGames: vi.fn(async () => [
{
index: 1,
name: 'baal-01',
description: 'walk test',
playerCount: 1,
status: 0,
},
]),
getGameInfo: vi.fn(async () => ({
name: 'baal-01',
status: 0,
uptimeSec: 10,
maxPlayers: 8,
players: [],
})),
createGame: vi.fn(async () => {}),
joinGame: vi.fn(async () => {
flowState = 'ingame'
return mockAdapter
}),
leaveToLobby: vi.fn(async () => {
flowState = 'realm_ready'
}),
close: vi.fn(async () => {
flowState = 'closed'
}),
}
const settingsStore = createSettingsStore()
const session = new OnlineSession({
settingsStore,
flowFactory: () => mockFlow,
})
// 1. Login & enter game
const loginRes = await session.login({
username: 'webbottwo',
password: 'password123',
})
expect(loginRes.characters.length).toBe(1)
expect(session.phase).toBe('lobby')
await session.selectCharacter('webbottwo')
const games = await session.listGames()
expect(games.length).toBe(1)
await session.enterGame({
mode: 'create',
spec: { name: 'testgame1', difficulty: 0, maxPlayers: 8 },
})
expect(session.phase).toBe('ingame')
// 2. Emit authoritative events from GameServerAdapter
for (const cb of eventCallbacks) {
cb({ type: 'LoadAct', act: 0, mapSeed: 0xdeadbeef, areaId: 1 })
cb({ type: 'RoomReveal', areaId: 1, tileX: 100, tileY: 100 })
cb({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'webbottwo', x: 500, y: 500 },
isSelf: true,
})
// Max pools arrive as 8.8 fixed-point base attributes (0x1E) before life/mana updates (0x95, plain)
cb({ type: 'StatSet', stat: D2StatId.MaxHp, value: 400 << 8 })
cb({ type: 'StatSet', stat: D2StatId.MaxMana, value: 450 << 8 })
cb({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 200 << 8 })
cb({
type: 'LifeMana',
life: 320,
mana: 410,
stamina: 190,
at: { x: 502, y: 501 }, // drift <= 5 -> rubber-band
})
}
expect(session.world.areaId).toBe(1)
expect(session.hudManager.hp).toBe(320)
expect(session.hudManager.mana).toBe(410)
expect(session.predictor.getSnapshot().logicalX).toBe(502)
expect(session.predictor.getSnapshot().logicalY).toBe(501)
// Step frame tick and build SceneSource snapshot
session.tick(40, 1000)
const frameSnap = session.sceneSource.buildFrameSnapshot()
expect(frameSnap.levelId).toBe(1)
expect(frameSnap.entities.unitDrawables.length).toBeGreaterThanOrEqual(1)
// 3. Deterministic .d2cap offline replay
const capHeader = formatD2CapHeader({
format: 'd2cap-v1',
createdAt: '2026-09-29T00:00:00Z',
server: '101.37.117.183',
account: 'webbottwo',
character: 'webbottwo',
})
// 0x03 LoadAct (12B): act=1 (Act 2), seed=0x01020304, areaId=40 (Lut Gholein), objSeed=0
const pktLoadAct = new Uint8Array([
0x03, 0x01, 0x04, 0x03, 0x02, 0x01, 40, 0x00, 0, 0, 0, 0,
])
// 0x15 ReassignPlayer (11B): unitType=0, id=1, x=620, y=640, snap=1
const pktReassign = new Uint8Array([
0x15, 0x00, 1, 0, 0, 0, 0x6c, 0x02, 0x80, 0x02, 1,
])
const d2capText = [
capHeader,
formatD2CapEntry({ t: 10, conn: 'game', dir: 's2c', raw: pktLoadAct, packetId: 0x03 }),
formatD2CapEntry({ t: 20, conn: 'game', dir: 's2c', raw: pktReassign, packetId: 0x15 }),
].join('\n')
const loadedCount = session.loadD2CapReplay(d2capText)
expect(loadedCount).toBe(2)
expect(session.phase).toBe('replay')
const replayedCount = session.replayAllD2Cap()
expect(replayedCount).toBe(2)
expect(session.world.areaId).toBe(40)
expect(session.predictor.getSnapshot().logicalX).toBe(620)
expect(session.predictor.getSnapshot().logicalY).toBe(640)
expect(session.packetInspector.getAllRows().length).toBeGreaterThanOrEqual(2)
// 4. Graceful disconnect recovery
const disconnectReasons: string[] = []
session.onDisconnect((r) => disconnectReasons.push(r))
session.handleServerEvent(decodeCapturedGameS2cPacket(new Uint8Array([0x06]))) // 0x06 GameLogout
await session.disconnect('Test server disconnect')
expect(session.phase).toBe('disconnected')
expect(session.disconnectReason).toBe('Test server disconnect')
session.destroy()
})
it('replays all 4 live .d2cap wire captures through OnlineSession with 0 Opaque events and full 0x9C/0x9D item decoding', () => {
const fixtureFiles = [
'tests/fixtures/netproto/01-bncs-mcp-lobby.d2cap',
'tests/fixtures/netproto/02-d2gs-town-walk-chat.d2cap',
'tests/fixtures/netproto/03-d2gs-items-npc-skills.d2cap',
'tests/fixtures/netproto/04-d2gs-idle-60s.d2cap',
]
for (const relPath of fixtureFiles) {
const jsonlText = readFileSync(resolve(process.cwd(), relPath), 'utf8')
const session = new OnlineSession({
settingsStore: createSettingsStore(),
})
const events: ServerEvent[] = []
session.onServerEvent((ev) => events.push(ev))
const recordCount = session.loadD2CapReplay(jsonlText)
expect(recordCount).toBeGreaterThan(100)
const replayed = session.replayAllD2Cap()
expect(replayed).toBe(recordCount)
const opaqueEvents = events.filter((e) => e.type === 'Opaque')
expect(opaqueEvents).toHaveLength(0)
expect(events.length).toBeGreaterThan(50)
expect(session.world.areaId).toBe(1)
expect(session.world.self.name).toBe('WebBotOne')
expect(session.world.containers.inventory.size).toBeGreaterThanOrEqual(2)
expect(session.world.containers.equipment.size).toBeGreaterThanOrEqual(2)
expect(session.world.containers.belt.size).toBeGreaterThanOrEqual(4)
expect(session.hudManager.inventory.gridItems.length).toBeGreaterThanOrEqual(2)
const decodedRows = session.packetInspector
.getAllRows()
.filter((r) => r.decoded !== undefined)
expect(decodedRows.length).toBeGreaterThan(0)
session.destroy()
}
})
})
})