1113 lines
41 KiB
TypeScript
1113 lines
41 KiB
TypeScript
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)
|
||
|
||
// 6. Multiple Shot (skill 12, level 5 -> 7 multipleshotarrow missiles in a fan per 1.13c calc1 min(24, 2 + slvl), classId=213)
|
||
const msWorld = new ClientWorld()
|
||
msWorld.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 0, id: 1, classId: 0, name: 'AmaHero', x: 5000, y: 5000 },
|
||
isSelf: true,
|
||
})
|
||
msWorld.apply({
|
||
type: 'SkillCast',
|
||
caster: { unitType: 0, id: 1 },
|
||
skill: 12,
|
||
skillId: 12,
|
||
level: 5,
|
||
target: { x: 5020, y: 5000 },
|
||
})
|
||
const multiShotArrows = Array.from(msWorld.units.values()).filter(
|
||
u => u.unitType === 'missile' && u.missileKey === 'multipleshotarrow',
|
||
)
|
||
expect(multiShotArrows).toHaveLength(7)
|
||
expect(multiShotArrows.every(m => m.classId === 213)).toBe(true)
|
||
|
||
// 7. Challenge 3: spawnMissileByKey('arrow') without opts.classId preserves classId === 0
|
||
const arrowUnit = msWorld.spawnMissileByKey('arrow', 5000, 5000, 5020, 5000)
|
||
expect(arrowUnit.classId).toBe(0)
|
||
expect(arrowUnit.missileKey).toBe('arrow')
|
||
|
||
// 8. Full R2 skill sweep: all 36 R2 skill IDs cast and step at 25Hz without throwing
|
||
const allR2Skills = [
|
||
6, 7, 11, 12, 16, 21, 22, 26, 27, 31, 36, 38, 39, 42, 43, 44, 45, 46,
|
||
47, 48, 49, 51, 53, 54, 55, 56, 59, 64, 66, 67, 84, 92, 93, 101, 112, 121,
|
||
]
|
||
for (const skillId of allR2Skills) {
|
||
const sweepWorld = new ClientWorld()
|
||
sweepWorld.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 0, id: 1, classId: 1, name: 'Caster', x: 5000, y: 5000 },
|
||
isSelf: true,
|
||
})
|
||
expect(() => {
|
||
sweepWorld.apply({
|
||
type: 'SkillCast',
|
||
caster: { unitType: 0, id: 1 },
|
||
skill: skillId,
|
||
skillId,
|
||
level: 3,
|
||
target: { x: 5020, y: 5005 },
|
||
})
|
||
sweepWorld.tick(40, 1000)
|
||
}).not.toThrow()
|
||
}
|
||
})
|
||
|
||
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)
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|