312 lines
9.9 KiB
TypeScript
312 lines
9.9 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('normalizes 8.8 fixed-point pool stats 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,
|
|
})
|
|
|
|
// 50 life in 8.8 fixed point = 12800 (0x3200)
|
|
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)
|
|
|
|
// 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)
|
|
})
|
|
})
|