770 lines
26 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|
|
})
|