diablo2-web/tests/client/client-world.test.ts

325 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import type { ItemData } from '../../src/netproto/index.ts'
function makeItem(overrides: Partial<ItemData> & { id: number; code: string }): ItemData {
return {
id: overrides.id,
itemId: overrides.itemId ?? overrides.id,
opcode: overrides.opcode ?? 0x9c,
action: overrides.action ?? 0,
category: overrides.category ?? 0,
flags: overrides.flags ?? 0x00200010,
rawFlags: overrides.rawFlags ?? 0x00200010,
version: overrides.version ?? 101,
location: overrides.location ?? 0,
container: overrides.container ?? 'inventory',
bodyLoc: overrides.bodyLoc ?? overrides.equippedSlot ?? 0,
equippedSlot: overrides.equippedSlot ?? overrides.bodyLoc ?? 0,
gridX: overrides.gridX ?? 0,
gridY: overrides.gridY ?? 0,
storePage: overrides.storePage ?? 1,
storagePage: overrides.storagePage ?? 1,
worldX: overrides.worldX ?? 0,
worldY: overrides.worldY ?? 0,
x: overrides.x ?? overrides.worldX ?? 0,
y: overrides.y ?? overrides.worldY ?? 0,
ownerType: overrides.ownerType,
ownerId: overrides.ownerId,
code: overrides.code,
rawCode: overrides.rawCode ?? `${overrides.code} `,
compact: overrides.compact ?? true,
simple: overrides.simple ?? true,
identified: overrides.identified ?? true,
socketed: overrides.socketed ?? false,
ethereal: overrides.ethereal ?? false,
runeword: overrides.runeword ?? false,
personalized: overrides.personalized ?? false,
isEar: overrides.isEar ?? false,
newItem: overrides.newItem ?? false,
starterItem: overrides.starterItem ?? false,
broken: overrides.broken ?? false,
inSocket: overrides.inSocket ?? false,
equipped: overrides.equipped ?? false,
socketedChildCount: overrides.socketedChildCount ?? 0,
usedSockets: overrides.usedSockets ?? 0,
totalSockets: overrides.totalSockets ?? 0,
invWidth: overrides.invWidth ?? 1,
invHeight: overrides.invHeight ?? 1,
quality: overrides.quality,
ilvl: overrides.ilvl ?? 1,
graphicIndex: overrides.graphicIndex,
defense: overrides.defense,
durability: overrides.durability,
maxDurability: overrides.maxDurability,
quantity: overrides.quantity,
stats: overrides.stats ?? [],
setBonusStats: overrides.setBonusStats ?? [],
runewordStats: overrides.runewordStats ?? [],
}
}
describe('ClientWorld authoritative state mirror', () => {
it('applies lifecycle, act/mapSeed, room reveal/hide, and self player assignment events', () => {
const world = new ClientWorld()
expect(world.loading).toBe(true)
world.apply({
type: 'GameFlags',
difficulty: 1,
hardcore: true,
expansion: true,
ladder: true,
})
expect(world.difficulty).toBe(1)
expect(world.hardcore).toBe(true)
expect(world.expansion).toBe(true)
expect(world.ladder).toBe(true)
world.apply({
type: 'LoadAct',
act: 0,
mapSeed: 0x12345678,
areaId: 1,
objectSeed: 0x9abcdef0,
})
expect(world.act).toBe(0)
expect(world.mapSeed).toBe(0x12345678)
expect(world.areaId).toBe(1)
expect(world.objectSeed).toBe(0x9abcdef0)
world.apply({ type: 'RoomReveal', areaId: 1, tileX: 100, tileY: 200 })
expect(world.revealedRooms.has('1:100:200')).toBe(true)
world.apply({
type: 'UnitAssign',
unit: {
unitType: 0,
id: 1,
classId: 1, // Sorceress
name: 'SorcHero',
x: 5100,
y: 5200,
},
isSelf: true,
})
// 0x0B GameHandshake carries classId: -1 and must not overwrite classId=1
world.apply({
type: 'UnitAssign',
unit: {
unitType: 0,
id: 1,
classId: -1,
x: 0,
y: 0,
},
isSelf: true,
})
expect(world.selfId).toBe(1)
expect(world.self.name).toBe('SorcHero')
expect(world.self.charClass).toBe(1)
expect(world.self.x).toBe(5100)
expect(world.self.y).toBe(5200)
expect(world.getUnit(0, 1)?.classId).toBe(1)
world.apply({ type: 'LoadComplete' })
expect(world.loading).toBe(false)
world.apply({ type: 'RoomHide', areaId: 1, tileX: 100, tileY: 200 })
expect(world.revealedRooms.has('1:100:200')).toBe(false)
})
it('decodes 8.8 fixed-point pool stats 6..11 (ValShift 8) and ignores negative sentinels from WalkVerify', () => {
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 },
isSelf: true,
})
// 1.13c wire values for stats 6..11 are 8.8 fixed point (ItemStatCost ValShift = 8):
// capture bytes `1e 07 00 32` -> u16-LE 12800 = 50 max life for a fresh level-1 character.
world.apply({ type: 'StatSet', stat: 6, value: 12800 })
world.apply({ type: 'StatSet', stat: 7, value: 12800 })
world.apply({ type: 'StatSet', stat: 8, value: 6400 })
world.apply({ type: 'StatSet', stat: 9, value: 6400 })
world.apply({ type: 'StatSet', stat: 10, value: 24320 })
world.apply({ type: 'StatSet', stat: 11, value: 24320 })
expect(world.self.life).toBe(50)
expect(world.self.maxLife).toBe(50)
expect(world.self.mana).toBe(25)
expect(world.self.maxMana).toBe(25)
expect(world.self.stamina).toBe(95)
expect(world.self.maxStamina).toBe(95)
// The StatList keeps the raw wire value; only the pool fields are shifted
expect(world.self.stats.get(7)).toBe(12800)
// The shift is unconditional (no magnitude heuristic): raw 128 = 0.5 -> 0, raw 420 << 8 -> 420
world.apply({ type: 'StatSet', stat: 7, value: 128 })
expect(world.self.maxLife).toBe(0)
expect(world.self.stats.get(7)).toBe(128)
world.apply({ type: 'StatSet', stat: 7, value: 420 << 8 })
expect(world.self.maxLife).toBe(420)
world.apply({ type: 'StatSet', stat: 7, value: 12800 })
expect(world.self.maxLife).toBe(50)
// WalkVerify (0x96) sends life: -1, mana: -1, stamina: 90, at: (5010, 5012)
world.apply({
type: 'LifeMana',
life: -1,
mana: -1,
stamina: 90,
at: { x: 5010, y: 5012 },
})
expect(world.self.life).toBe(50)
expect(world.self.mana).toBe(25)
expect(world.self.stamina).toBe(90)
expect(world.self.x).toBe(5010)
expect(world.self.y).toBe(5012)
})
it('tracks item container moves across ground, inventory, equipment, belt, and cursor', () => {
const world = new ClientWorld()
const sword = makeItem({
id: 500,
code: 'ssd',
action: 0, // AddToGround
container: 'ground',
worldX: 5120,
worldY: 5130,
})
world.apply({ type: 'ItemEvent', action: 0, item: sword, container: 'ground' })
expect(world.containers.ground.has(500)).toBe(true)
// Pick up to inventory (action 4 / PutInContainer)
const inInv = makeItem({
id: 500,
code: 'ssd',
action: 4,
container: 'inventory',
gridX: 2,
gridY: 1,
})
world.apply({ type: 'ItemEvent', action: 4, item: inInv, container: 'inventory' })
expect(world.containers.ground.has(500)).toBe(false)
expect(world.containers.inventory.get(500)?.gridX).toBe(2)
// Move to cursor (action 0x02 / ToCursor)
const onCursor = makeItem({
id: 500,
code: 'ssd',
action: 2,
container: 'cursor',
})
world.apply({ type: 'ItemEvent', action: 2, item: onCursor, container: 'cursor' })
expect(world.containers.inventory.has(500)).toBe(false)
expect(world.containers.cursor?.id).toBe(500)
// Equip to right arm (slot 4)
const equipped = makeItem({
id: 500,
code: 'ssd',
action: 6,
container: 'equipment',
equippedSlot: 4,
bodyLoc: 4,
})
world.apply({ type: 'ItemEvent', action: 6, item: equipped, container: 'equipment' })
expect(world.containers.cursor).toBeNull()
expect(world.containers.equipment.get(500)?.id).toBe(500)
expect(world.containers.equipmentBySlot.get(4)?.id).toBe(500)
})
it('tracks skills, states, quests, waypoints, portals, party, trade, chat, sound, and RTT', () => {
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 },
isSelf: true,
})
world.apply({
type: 'SkillList',
unitId: 1,
skills: [
{ skillId: 36, level: 5, baseLevel: 5, bonus: 2 },
{ skillId: 37, level: 1, baseLevel: 1, bonus: 0 },
],
})
expect(world.self.skills.get(36)).toBe(7)
expect(world.self.skillEntries.get(36)).toEqual({
skillId: 36,
skill: 36,
level: 5,
baseLevel: 5,
bonus: 2,
})
world.apply({ type: 'ActiveSkill', unitId: 1, hand: 'right', skill: 36 })
expect(world.self.rightSkill).toBe(36)
world.apply({
type: 'StateSet',
unit: { unitType: 0, id: 1 },
state: 16,
stats: [{ statId: 31, param: 0, value: 45 }],
})
expect(world.getUnit(0, 1)?.states.has(16)).toBe(true)
expect(world.self.states.has(16)).toBe(true)
world.apply({ type: 'StateEnd', unit: { unitType: 0, id: 1 }, state: 16 })
expect(world.getUnit(0, 1)?.states.has(16)).toBe(false)
expect(world.self.states.has(16)).toBe(false)
world.apply({ type: 'QuestState', kind: 'log', quests: new Uint16Array([0x0001, 0x0004]) })
expect(world.quests[0]).toBe(0x0001)
expect(world.quests[1]).toBe(0x0004)
world.apply({
type: 'WaypointMenu',
unitId: 10,
open: [0, 1],
})
expect(world.waypoints.has(0)).toBe(true)
expect(world.waypoints.has(1)).toBe(true)
expect(world.waypoints.has(2)).toBe(false)
world.apply({
type: 'Portal',
ownerId: 1,
ownerName: 'SorcHero',
localId: 99,
remoteId: 100,
areaId: 2,
flags: 1,
})
expect(world.portals.get(99)?.areaId).toBe(2)
world.apply({
type: 'Party',
kind: 'roster',
unitId: 2,
charName: 'PaladinBro',
charClass: 3,
charLevel: 12,
partyId: 1,
areaId: 1,
lifePct: 100,
})
expect(world.party.get(2)?.charName).toBe('PaladinBro')
world.apply({ type: 'Chat', kind: 'say', from: 'PaladinBro', text: 'tp up' })
expect(world.chatLog).toHaveLength(1)
world.apply({ type: 'Chat', kind: 'overhead', unit: { unitType: 0, id: 1 }, text: 'follow me' })
expect(world.getUnit(0, 1)?.overheadText).toBe('follow me')
world.apply({ type: 'PlaySound', unit: { unitType: 0, id: 1 }, sound: 19 })
expect(world.drainSoundQueue()).toHaveLength(1)
expect(world.soundQueue).toHaveLength(0)
world.apply({ type: 'Pong', rttMs: 42, tickCount: 1000 })
expect(world.lastRttMs).toBe(42)
})
})