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

1062 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import type { ClientCommand, 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,
goldAmount: overrides.goldAmount,
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)
})
it('encodes all 18 M1 C2S opcodes matching C2S_PACKET_SIZES and encodeClientCommand', async () => {
const netproto = await import('../../src/netproto/index.ts')
const {
C2S_PACKET_SIZES,
encodeClientCommand,
encodeC2sWarpTo,
encodeC2sSwapEquippedItem,
encodeC2sSwapContainerItem,
encodeC2sBeltPut,
encodeC2sBeltRemove,
encodeC2sBeltSwap,
encodeC2sBeltUse,
encodeC2sIdentifyItem,
encodeC2sSocketItem,
encodeC2sScrollToBook,
encodeC2sQuestMessage,
encodeC2sNpcMercHire,
encodeC2sNpcEntityAction,
encodeC2sResurrect,
encodeC2sClickButton,
encodeC2sHostile,
encodeC2sPartyAction,
encodeC2sMercItem,
encodeC2sNpcMercResurrect,
} = netproto
const directPackets: Array<{ opcode: number; pkt: Uint8Array }> = [
{ opcode: 0x09, pkt: encodeC2sWarpTo(42, 5) },
{ opcode: 0x1d, pkt: encodeC2sSwapEquippedItem(100, 4) },
{ opcode: 0x1f, pkt: encodeC2sSwapContainerItem(101, 102, 3, 2) },
{ opcode: 0x23, pkt: encodeC2sBeltPut(103, 0) },
{ opcode: 0x24, pkt: encodeC2sBeltRemove(103) },
{ opcode: 0x25, pkt: encodeC2sBeltSwap(104, 103) },
{ opcode: 0x26, pkt: encodeC2sBeltUse(103, false) },
{ opcode: 0x27, pkt: encodeC2sIdentifyItem(105, 106) },
{ opcode: 0x28, pkt: encodeC2sSocketItem(107, 108) },
{ opcode: 0x29, pkt: encodeC2sScrollToBook(109, 110) },
{ opcode: 0x31, pkt: encodeC2sQuestMessage(15, 25) },
{ opcode: 0x36, pkt: encodeC2sNpcMercHire(15, 7) },
{ opcode: 0x38, pkt: encodeC2sNpcEntityAction(1, 15, 0) },
{ opcode: 0x41, pkt: encodeC2sResurrect() },
{ opcode: 0x4f, pkt: encodeC2sClickButton(0x12, 0) },
{ opcode: 0x5d, pkt: encodeC2sHostile(2, true, 0x04) },
{ opcode: 0x5e, pkt: encodeC2sPartyAction(6, 2) },
{ opcode: 0x61, pkt: encodeC2sMercItem(4) },
{ opcode: 0x62, pkt: encodeC2sNpcMercResurrect(15) },
]
for (const { opcode, pkt } of directPackets) {
expect(pkt[0]).toBe(opcode)
expect(pkt.byteLength).toBe(C2S_PACKET_SIZES[opcode])
}
const cmds: ClientCommand[] = [
{ type: 'WarpTo', warpId: 42 },
{ type: 'TakeWarp', warpId: 42 },
{ type: 'ItemSwap', itemId: 100, slot: 4 },
{ type: 'SwapContainerItem', cursorItemId: 101, replacedItemId: 102, x: 3, y: 2 },
{ type: 'BeltPut', itemId: 103, slot: 0 },
{ type: 'BeltRemove', itemId: 103 },
{ type: 'BeltSwap', cursorItemId: 104, beltItemId: 103 },
{ type: 'BeltUse', itemId: 103, shift: true },
{ type: 'IdentifyItem', itemId: 105, scrollId: 106 },
{ type: 'SocketItem', itemId: 107, targetItemId: 108 },
{ type: 'ScrollToBook', scrollId: 109, bookId: 110 },
{ type: 'QuestMessage', npcId: 15, messageId: 25 },
{ type: 'MercHire', npcId: 15, mercId: 7 },
{ type: 'NpcEntityAction', action: 3, npcId: 15, complement: 0 },
{ type: 'Resurrect' },
{ type: 'ClickButton', buttonId: 0x12, amount: 0 },
{ type: 'Hostile', targetPlayerId: 2, hostile: true },
{ type: 'PartyAction', action: 6, targetPlayerId: 2 },
{ type: 'MercItem', slot: 4 },
{ type: 'MercResurrect', npcId: 15 },
]
for (const cmd of cmds) {
const pkts = encodeClientCommand(cmd)
expect(pkts.length).toBe(1)
const pkt = pkts[0]!
expect(pkt.byteLength).toBe(C2S_PACKET_SIZES[pkt[0]!])
}
})
it('resolves all Act 1–5 town & quest NPC descriptors by hcIdx', async () => {
const {
getTownNpcDescriptorByHcIdx,
NPC_HCIDX_TO_DESCRIPTOR_ID,
TOWN_NPC_SERVICES,
} = await import('../../src/common/units/npc-table.ts')
const expectedHcIdx = [
146, 147, 148, 150, 154, 155, 265,
175, 176, 177, 178, 198, 199, 200, 201, 202, 203, 210, 244, 251,
245, 252, 253, 254, 255, 256, 257, 258, 297,
246, 259, 367, 405, 534,
511, 512, 513, 514, 515, 520, 521,
]
for (const hcIdx of expectedHcIdx) {
const desc = getTownNpcDescriptorByHcIdx(hcIdx)
expect(desc, `Missing NPC descriptor for hcIdx=${hcIdx}`).toBeDefined()
expect(NPC_HCIDX_TO_DESCRIPTOR_ID[hcIdx]).toBe(desc!.id)
expect(desc!.gossipLines?.length).toBeGreaterThan(0)
}
expect(TOWN_NPC_SERVICES.kashya?.canHire).toBe(true)
expect(TOWN_NPC_SERVICES.greiz?.canHire).toBe(true)
expect(TOWN_NPC_SERVICES.asheara?.canHire).toBe(true)
expect(TOWN_NPC_SERVICES.qual_kehk?.canHire).toBe(true)
expect(TOWN_NPC_SERVICES.warriv?.travelActions).toContain('travel_east')
expect(TOWN_NPC_SERVICES.meshif_act2?.travelActions).toContain('travel_east')
expect(TOWN_NPC_SERVICES.tyrael_act4?.travelActions).toContain('travel_harrogath')
expect(TOWN_NPC_SERVICES.charsi?.questRewardActions).toContain('imbue')
expect(TOWN_NPC_SERVICES.larzuk?.questRewardActions).toContain('add_sockets')
expect(TOWN_NPC_SERVICES.anya?.questRewardActions).toContain('personalize')
})
it('bridges online ItemData into UiInventoryItem with canonical 1.13c names, stats, requirements, and sockets', async () => {
const { onlineItemDataToUiInventoryItem } = await import('../../src/common/items/item-bridge.ts')
const { resolveGroundItemMetadata } = await import('../../src/common/items/ground-items.ts')
// 1. Unique item with stats and sockets
const shako = makeItem({
id: 700,
code: 'uap',
quality: 7,
identified: true,
defense: 141,
durability: 12,
maxDurability: 12,
totalSockets: 1,
usedSockets: 1,
stats: [
{ statId: 127, param: 0, value: 2 }, // item_allskills: +2 to All Skill Levels
{ statId: 0, param: 0, value: 2 }, // strength: +2 to Strength
{ statId: 80, param: 0, value: 50 }, // item_magicbonus: 50% Better Chance of Getting Magic Items
],
})
const topaz = makeItem({
id: 701,
code: 'gpz',
quality: 2,
identified: true,
inSocket: true,
ownerId: 700,
stats: [{ statId: 80, param: 0, value: 24 }],
})
const uiShako = onlineItemDataToUiInventoryItem(shako, [topaz])
expect(uiShako.quality).toBe('unique')
expect(uiShako.name).toContain('Harlequin Crest')
expect(uiShako.reqLevel).toBeGreaterThanOrEqual(43)
expect(uiShako.sockets).toBe(1)
expect(uiShako.socketedItems).toHaveLength(1)
expect(uiShako.stats.some(s => !s.text.startsWith('Stat #'))).toBe(true)
expect((uiShako.statsEn ?? uiShako.stats).some(s => s.text.includes('Strength') || s.text.includes('Skill'))).toBe(true)
// 2. Ground gold pile with goldAmount
const goldItem = makeItem({
id: 702,
code: 'gld',
goldAmount: 350,
})
const uiGold = onlineItemDataToUiInventoryItem(goldItem)
expect(uiGold.name).toBe('350 Gold')
const groundMeta = resolveGroundItemMetadata(goldItem)
expect(groundMeta.labelText).toBe('350 Gold')
})
it('reduces Npc, Merc, SkillCast, player death/resurrect, Party, Trade, and Tile warpId in ClientWorld', () => {
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: 'StatSet', stat: 7, value: 100 << 8 }) // maxLife = 100
world.apply({ type: 'StatSet', stat: 6, value: 100 << 8 }) // life = 100
expect(world.isDead).toBe(false)
// 1. Tile warpId
world.apply({
type: 'UnitAssign',
unit: { unitType: 5, id: 300, classId: 18, warpId: 18, x: 5020, y: 5020 },
})
expect(world.getUnit(5, 300)?.warpId).toBe(18)
// 2. Ground item goldAmount & name
const groundGold = makeItem({
id: 800,
code: 'gld',
action: 0,
container: 'ground',
worldX: 5005,
worldY: 5005,
goldAmount: 420,
})
world.apply({ type: 'ItemEvent', action: 0, item: groundGold, container: 'ground' })
const itemUnit = world.getUnit('item', 800)
expect(itemUnit?.goldAmount).toBe(420)
expect(itemUnit?.name).toBe('420 Gold')
// 3. NPC interaction & heal
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 50, classId: 148, name: 'Akara', x: 5010, y: 5010, lifePct: 80 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 50 } })
expect(world.npcInteraction.npcId).toBe(50)
world.apply({ type: 'Npc', kind: 'info', npcId: 50, raw: new Uint8Array([0x27, 0x01]) })
world.apply({ type: 'Npc', kind: 'menu', npcId: 50, raw: new Uint8Array([0x91, 0x01]) })
expect(world.npcInteraction.npcInfoRaw?.[0]).toBe(0x27)
expect(world.npcInteraction.npcTalkRaw?.[0]).toBe(0x91)
world.noteOutgoingCommand({ type: 'NpcEntityAction', action: 3, npcId: 50 })
expect(world.npcInteraction.activeWindow).toBe('hire')
world.apply({ type: 'Npc', kind: 'heal', npcId: 50, result: 112 })
expect(world.getUnit('monster', 50)?.lifePct).toBe(112)
// 4. Mercenary hire list, assign, equipment, and revive cost
world.apply({ type: 'Merc', kind: 'hireList' }) // 0x4F clear
world.apply({ type: 'Merc', kind: 'hireList', nameId: 10, value: 0x11223344 })
world.apply({ type: 'Merc', kind: 'hireList', nameId: 11, value: 0x55667788 })
expect(world.mercHireList).toHaveLength(2)
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 90, classId: 271, nameId: 10 })
expect(world.merc?.mercId).toBe(90)
expect(world.merc?.isDead).toBe(false)
const mercHelm = makeItem({
id: 810,
code: 'cap',
action: 6,
container: 'merc',
ownerType: 1,
ownerId: 90,
equippedSlot: 1,
bodyLoc: 1,
})
world.apply({ type: 'ItemEvent', action: 6, item: mercHelm, container: 'merc' })
expect(world.containers.merc.has(810)).toBe(true)
expect(world.containers.merc.get(1)?.id).toBe(810)
// Unequip merc helm to cursor
world.apply({ type: 'ItemEvent', action: 8, item: mercHelm, container: 'merc' })
expect(world.containers.merc.has(810)).toBe(false)
expect(world.containers.merc.get(1)).toBeUndefined()
expect(world.containers.cursor?.id).toBe(810)
// Merc death & revive cost (0x9B)
world.apply({ type: 'Merc', kind: 'stat', nameId: 10, value: 15000 })
expect(world.merc?.isDead).toBe(true)
expect(world.merc?.reviveCost).toBe(15000)
// 5. SkillCast animation state & direction
world.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 36,
skillId: 36, // Fire Bolt (spellcast mode 10)
level: 1,
target: { x: 5010, y: 5000 },
})
const selfUnit = world.getUnit(0, 1)!
expect(selfUnit.mode).toBe(10)
expect(selfUnit.castState?.skillId).toBe(36)
expect(world.self.castState?.skillId).toBe(36)
// 6. Player death and town resurrection
world.apply({ type: 'LifeMana', life: 0, mana: 10, stamina: 50, at: { x: 5000, y: 5000 } })
expect(world.isDead).toBe(true)
expect(world.self.isDead).toBe(true)
world.noteOutgoingCommand({ type: 'Resurrect' })
expect(world.isDead).toBe(false)
// 7. Party roster, relation, hostility, loot permit, position
world.apply({
type: 'Party',
kind: 'roster',
unitId: 2,
charName: 'BarbPeer',
charClass: 4,
charLevel: 20,
partyId: 0xffff, // 0x5B unpartied player joining game
})
expect(world.party.has(2)).toBe(true)
expect(world.party.get(2)?.partyState).toBe(0)
world.apply({ type: 'Party', kind: 'relation', unitId: 2, relation: 2 }) // invited
expect(world.party.get(2)?.partyState).toBe(2)
world.apply({ type: 'Party', kind: 'roster', unitId: 2, partyId: 1 }) // joined party 1
expect(world.party.get(2)?.partyState).toBe(1)
world.apply({ type: 'Party', kind: 'relation', unitId: 2, otherId: 1, relation: 0x0c }) // hostile + loot
expect(world.party.get(2)?.hostile).toBe(true)
expect(world.party.get(2)?.lootPermit).toBe(true)
world.apply({ type: 'Party', kind: 'memberPos', unitId: 2, at: { x: 5100, y: 5200 } })
expect(world.party.get(2)?.x).toBe(5100)
expect(world.party.get(2)?.y).toBe(5200)
world.apply({ type: 'Party', kind: 'roster', unitId: 2, partyId: 0xffff }) // 0x5C left game
expect(world.party.has(2)).toBe(false)
// 8. Trade session state & trade grids
world.apply({ type: 'Trade', action: 0x01 }) // open trade window
expect(world.trade.active).toBe(true)
const myTradeItem = makeItem({
id: 820,
code: 'rin',
action: 4,
container: 'trade',
ownerId: 1,
gridX: 0,
gridY: 0,
})
const partnerTradeItem = makeItem({
id: 821,
code: 'amu',
action: 4,
container: 'trade',
ownerId: 2,
gridX: 1,
gridY: 0,
})
world.apply({ type: 'ItemEvent', action: 4, item: myTradeItem, container: 'trade' })
world.apply({ type: 'ItemEvent', action: 4, item: partnerTradeItem, container: 'trade' })
expect(world.containers.tradeSelf.has(820)).toBe(true)
expect(world.containers.tradePartner.has(821)).toBe(true)
expect(world.containers.tradeGrid.some(i => i.id === 820)).toBe(true)
expect(world.containers.partnerTradeGrid.some(i => i.id === 821)).toBe(true)
world.apply({ type: 'Trade', action: 0x78, partnerId: 2, partnerName: 'BarbPeer' })
expect(world.trade.partnerAccepted).toBe(true)
expect(world.trade.partnerName).toBe('BarbPeer')
world.apply({ type: 'Trade', action: 0x7901, gold: 5000 })
world.apply({ type: 'Trade', action: 0x7900, gold: 8000 })
expect(world.trade.selfGold).toBe(5000)
expect(world.trade.partnerGold).toBe(8000)
world.apply({ type: 'Trade', action: 0x09 }) // disable accept
expect(world.trade.acceptDisabled).toBe(true)
expect(world.trade.partnerAccepted).toBe(false)
world.apply({ type: 'Trade', action: 0x0a }) // enable accept
expect(world.trade.acceptDisabled).toBe(false)
world.apply({ type: 'Trade', action: 0x02 }) // cancel trade
expect(world.trade.active).toBe(false)
expect(world.containers.tradeGrid).toHaveLength(0)
expect(world.containers.partnerTradeGrid).toHaveLength(0)
})
it('R1: attaches canonical 1.13c cast overlays, deduplicates local cast echoes within 250ms, and expires castState to neutral mode 1', async () => {
const { getSkillCastOverlay } = await import('../../src/common/skills/missiles-data.ts')
expect(getSkillCastOverlay(36)).toBe('fire_cast_1')
expect(getSkillCastOverlay(39)).toBe('ice_cast_1')
expect(getSkillCastOverlay(44)).toBe('ice_cast_2')
expect(getSkillCastOverlay(47)).toBe('fire_cast_2')
expect(getSkillCastOverlay(49)).toBe('light_cast_1')
expect(getSkillCastOverlay(54)).toBe('teleport')
expect(getSkillCastOverlay(62)).toBe('fire_cast_2')
expect(getSkillCastOverlay(64)).toBe('ice_cast_3')
expect(getSkillCastOverlay(66)).toBe('cursecast')
expect(getSkillCastOverlay(67)).toBe('bonecast')
expect(getSkillCastOverlay(68)).toBe('bonearmor_cast')
expect(getSkillCastOverlay(84)).toBe('bonecast')
expect(getSkillCastOverlay(101)).toBe('holy_bolt_cast')
expect(getSkillCastOverlay(112)).toBe('holy_bolt_cast')
expect(getSkillCastOverlay(121)).toBe('fist_of_the_heavens_cast')
expect(getSkillCastOverlay(8)).toBe('cast_innersight')
expect(getSkillCastOverlay(32)).toBe('valkyriestart')
expect(getSkillCastOverlay(138)).toBe('shoutstart')
expect(getSkillCastOverlay(149)).toBe('battleorderscast')
expect(getSkillCastOverlay(155)).toBe('battlecommandcast')
expect(getSkillCastOverlay(225)).toBe('druid_fire_cast_1')
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: 'ActiveSkill', unitId: 1, hand: 'right', skill: 36 })
// Local cast command attaches fire_cast_1 overlay and spawns 1 firebolt missile
const castCmd: ClientCommand = {
type: 'SkillAt',
hand: 'right',
at: { x: 5020, y: 5000 },
shift: false,
}
world.noteOutgoingCommand(castCmd)
// Calling noteOutgoingCommand again with the same command object is idempotent
world.noteOutgoingCommand(castCmd)
const selfUnit = world.getUnit(0, 1)!
expect(selfUnit.mode).toBe(10)
expect(selfUnit.castState?.skillId).toBe(36)
expect(selfUnit.castState?.castOverlayToken).toBe('fire_cast_1')
expect(selfUnit.overlays?.some(o => o.token === 'fire_cast_1' && o.loop === false)).toBe(true)
const initialMissiles = Array.from(world.units.values()).filter(u => u.unitType === 'missile')
expect(initialMissiles).toHaveLength(1)
expect(initialMissiles[0]?.missileKey).toBe('firebolt')
// Server echo within 250ms must not duplicate the cast overlay or spawn a second firebolt
world.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 36,
skillId: 36,
level: 1,
target: { x: 5020, y: 5000 },
})
const afterEchoMissiles = Array.from(world.units.values()).filter(u => u.unitType === 'missile')
expect(afterEchoMissiles).toHaveLength(1)
expect(selfUnit.overlays?.filter(o => o.token === 'fire_cast_1')).toHaveLength(1)
// Stepping ticks advances the one-shot overlay and expires castState back to mode 1 (Neutral)
const startMs = selfUnit.castState!.startedAtMs
for (let i = 1; i <= 15; i++) {
world.tick(40, startMs + i * 40)
}
expect(selfUnit.castState).toBeUndefined()
expect(world.self.castState).toBeUndefined()
expect(selfUnit.mode).toBe(1)
expect(selfUnit.overlays?.some(o => o.token === 'fire_cast_1')).toBe(false)
})
it('R2: decodes 0x73 CreateMissile and simulates 1.13c linear, fan, radial ring, spiral, emitter, and skyfall trajectories at 25Hz', async () => {
const { decodeD2gsS2cPacket } = await import('../../src/netproto/index.ts')
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 },
isSelf: true,
})
// 1. 0x73 CreateMissile wire packet decoding & ClientWorld reduction (missileId=62 fireball at (5002,5004) -> (5022,5004))
const raw0x73 = new Uint8Array(32)
const dv = new DataView(raw0x73.buffer)
raw0x73[0] = 0x73
dv.setUint32(1, 0, true) // unk
dv.setUint16(5, 62, true) // missileClassId = 62 (fireball)
dv.setUint16(7, 5002, true) // x
dv.setUint16(9, 5004, true) // y
dv.setUint16(11, 5022, true) // targetX
dv.setUint16(13, 5004, true) // targetY
raw0x73[15] = 5 // level = 5
raw0x73[16] = 0 // ownerType = player
dv.setUint32(17, 2, true) // ownerId = 2 (remote player)
const ev0x73 = decodeD2gsS2cPacket(raw0x73)
expect(ev0x73.type).toBe('SkillCast')
if (ev0x73.type === 'SkillCast') {
expect(ev0x73.caster).toEqual({ unitType: 3, id: 62 })
expect(ev0x73.skill).toBe(62)
expect(ev0x73.at).toEqual({ x: 5002, y: 5004 })
expect(ev0x73.target).toEqual({ x: 5022, y: 5004 })
expect(ev0x73.level).toBe(5)
expect(ev0x73.owner).toEqual({ unitType: 0, id: 2 })
}
world.apply(ev0x73)
const spawnedFireball = Array.from(world.units.values()).find(
u => u.unitType === 'missile' && u.missileKey === 'fireball',
)
expect(spawnedFireball).toBeDefined()
expect(spawnedFireball?.x).toBe(5002)
expect(spawnedFireball?.y).toBe(5004)
world.tick(40, Date.now() + 40)
expect(spawnedFireball!.x).toBeGreaterThan(5002)
// 2. Charged Bolt (skill 38, level 3 -> 5 bolts in a fan)
const cbWorld = new ClientWorld()
cbWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 2, classId: 1, name: 'RemoteSorc', x: 5000, y: 5000 },
})
cbWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 2 },
skill: 38,
skillId: 38,
level: 3,
target: { x: 5015, y: 5000 },
})
const chargedBolts = Array.from(cbWorld.units.values()).filter(
u => u.unitType === 'missile' && u.missileKey === 'chargedbolt',
)
expect(chargedBolts).toHaveLength(5)
// 3. Frost Nova (skill 44 -> 32 radial projectiles)
const fnWorld = new ClientWorld()
fnWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 2, classId: 1, name: 'RemoteSorc', x: 5000, y: 5000 },
})
fnWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 2 },
skill: 44,
skillId: 44,
level: 1,
target: { x: 5005, y: 5005 },
})
const frostNovas = Array.from(fnWorld.units.values()).filter(
u => u.unitType === 'missile' && u.missileKey === 'frostnova',
)
expect(frostNovas).toHaveLength(32)
// 4. Blessed Hammer (skill 112 -> spiral trajectory)
const bhWorld = new ClientWorld()
bhWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 3, classId: 3, name: 'Hammerdin', x: 5000, y: 5000 },
})
bhWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 3 },
skill: 112,
skillId: 112,
level: 1,
target: { x: 5005, y: 5000 },
})
const hammer = Array.from(bhWorld.units.values()).find(
u => u.unitType === 'missile' && u.missileKey === 'blessedhammer',
)!
expect(hammer).toBeDefined()
const r0 = hammer.spiralRadius ?? 0
bhWorld.tick(120, Date.now() + 120)
expect(hammer.spiralRadius).toBeGreaterThan(r0)
expect(Math.hypot(hammer.x - 5000, hammer.y - 5000)).toBeGreaterThan(0.5)
// 5. Frozen Orb (skill 64 -> emits frozenorbbolt sub-missiles during flight)
const foWorld = new ClientWorld()
foWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 2, classId: 1, name: 'OrbSorc', x: 5000, y: 5000 },
})
foWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 2 },
skill: 64,
skillId: 64,
level: 1,
target: { x: 5030, y: 5000 },
})
foWorld.tick(160, Date.now() + 160) // 4 ticks
const subBolts = Array.from(foWorld.units.values()).filter(
u => u.unitType === 'missile' && u.missileKey === 'frozenorbbolt',
)
expect(subBolts.length).toBeGreaterThanOrEqual(4)
})
it('R3: detects wall and hostile monster collisions, respects pierce, and spawns one-shot 1.13c ExplosionMissile visuals', () => {
// 1. Blocked sub-tile collision -> spawns fireexplode and removes firebolt
const wallWorld = new ClientWorld()
wallWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 },
isSelf: true,
})
wallWorld.setSubTileBlocked(5005, 5000, true)
wallWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 36,
skillId: 36, // Fire Bolt (vel=20 -> 2.5 sub-tiles/tick)
level: 1,
target: { x: 5020, y: 5000 },
})
wallWorld.tick(80, Date.now() + 80) // 2 ticks -> reaches x=5005
const afterWallUnits = Array.from(wallWorld.units.values()).filter(u => u.unitType === 'missile')
expect(afterWallUnits.some(u => u.missileKey === 'firebolt')).toBe(false)
const wallExplosion = afterWallUnits.find(u => u.missileKey === 'fireexplode')
expect(wallExplosion).toBeDefined()
expect(wallExplosion?.loopAnim).toBe(false)
// Step explosion past its lifetime -> clamps frame and removes upon completion
for (let i = 1; i <= 15; i++) {
wallWorld.tick(40, Date.now() + 80 + i * 40)
}
expect(
Array.from(wallWorld.units.values()).some(u => u.missileKey === 'fireexplode'),
).toBe(false)
// 2. Hostile monster collision: non-piercing Ice Blast vs piercing Bone Spear
const monWorld = new ClientWorld()
monWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 },
isSelf: true,
})
monWorld.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 99, classId: 1, name: 'Fallen', x: 5003, y: 5000, lifePct: 100 },
})
// Ice Blast (skill 45) hits Fallen at (5003, 5000), spawns freezeexplode + glacialspikeejecta, and is removed
monWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 45,
skillId: 45,
level: 1,
target: { x: 5020, y: 5000 },
})
monWorld.tick(80, Date.now() + 80)
const monMissiles = Array.from(monWorld.units.values()).filter(u => u.unitType === 'missile')
expect(monMissiles.some(u => u.missileKey === 'iceblast')).toBe(false)
expect(monMissiles.some(u => u.missileKey === 'freezeexplode')).toBe(true)
// Piercing Bone Spear (skill 84) passes through Fallen without being destroyed
const pierceWorld = new ClientWorld()
pierceWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 2, classId: 2, name: 'Necro', x: 5000, y: 5000 },
})
pierceWorld.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 100, classId: 1, name: 'Zombie', x: 5003, y: 5000, lifePct: 100 },
})
pierceWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 2 },
skill: 84,
skillId: 84,
level: 1,
target: { x: 5030, y: 5000 },
})
pierceWorld.tick(120, Date.now() + 120)
const boneSpear = Array.from(pierceWorld.units.values()).find(
u => u.unitType === 'missile' && u.missileKey === 'bonespear',
)
expect(boneSpear).toBeDefined()
expect(boneSpear?.hitUnitIds?.has(100)).toBe(true)
expect(boneSpear!.x).toBeGreaterThan(5005)
})
it('Issue #551: initializes alive monsters/NPCs (e.g. Rogue Scout) in MONMODE_NEUTRAL (1) instead of MONMODE_DEATH (0) and transitions mode on UnitMove', () => {
const world = new ClientWorld()
// Assign an alive camp Rogue Scout without an explicit mode field
world.apply({
type: 'UnitAssign',
unit: {
unitType: 1,
id: 42,
classId: 564, // act1hire (Rogue Scout)
x: 5110,
y: 5060,
lifePct: 128,
},
})
const rogue = world.getUnit(1, 42)!
expect(rogue).toBeDefined()
expect(rogue.mode).toBe(1) // MONMODE_NEUTRAL (1), never MONMODE_DEATH (0)
// Patrol walk (0x67 NPCMove) transitions mode to MONMODE_WALK (2)
world.apply({
type: 'UnitMove',
unitType: 1,
id: 42,
mode: 'walk',
from: { x: 5110, y: 5060 },
to: { x: 5116, y: 5064 },
})
expect(rogue.mode).toBe(2)
// Patrol stop (0x6D NPCStop) transitions mode back to MONMODE_NEUTRAL (1)
world.apply({
type: 'UnitMove',
unitType: 1,
id: 42,
mode: 'stop',
to: { x: 5116, y: 5064 },
})
expect(rogue.mode).toBe(1)
// Dead monster spawned with lifePct=0 defaults to MONMODE_DEAD (12)
world.apply({
type: 'UnitAssign',
unit: {
unitType: 1,
id: 99,
classId: 1,
x: 5200,
y: 5200,
lifePct: 0,
},
})
expect(world.getUnit(1, 99)?.mode).toBe(12)
})
it('Issue #551: live .d2cap Rogue Encampment captures never render alive camp NPCs (Rogue Scout, Akara, Charsi, Kashya, Warriv, Gheed) as corpses or mode=0 (DT)', async () => {
const { readFileSync } = await import('node:fs')
const { resolve } = await import('node:path')
const { OnlineSession } = await import('../../src/client/session/online-session.ts')
const { createSettingsStore } = await import('../../src/client/settings/settings-store.ts')
const { buildSceneDrawables } = await import('../../src/client/view/unit-drawables.ts')
for (const fixture of [
'02-d2gs-town-walk-chat.d2cap',
'03-d2gs-items-npc-skills.d2cap',
'05-d2gs-vendor-open.d2cap',
]) {
const text = readFileSync(resolve('tests/fixtures/netproto', fixture), 'utf8')
const session = new OnlineSession({ settingsStore: createSettingsStore() })
session.loadD2CapReplay(text)
session.replayAllD2Cap()
for (let i = 0; i < 30; i++) session.tick(40, 1000 + i * 40)
const renderUnits = session.buildRenderableUnits()
const monsterUnits = renderUnits.filter(u => u.kind === 'monster')
expect(monsterUnits.length, `${fixture} should have camp NPCs`).toBeGreaterThan(0)
for (const npc of monsterUnits) {
expect(npc.isCorpse, `${fixture} npc id=${npc.id} classId=${npc.classId}`).toBe(false)
expect(npc.mode, `${fixture} npc id=${npc.id} classId=${npc.classId} mode`).not.toBe(0)
expect(npc.mode, `${fixture} npc id=${npc.id} classId=${npc.classId} mode`).not.toBe(12)
}
const scene = buildSceneDrawables(renderUnits)
const monsterDrawables = scene.unitDrawables.filter(u => u.unitKind === 'monster')
expect(monsterDrawables.length).toBe(monsterUnits.length)
for (const d of monsterDrawables) {
expect(d.isCorpse, `${fixture} drawable id=${d.unitId} classId=${d.classId}`).toBe(false)
expect(d.mode, `${fixture} drawable id=${d.unitId} classId=${d.classId}`).toBeGreaterThan(0)
expect(d.mode, `${fixture} drawable id=${d.unitId} classId=${d.classId}`).not.toBe(12)
}
}
})
})