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

776 lines
28 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('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)
}
}
})
})