1322 lines
55 KiB
TypeScript
1322 lines
55 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
bridgeOnlineItemToUi,
|
||
buildNpcMenuOptions,
|
||
C2S_PACKET_SIZES,
|
||
createOnlineHudSession,
|
||
D2StatId,
|
||
decodeD2gsS2cPacket,
|
||
encodeClientCommandBytes,
|
||
extractStatStrings,
|
||
getNpcHcIdxToDescriptorIdMap,
|
||
getTownNpcDescriptorByHcIdx,
|
||
layoutGroundItemLabels,
|
||
makeItemData,
|
||
readU16LE,
|
||
readU32LE,
|
||
resolveGroundItemMetadata,
|
||
TOWN_NPC_DESCRIPTORS,
|
||
validateC2sPacket,
|
||
} from './helpers.ts'
|
||
|
||
describe('Tier 2 — Boundary, Corner, Negative & Anti-Regression Coverage (F1–F16, 80 Tests)', () => {
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F1 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F1 Boundary — Online Item Tooltip Bridge Edge Cases', () => {
|
||
it('F1.B1: unidentified Magic/Unique items preserve identified: false and base item name', () => {
|
||
const unidUnique = makeItemData({
|
||
id: 2101,
|
||
code: 'cap',
|
||
quality: 7,
|
||
identified: false,
|
||
uniqueItemId: 0,
|
||
stats: [{ statId: D2StatId.ARMORCLASS, param: 0, value: 5 }],
|
||
})
|
||
const ui = bridgeOnlineItemToUi(unidUnique)
|
||
expect(ui.identified).toBe(false)
|
||
expect(ui.quality).toBe('unique')
|
||
expect(ui.baseNameEn ?? ui.baseName).toMatch(/Cap|帽/i)
|
||
})
|
||
|
||
it('F1.B2: ethereal armor and weapons mark ethereal: true and adjust requirements/stats', () => {
|
||
const ethSword = makeItemData({
|
||
id: 2102,
|
||
code: 'bsd',
|
||
quality: 2,
|
||
ethereal: true,
|
||
identified: true,
|
||
stats: [
|
||
{ statId: D2StatId.DURABILITY, param: 0, value: 17 },
|
||
{ statId: D2StatId.MAXDURABILITY, param: 0, value: 17 },
|
||
],
|
||
})
|
||
const ui = bridgeOnlineItemToUi(ethSword)
|
||
expect(ui.ethereal).toBe(true)
|
||
expect(ui.damage).toBeDefined()
|
||
expect(ui.damageRange!.min).toBeGreaterThanOrEqual(7)
|
||
})
|
||
|
||
it('F1.B3: 2H and 1-or-2H weapons (2hs, lax) compute twoHandDamage scaled by Enhanced Damage (statId 17/18) and +Min/+Max (21–24)', () => {
|
||
const twoHandSword = makeItemData({
|
||
id: 2103,
|
||
code: '2hs',
|
||
quality: 4,
|
||
identified: true,
|
||
stats: [
|
||
{ statId: 17, param: 0, value: 100 }, // +100% ED max
|
||
{ statId: 18, param: 0, value: 100 }, // +100% ED min
|
||
{ statId: 23, param: 0, value: 5 }, // +5 two-hand min damage
|
||
{ statId: 24, param: 0, value: 10 }, // +10 two-hand max damage
|
||
],
|
||
})
|
||
const ui = bridgeOnlineItemToUi(twoHandSword)
|
||
expect(ui.twoHandDamage).toBeDefined()
|
||
expect(ui.twoHandDamage!.min).toBeGreaterThan(8)
|
||
expect(ui.twoHandDamage!.max).toBeGreaterThan(17)
|
||
})
|
||
|
||
it('F1.B4: indestructible items (MAXDURABILITY === 0 or stat 152) do not produce invalid durability ratios', () => {
|
||
const indestBlade = makeItemData({
|
||
id: 2104,
|
||
code: '7cr', // Phase Blade (no durability)
|
||
quality: 2,
|
||
identified: true,
|
||
stats: [{ statId: 152, param: 0, value: 1 }],
|
||
})
|
||
const ui = bridgeOnlineItemToUi(indestBlade)
|
||
expect(ui.durability).toBeUndefined()
|
||
})
|
||
|
||
it('F1.B5: personalized items and unknown fallback item codes do not throw or emit raw Stat #N for known stats', () => {
|
||
const personalized = makeItemData({
|
||
id: 2105,
|
||
code: 'cap',
|
||
quality: 6,
|
||
identified: true,
|
||
personalized: true,
|
||
personalizedName: 'Taodao',
|
||
rareName1: 5,
|
||
rareName2: 12,
|
||
stats: [{ statId: D2StatId.ALL_SKILLS, param: 0, value: 2 }],
|
||
})
|
||
const ui = bridgeOnlineItemToUi(personalized)
|
||
const statStr = extractStatStrings(ui.stats).join('\n')
|
||
expect(statStr).not.toMatch(/Stat #\d+/i)
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F2 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F2 Boundary — C2S Binary Packet Edge Cases', () => {
|
||
it('F2.B1: encodes unsigned 32-bit boundary IDs (0, 0x7fffffff, 0xffffffff) without sign corruption', () => {
|
||
for (const id of [0, 0x7fffffff, 0xffffffff]) {
|
||
const pkts = encodeClientCommandBytes({ type: 'BeltRemove', itemId: id })
|
||
expect(pkts.length).toBe(1)
|
||
expect(readU32LE(pkts[0]!, 1)).toBe(id >>> 0)
|
||
}
|
||
})
|
||
|
||
it('F2.B2: WarpTo (0x09) defaults unitType to 5 and honors explicit unitType', () => {
|
||
const defPkts = encodeClientCommandBytes({ type: 'WarpTo', warpId: 123 })
|
||
expect(readU32LE(defPkts[0]!, 1)).toBe(5)
|
||
expect(readU32LE(defPkts[0]!, 5)).toBe(123)
|
||
|
||
const customPkts = encodeClientCommandBytes({ type: 'WarpTo', warpId: 123, unitType: 2 })
|
||
expect(readU32LE(customPkts[0]!, 1)).toBe(2)
|
||
})
|
||
|
||
it('F2.B3: ClickButton (0x4F) splits 32-bit complement into u16LE low (offset 3) and u16LE high (offset 5)', () => {
|
||
const pkts = encodeClientCommandBytes({
|
||
type: 'ClickButton',
|
||
button: 0x08,
|
||
buttonId: 0x08,
|
||
complement: 0x12345678,
|
||
})
|
||
expect(pkts[0]!.byteLength).toBe(7)
|
||
expect(readU16LE(pkts[0]!, 3)).toBe(0x5678)
|
||
expect(readU16LE(pkts[0]!, 5)).toBe(0x1234)
|
||
})
|
||
|
||
it('F2.B4: Hostile (0x5D) defaults button to 1 and encodes state=0 when hostile is false', () => {
|
||
const pkts = encodeClientCommandBytes({
|
||
type: 'Hostile',
|
||
targetPlayerId: 42,
|
||
hostile: false,
|
||
})
|
||
expect(pkts[0]!.byteLength).toBe(7)
|
||
expect(pkts[0]![0]).toBe(0x5d)
|
||
expect(pkts[0]![1]).toBe(1)
|
||
expect(pkts[0]![2]).toBe(0)
|
||
expect(readU32LE(pkts[0]!, 3)).toBe(42)
|
||
})
|
||
|
||
it('F2.B5: validateC2sPacket validates all 18 newly wired C2S opcodes against C2S_PACKET_SIZES', () => {
|
||
const commands = [
|
||
{ type: 'TakeWarp', warpId: 1 },
|
||
{ type: 'ItemSwap', itemId: 1, slot: 1 },
|
||
{ type: 'SwapContainerItem', cursorItemId: 1, targetItemId: 2, x: 0, y: 0 },
|
||
{ type: 'BeltPut', itemId: 1, slot: 0 },
|
||
{ type: 'BeltRemove', itemId: 1 },
|
||
{ type: 'BeltSwap', cursorItemId: 1, beltItemId: 2 },
|
||
{ type: 'IdentifyItem', itemId: 1, scrollId: 2 },
|
||
{ type: 'SocketItem', itemId: 1, targetItemId: 2 },
|
||
{ type: 'ScrollToBook', scrollId: 1, bookId: 2 },
|
||
{ type: 'QuestMessage', npcId: 1, messageId: 0 },
|
||
{ type: 'MercHire', npcId: 1, mercId: 1 },
|
||
{ type: 'NpcEntityAction', action: 3, npcId: 1, complement: 0 },
|
||
{ type: 'Resurrect' },
|
||
{ type: 'ClickButton', button: 4, buttonId: 4, complement: 0 },
|
||
{ type: 'Hostile', targetPlayerId: 2, hostile: true, button: 1 },
|
||
{ type: 'PartyAction', action: 6, targetPlayerId: 2 },
|
||
{ type: 'MercItem', slot: 1 },
|
||
{ type: 'MercResurrect', npcId: 1 },
|
||
]
|
||
for (const cmd of commands) {
|
||
const pkts = encodeClientCommandBytes(cmd)
|
||
expect(pkts.length, `Expected packet for ${cmd.type}`).toBeGreaterThan(0)
|
||
const v = validateC2sPacket(pkts[0]!)
|
||
expect(v.byteLength, `Expected valid C2S packet for ${cmd.type}`).toBe(pkts[0]!.byteLength)
|
||
expect(pkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[pkts[0]![0]!])
|
||
}
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F3 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F3 Boundary — Container, Belt, Identify, Socket & Tome UI Edge Cases', () => {
|
||
it('F3.B1: dropping tsc onto ibk (mismatched scroll/tome) does not emit 0x29 ScrollToBook', () => {
|
||
const { world, hud, hudModel, sentCommands } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 2301,
|
||
code: 'ibk',
|
||
location: 0,
|
||
container: 'inventory',
|
||
gridX: 0,
|
||
gridY: 0,
|
||
invWidth: 1,
|
||
invHeight: 2,
|
||
}),
|
||
})
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 2302,
|
||
code: 'tsc',
|
||
location: 4,
|
||
container: 'cursor',
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
hud.rightPanel = 'inv'
|
||
if (typeof (hud as Record<string, any>).forwardGridClick === 'function') {
|
||
;(hud as Record<string, any>).forwardGridClick(0, 0, 0)
|
||
}
|
||
expect(sentCommands.some((c) => c.type === 'ScrollToBook')).toBe(false)
|
||
})
|
||
|
||
it('F3.B2: dropping a gem onto a full socketed item (usedSockets === totalSockets) does not emit 0x28 SocketItem', () => {
|
||
const { world, hud, hudModel, sentCommands } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 2311,
|
||
code: 'cap',
|
||
location: 0,
|
||
container: 'inventory',
|
||
gridX: 0,
|
||
gridY: 0,
|
||
invWidth: 2,
|
||
invHeight: 2,
|
||
socketed: true,
|
||
totalSockets: 1,
|
||
usedSockets: 1,
|
||
}),
|
||
})
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 2312,
|
||
code: 'gcy',
|
||
location: 4,
|
||
container: 'cursor',
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
hud.rightPanel = 'inv'
|
||
if (typeof (hud as Record<string, any>).forwardGridClick === 'function') {
|
||
;(hud as Record<string, any>).forwardGridClick(0, 0, 0)
|
||
}
|
||
expect(sentCommands.some((c) => c.type === 'SocketItem')).toBe(false)
|
||
})
|
||
|
||
it('F3.B3: useBeltSlot on an empty belt slot is a no-op and emits 0 C2S packets', () => {
|
||
const { hudModel, sentCommands, sentPackets } = createOnlineHudSession()
|
||
hudModel.useBeltSlot(3, false)
|
||
expect(sentCommands.length).toBe(0)
|
||
expect(sentPackets.length).toBe(0)
|
||
})
|
||
|
||
it('F3.B4: useBeltSlot without Shift encodes useOnMerc = 0 in 0x26 BeltUse', () => {
|
||
const { world, hudModel, sentPackets } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 2340,
|
||
code: 'hp1',
|
||
location: 2,
|
||
container: 'belt',
|
||
gridX: 1,
|
||
x: 1,
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
hudModel.useBeltSlot(1, false)
|
||
expect(sentPackets.length).toBe(1)
|
||
expect(sentPackets[0]![0]).toBe(0x26)
|
||
expect(readU32LE(sentPackets[0]!, 5)).toBe(0)
|
||
})
|
||
|
||
it('F3.B5: container and belt operations never mutate hud.inventory ahead of S2C ItemEvent', () => {
|
||
const { world, hud, hudModel } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 2350,
|
||
code: 'hp1',
|
||
location: 2,
|
||
container: 'belt',
|
||
gridX: 0,
|
||
x: 0,
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(hud.inventory.belt[0]?.id).toBe(2350)
|
||
|
||
;(hudModel as Record<string, any>).removeBeltItem(2350)
|
||
// Still in belt until server confirms removal
|
||
expect(hud.inventory.belt[0]?.id).toBe(2350)
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F4 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F4 Boundary — Weapon Set II (bodyLoc 11 & 12) Edge Cases', () => {
|
||
it('F4.B1: swapWeapons defaults to { weapon1: null, weapon2: null } when bodyLoc 11 & 12 are empty', () => {
|
||
const { hud, hudModel } = createOnlineHudSession()
|
||
hudModel.syncFromWorld()
|
||
expect(hud.inventory.swapWeapons).toEqual({ weapon1: null, weapon2: null })
|
||
})
|
||
|
||
it('F4.B2: repeated syncFromWorld calls clear stale swapWeapons when items are moved to inventory', () => {
|
||
const { world, hud, hudModel } = createOnlineHudSession()
|
||
const w1 = makeItemData({
|
||
id: 2401,
|
||
code: 'bsd',
|
||
location: 1,
|
||
container: 'equipped',
|
||
bodyLoc: 11,
|
||
equippedSlot: 11,
|
||
})
|
||
world.apply({ type: 'ItemEvent', item: w1 })
|
||
hudModel.syncFromWorld()
|
||
expect(hud.inventory.swapWeapons.weapon1?.id).toBe(2401)
|
||
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({ ...w1, action: 0x05, location: 4, container: 'cursor' }),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(hud.inventory.swapWeapons.weapon1).toBeNull()
|
||
})
|
||
|
||
it('F4.B3: 2H weapon in bodyLoc 11 preserves full twoHandDamage in swapWeapons.weapon1', () => {
|
||
const { world, hud, hudModel } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 2402,
|
||
code: 'lax', // Large Axe
|
||
location: 1,
|
||
container: 'equipped',
|
||
bodyLoc: 11,
|
||
equippedSlot: 11,
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(hud.inventory.swapWeapons.weapon1?.twoHandDamage).toBeDefined()
|
||
})
|
||
|
||
it('F4.B4: double WeaponSwitch (0x97 twice) toggles activeWeaponSlot 0 -> 1 -> 0 cleanly', () => {
|
||
const { world, hud, hudModel } = createOnlineHudSession()
|
||
expect(hud.inventory.activeWeaponSlot).toBe(0)
|
||
world.apply({ type: 'WeaponSwitch' })
|
||
hudModel.syncFromWorld()
|
||
expect(hud.inventory.activeWeaponSlot).toBe(1)
|
||
world.apply({ type: 'WeaponSwitch' })
|
||
hudModel.syncFromWorld()
|
||
expect(hud.inventory.activeWeaponSlot).toBe(0)
|
||
})
|
||
|
||
it('F4.B5: socketed item in bodyLoc 12 (swapWeapons.weapon2) includes socketedItems from world.containers.sockets', () => {
|
||
const { world, hud, hudModel } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 2405,
|
||
code: 'lrg',
|
||
location: 1,
|
||
container: 'equipped',
|
||
bodyLoc: 12,
|
||
equippedSlot: 12,
|
||
socketed: true,
|
||
totalSockets: 2,
|
||
usedSockets: 1,
|
||
}),
|
||
})
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 2406,
|
||
opcode: 0x9d,
|
||
action: 0x0b,
|
||
code: 'r08', // Ral
|
||
location: 6,
|
||
container: 'socket',
|
||
inSocket: true,
|
||
ownerType: 4,
|
||
ownerId: 2405,
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(hud.inventory.swapWeapons.weapon2?.sockets).toBe(2)
|
||
expect(hud.inventory.swapWeapons.weapon2?.usedSockets).toBe(1)
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F5 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F5 Boundary — Town & Quest NPC Descriptor Edge Cases', () => {
|
||
it('F5.B1: getTownNpcDescriptorByHcIdx returns null for hostile monster hcIdx or invalid numbers', () => {
|
||
expect(getTownNpcDescriptorByHcIdx(1)).toBeNull()
|
||
expect(getTownNpcDescriptorByHcIdx(-1)).toBeNull()
|
||
expect(getTownNpcDescriptorByHcIdx(99999)).toBeNull()
|
||
})
|
||
|
||
it('F5.B2: Tyrael Act 4 (367) has travel_harrogath while Tyrael Act 2 (251) does not expose Act 4 travel', () => {
|
||
const tyraelAct2 = getTownNpcDescriptorByHcIdx(251)
|
||
const tyraelAct4 = getTownNpcDescriptorByHcIdx(367)
|
||
expect(tyraelAct2).not.toBeNull()
|
||
expect(tyraelAct4?.travelActions).toContain('travel_harrogath')
|
||
expect(tyraelAct2?.travelActions ?? []).not.toContain('travel_harrogath')
|
||
})
|
||
|
||
it('F5.B3: non-captain vendors (Akara 148, Gheed 147, Drognan 177, Ormus 255, Malah 513) do not set canHire: true', () => {
|
||
for (const hcIdx of [148, 147, 177, 255, 513]) {
|
||
const desc = getTownNpcDescriptorByHcIdx(hcIdx) as Record<string, any>
|
||
expect(Boolean(desc?.canHire)).toBe(false)
|
||
}
|
||
})
|
||
|
||
it('F5.B4: non-quest-reward vendors (Akara 148, Fara 178, Hratli 253, Jamella 405) do not expose questRewardActions', () => {
|
||
for (const hcIdx of [148, 178, 253, 405]) {
|
||
const desc = getTownNpcDescriptorByHcIdx(hcIdx) as Record<string, any>
|
||
expect(desc?.questRewardActions ?? []).toEqual([])
|
||
}
|
||
})
|
||
|
||
it('F5.B5: every entry in NPC_HCIDX_TO_DESCRIPTOR_ID resolves to a defined key in TOWN_NPC_DESCRIPTORS', () => {
|
||
const map = getNpcHcIdxToDescriptorIdMap()
|
||
const keys = Object.keys(map)
|
||
expect(keys.length).toBeGreaterThanOrEqual(25)
|
||
for (const [hcIdx, descId] of Object.entries(map)) {
|
||
expect(
|
||
TOWN_NPC_DESCRIPTORS[descId],
|
||
`Missing TOWN_NPC_DESCRIPTORS["${descId}"] for hcIdx=${hcIdx}`,
|
||
).toBeDefined()
|
||
}
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F6 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F6 Boundary — NPC Info, SetNPCTalk, and Talk Submenu Edge Cases', () => {
|
||
it('F6.B1: synchronous NpcInit without S2C 0x91 immediately opens npcMenu on syncFromWorld (synthetic compatibility)', () => {
|
||
const { world, worldPanels, hudModel } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 2601, classId: 148, name: 'Akara', x: 5105, y: 5055 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 2601 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(world.npcInteraction.npcId).toBe(2601)
|
||
expect(worldPanels.npcMenu).not.toBeNull()
|
||
expect(worldPanels.npcMenu?.npcUnitId).toBe(2601)
|
||
})
|
||
|
||
it('F6.B2: NpcCancel (0x30) clears npcId, npcInfoRaw, npcTalkRaw, and closes npcMenu', () => {
|
||
const { world, worldPanels, hudModel } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 2602, classId: 148, name: 'Akara', x: 5105, y: 5055 },
|
||
})
|
||
world.apply({ type: 'Npc', kind: 'menu', npcUnitId: 2602, raw: new Uint8Array(26) })
|
||
hudModel.syncFromWorld()
|
||
expect(worldPanels.npcMenu).not.toBeNull()
|
||
|
||
world.noteOutgoingCommand({ type: 'NpcCancel', npcId: 2602 })
|
||
hudModel.syncFromWorld()
|
||
expect(world.npcInteraction.npcId).toBeNull()
|
||
expect(worldPanels.npcMenu).toBeNull()
|
||
})
|
||
|
||
it('F6.B3: 0xAB NPCHeal for an unknown unitId does not throw or create phantom units', () => {
|
||
const { world } = createOnlineHudSession()
|
||
expect(() =>
|
||
world.apply({
|
||
type: 'Npc',
|
||
kind: 'heal',
|
||
unitType: 1,
|
||
unitId: 99999,
|
||
lifePct: 128,
|
||
}),
|
||
).not.toThrow()
|
||
expect(world.getUnit('monster', 99999)).toBeUndefined()
|
||
})
|
||
|
||
it('F6.B4: clicking cancel in NPC menu dispatches NpcCancel (0x30) and closes menu', () => {
|
||
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 2604, classId: 148, name: 'Akara', x: 5105, y: 5055 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 2604 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
worldPanels.clickNpcMenuOption('cancel')
|
||
expect(sentCommands).toContainEqual({ type: 'NpcCancel', npcId: 2604 })
|
||
expect(sentPackets.some((p) => p[0] === 0x30 && p.byteLength === 9)).toBe(true)
|
||
expect(worldPanels.npcMenu).toBeNull()
|
||
})
|
||
|
||
it('F6.B5: rendering NPC menu and speech dialog in both zh and en languages never throws missing TBL key errors', () => {
|
||
const { world, worldPanels, hudModel, renderFrame } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 2605, classId: 515, name: 'Qual-Kehk', x: 5105, y: 5055 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 2605 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
worldPanels.setLanguage('zh')
|
||
expect(() => renderFrame()).not.toThrow()
|
||
worldPanels.setLanguage('en')
|
||
expect(() => renderFrame()).not.toThrow()
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F7 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F7 Boundary — Act Travel Menu Options Edge Cases', () => {
|
||
it('F7.B1: non-travel NPCs do not include travel_east, travel_west, or travel_harrogath in buildNpcMenuOptions', () => {
|
||
for (const hcIdx of [148, 154, 244, 255, 513]) {
|
||
const desc = getTownNpcDescriptorByHcIdx(hcIdx)!
|
||
const opts = buildNpcMenuOptions(desc)
|
||
expect(
|
||
opts.some((o) =>
|
||
['travel_east', 'travel_west', 'travel_harrogath'].includes(String(o.id)),
|
||
),
|
||
).toBe(false)
|
||
}
|
||
})
|
||
|
||
it('F7.B2: clicking travel_east on Warriv closes the NPC menu immediately after emitting 0x38', () => {
|
||
const { world, worldPanels, hudModel } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 2702, classId: 155, name: 'Warriv', x: 5105, y: 5055 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 2702 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
worldPanels.clickNpcMenuOption('travel_east' as any)
|
||
expect(worldPanels.npcMenu).toBeNull()
|
||
})
|
||
|
||
it('F7.B3: Warriv Act 1 (155) only exposes travel_east and Warriv Act 2 (175) only exposes travel_west', () => {
|
||
const opts1 = buildNpcMenuOptions(getTownNpcDescriptorByHcIdx(155)!)
|
||
const opts2 = buildNpcMenuOptions(getTownNpcDescriptorByHcIdx(175)!)
|
||
expect(opts1.some((o) => o.id === ('travel_east' as any))).toBe(true)
|
||
expect(opts1.some((o) => o.id === ('travel_west' as any))).toBe(false)
|
||
expect(opts2.some((o) => o.id === ('travel_west' as any))).toBe(true)
|
||
expect(opts2.some((o) => o.id === ('travel_east' as any))).toBe(false)
|
||
})
|
||
|
||
it('F7.B4: Meshif Act 2 (210) only exposes travel_east and Meshif Act 3 (258) only exposes travel_west', () => {
|
||
const opts2 = buildNpcMenuOptions(getTownNpcDescriptorByHcIdx(210)!)
|
||
const opts3 = buildNpcMenuOptions(getTownNpcDescriptorByHcIdx(258)!)
|
||
expect(opts2.some((o) => o.id === ('travel_east' as any))).toBe(true)
|
||
expect(opts2.some((o) => o.id === ('travel_west' as any))).toBe(false)
|
||
expect(opts3.some((o) => o.id === ('travel_west' as any))).toBe(true)
|
||
expect(opts3.some((o) => o.id === ('travel_east' as any))).toBe(false)
|
||
})
|
||
|
||
it('F7.B5: S2C LoadAct (0x03) after Act travel updates world.act and world.areaId cleanly', () => {
|
||
const { world, hudModel } = createOnlineHudSession({ act: 0, areaId: 1 })
|
||
world.apply({
|
||
type: 'LoadAct',
|
||
act: 1,
|
||
mapSeed: 0xabcdef01,
|
||
areaId: 40,
|
||
objectSeed: 0x1234,
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(world.act).toBe(1)
|
||
expect(world.areaId).toBe(40)
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F8 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F8 Boundary — Mercenary World State Edge Cases', () => {
|
||
it('F8.B1: consecutive 0x4F MercForHireListStart packets clear prior candidates without duplication', () => {
|
||
const { world } = createOnlineHudSession()
|
||
world.apply({ type: 'Merc', kind: 'hireListStart' })
|
||
world.apply({ type: 'Merc', kind: 'forHire', nameId: 1, seed: 100 })
|
||
world.apply({ type: 'Merc', kind: 'hireListStart' })
|
||
world.apply({ type: 'Merc', kind: 'forHire', nameId: 2, seed: 200 })
|
||
|
||
const list =
|
||
(world as Record<string, any>).mercHireList ??
|
||
(world.merc as Record<string, any> | null)?.hireList ??
|
||
[]
|
||
expect(list).toEqual([{ nameId: 2, seed: 200 }])
|
||
})
|
||
|
||
it('F8.B2: 0x81 AssignMerc for another player (ownerId !== world.selfId) does not overwrite local world.merc', () => {
|
||
const { world } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 88, classId: 271 })
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 2, mercId: 99, classId: 338 })
|
||
|
||
expect(world.merc?.mercId).toBe(88)
|
||
expect(world.merc?.ownerId).toBe(1)
|
||
})
|
||
|
||
it('F8.B3: Damage (0x0C) with lifePct === 0 on local mercenary sets lifePct = 0 and isDead = true', () => {
|
||
const { world } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 88, classId: 271 })
|
||
world.apply({
|
||
type: 'Damage',
|
||
unitType: 1,
|
||
unitId: 88,
|
||
lifePct: 0,
|
||
raw: new Uint8Array(9),
|
||
})
|
||
|
||
expect((world.merc as Record<string, any>).lifePct).toBe(0)
|
||
expect((world.merc as Record<string, any>).isDead).toBe(true)
|
||
})
|
||
|
||
it('F8.B4: MercStatChange (0x9E..0xA0) updates mercenary stats map on world.merc', () => {
|
||
const { world } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 88, classId: 271 })
|
||
world.apply({
|
||
type: 'MercStatChange',
|
||
mercId: 88,
|
||
statId: D2StatId.LEVEL,
|
||
value: 25,
|
||
})
|
||
|
||
const merc = world.merc as Record<string, any>
|
||
const level =
|
||
merc.stats instanceof Map
|
||
? merc.stats.get(D2StatId.LEVEL)
|
||
: merc.stats?.[D2StatId.LEVEL] ?? merc.level
|
||
expect(level).toBe(25)
|
||
})
|
||
|
||
it('F8.B5: re-assigning mercenary via 0x81 after 0x9B MercReviveCost clears isDead and reviveCost', () => {
|
||
const { world } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 88, classId: 271 })
|
||
world.apply({ type: 'Merc', kind: 'reviveCost', deadNameId: 14, reviveCost: 5000 })
|
||
expect((world.merc as Record<string, any>).isDead).toBe(true)
|
||
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 89, classId: 271 })
|
||
expect((world.merc as Record<string, any>).isDead).toBe(false)
|
||
expect((world.merc as Record<string, any>).reviveCost ?? 0).toBe(0)
|
||
expect((world.merc as Record<string, any>).lifePct).toBe(128)
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F9 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F9 Boundary — Mercenary Hire & Resurrect UI Edge Cases', () => {
|
||
it('F9.B1: when mercenary is alive (isDead === false), captain menu does not show resurrect option', () => {
|
||
const { world, worldPanels, hudModel } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 88, classId: 271 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 2901, classId: 150, name: 'Kashya', x: 5105, y: 5055 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 2901 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('resurrect' as any))).toBe(false)
|
||
})
|
||
|
||
it('F9.B2: when player has never hired a mercenary (merc === null, reviveCost === 0), captain menu does not show resurrect', () => {
|
||
const { world, worldPanels, hudModel } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 2902, classId: 198, name: 'Greiz', x: 5105, y: 5055 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 2902 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('resurrect' as any))).toBe(false)
|
||
})
|
||
|
||
it('F9.B3: rendering hire panel with an empty hireList does not throw', () => {
|
||
const { world, worldPanels, hudModel, renderFrame } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 2903, classId: 150, name: 'Kashya', x: 5105, y: 5055 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 2903 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
worldPanels.clickNpcMenuOption('hire' as any)
|
||
expect(() => renderFrame()).not.toThrow()
|
||
})
|
||
|
||
it('F9.B4: closing hire panel via closeActiveNpcPanel dispatches NpcCancel (0x30)', () => {
|
||
const { world, worldPanels, hudModel, sentCommands } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 2904, classId: 150, name: 'Kashya', x: 5105, y: 5055 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 2904 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
worldPanels.clickNpcMenuOption('hire' as any)
|
||
hudModel.closeActiveNpcPanel()
|
||
expect(sentCommands).toContainEqual({ type: 'NpcCancel', npcId: 2904 })
|
||
})
|
||
|
||
it('F9.B5: Qual-Kehk (515) in Act 5 supports both hire and resurrect when merc is dead', () => {
|
||
const { world, worldPanels, hudModel } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 2905, classId: 515, name: 'Qual-Kehk', x: 5105, y: 5055 },
|
||
})
|
||
world.apply({ type: 'Merc', kind: 'reviveCost', deadNameId: 30, reviveCost: 15000 })
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 2905 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
const ids = worldPanels.npcMenu?.options.map((o) => String(o.id)) ?? []
|
||
expect(ids).toContain('hire')
|
||
expect(ids).toContain('resurrect')
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F10 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F10 Boundary — Mercenary HUD Portrait & Paperdoll Edge Cases', () => {
|
||
it('F10.B1: when world.merc is null or dead, pressing O does not open merc paperdoll panel', () => {
|
||
const { hud, commandMapper, dom } = createOnlineHudSession({ selfId: 1 })
|
||
dom.dispatchKeyDown('o')
|
||
commandMapper.handleKeyDown({ key: 'o' })
|
||
expect((hud.leftPanel as string) === 'merc').toBe(false)
|
||
})
|
||
|
||
it('F10.B2: renders mercenary portrait across green (>64), yellow (33..64), and red (<=32) lifePct thresholds', () => {
|
||
const { world, hudModel, renderFrame } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 88, classId: 271 })
|
||
|
||
for (const lifePct of [120, 50, 20]) {
|
||
world.apply({
|
||
type: 'Damage',
|
||
unitType: 1,
|
||
unitId: 88,
|
||
lifePct,
|
||
raw: new Uint8Array(9),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
const ctx = renderFrame()
|
||
expect(ctx.drawnRects.length).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('F10.B3: rendering open merc paperdoll panel displays mercenary stats and 4 equipment slots without throwing', () => {
|
||
const { world, hud, hudModel, dom, renderFrame } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 88, classId: 271, nameId: 14 })
|
||
hudModel.syncFromWorld()
|
||
|
||
dom.dispatchKeyDown('o')
|
||
;(hud as Record<string, any>).leftPanel = 'merc'
|
||
const ctx = renderFrame()
|
||
expect(ctx.drawnRects.length).toBeGreaterThan(2)
|
||
})
|
||
|
||
it('F10.B4: pressing Escape while merc panel is open closes merc panel without opening Escape menu', () => {
|
||
const { world, hud, hudModel, commandMapper } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 88, classId: 271 })
|
||
hudModel.syncFromWorld()
|
||
;(hud as Record<string, any>).leftPanel = 'merc'
|
||
|
||
commandMapper.handleKeyDown({ key: 'Escape' })
|
||
expect(hud.leftPanel).toBe('none')
|
||
expect(hud.escapeMenuOpen).toBe(false)
|
||
})
|
||
|
||
it('F10.B5: syncing socketed item in world.containers.merc preserves canonical 1.13c stats on merc slot', () => {
|
||
const { world, hud, hudModel } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 88, classId: 271 })
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 3005,
|
||
opcode: 0x9d,
|
||
action: 0x06,
|
||
code: 'cap',
|
||
location: 1,
|
||
container: 'merc',
|
||
bodyLoc: 1,
|
||
equippedSlot: 1,
|
||
stats: [{ statId: D2StatId.ARMORCLASS, param: 0, value: 12 }],
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
const mercState =
|
||
(hud as Record<string, any>).merc ?? (hud.worldPanels as Record<string, any>).merc
|
||
const helm = mercState?.equipped?.head ?? mercState?.equipped?.helm ?? mercState?.helm
|
||
expect(helm?.defense).toBe(12)
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F11 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F11 Boundary — Dungeon/Stair Warp Clicking Edge Cases', () => {
|
||
it('F11.B1: warp unit with unmapped warpId falls back to Entrance (<warpId>) label cleanly', () => {
|
||
const { world, onlineSession } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: {
|
||
unitType: 5,
|
||
id: 3101,
|
||
classId: 77,
|
||
warpId: 77,
|
||
name: '',
|
||
x: 5150,
|
||
y: 5080,
|
||
} as any,
|
||
})
|
||
|
||
const renderables = onlineSession.buildRenderableUnits()
|
||
const warp = renderables.find((u) => u.id === 3101)
|
||
expect(warp).toBeDefined()
|
||
expect((warp!.name ?? '').length).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('F11.B2: pickUnitAtScreen returns null when clicking far outside warp hitRadiusPx', () => {
|
||
const { world, sceneSource } = createOnlineHudSession({ x: 5100, y: 5050 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: {
|
||
unitType: 5,
|
||
id: 3102,
|
||
classId: 4,
|
||
warpId: 4,
|
||
name: 'To Den of Evil',
|
||
x: 5100,
|
||
y: 5050,
|
||
} as any,
|
||
})
|
||
|
||
expect(sceneSource.pickUnitAtScreen(50, 50, 24)).toBeNull()
|
||
})
|
||
|
||
it('F11.B3: clicking a warp while Shift is held casts SkillAt (stand-still) instead of TakeWarp', () => {
|
||
const { world, commandMapper, sentCommands } = createOnlineHudSession({ x: 5100, y: 5050 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: {
|
||
unitType: 5,
|
||
id: 3103,
|
||
classId: 4,
|
||
warpId: 4,
|
||
name: 'To Den of Evil',
|
||
x: 5104,
|
||
y: 5054,
|
||
} as any,
|
||
})
|
||
const warpUnit = world.getUnit('tile', 3103)!
|
||
|
||
commandMapper.handleKeyDown({ key: 'Shift', shiftKey: true })
|
||
commandMapper.triggerPrimaryWorldAction(5104, 5054, warpUnit, 4000)
|
||
expect(sentCommands).toContainEqual(expect.objectContaining({ type: 'SkillAt', shift: true }))
|
||
expect(sentCommands.some((c) => c.type === 'TakeWarp')).toBe(false)
|
||
})
|
||
|
||
it('F11.B4: UnitRemove (0x0A) for a tile unit removes it from world.units and buildRenderableUnits', () => {
|
||
const { world, onlineSession } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 5, id: 3104, classId: 4, x: 5110, y: 5060 },
|
||
})
|
||
expect(world.getUnit('tile', 3104)).toBeDefined()
|
||
|
||
world.apply({ type: 'UnitRemove', unitType: 5, unitId: 3104 })
|
||
expect(world.getUnit('tile', 3104)).toBeUndefined()
|
||
expect(onlineSession.buildRenderableUnits().some((u) => u.id === 3104)).toBe(false)
|
||
})
|
||
|
||
it('F11.B5: TakeWarp and WarpTo ClientCommands encode identical 9-byte 0x09 packets', () => {
|
||
const p1 = encodeClientCommandBytes({ type: 'TakeWarp', warpId: 888 })[0]!
|
||
const p2 = encodeClientCommandBytes({ type: 'WarpTo', warpId: 888, unitType: 5 })[0]!
|
||
expect(Array.from(p1)).toEqual(Array.from(p2))
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F12 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F12 Boundary — Portal Ownership & Ground Item Labels Edge Cases', () => {
|
||
it('F12.B1: Town Portal object (classId 59) without matching world.portals entry falls back to Town Portal', () => {
|
||
const { world, onlineSession } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 2, id: 3201, classId: 59, name: 'Town Portal', x: 5105, y: 5055 },
|
||
})
|
||
|
||
const portal = onlineSession.buildRenderableUnits().find((u) => u.id === 3201)
|
||
expect(portal).toBeDefined()
|
||
expect(portal!.name).toContain('Town Portal')
|
||
})
|
||
|
||
it('F12.B2: resolveGroundItemMetadata formats 1 Gold and 50000 Gold accurately', () => {
|
||
const g1 = resolveGroundItemMetadata({ id: 1, code: 'gld', goldAmount: 1 })
|
||
const g50k = resolveGroundItemMetadata({ id: 2, code: 'gld', goldAmount: 50000 })
|
||
expect(g1.labelEn).toBe('1 Gold')
|
||
expect(g50k.labelEn).toBe('50000 Gold')
|
||
})
|
||
|
||
it('F12.B3: releasing Alt key clears commandMapper.isAltHeld', () => {
|
||
const { commandMapper } = createOnlineHudSession()
|
||
commandMapper.handleKeyDown({ key: 'Alt', altKey: true })
|
||
expect(commandMapper.isAltHeld).toBe(true)
|
||
commandMapper.handleKeyUp({ key: 'Alt', altKey: false })
|
||
expect(commandMapper.isAltHeld).toBe(false)
|
||
})
|
||
|
||
it('F12.B4: removing a ground item via UnitRemove (0x0A) removes it from world.units', () => {
|
||
const { world } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 3204,
|
||
action: 0x01,
|
||
code: 'gld',
|
||
location: 3,
|
||
container: 'ground',
|
||
worldX: 5102,
|
||
worldY: 5052,
|
||
quantity: 100,
|
||
}),
|
||
})
|
||
expect(world.getUnit('item', 3204)).toBeDefined()
|
||
|
||
world.apply({ type: 'UnitRemove', unitType: 4, unitId: 3204 })
|
||
expect(world.getUnit('item', 3204)).toBeUndefined()
|
||
})
|
||
|
||
it('F12.B5: layoutGroundItemLabels handles empty input and clamps near-edge screen coordinates within viewport', () => {
|
||
expect(layoutGroundItemLabels([])).toEqual([])
|
||
const clamped = layoutGroundItemLabels(
|
||
[{ id: 1, screenX: 2, screenY: 2, label: 'Long Unique Item Name', color: '#c4a060' }],
|
||
800,
|
||
600,
|
||
)
|
||
expect(clamped[0]!.x).toBeGreaterThanOrEqual(0)
|
||
expect(clamped[0]!.y).toBeGreaterThanOrEqual(0)
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F13 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F13 Boundary — SkillCast Animation Edge Cases', () => {
|
||
it('F13.B1: SkillCast for an unknown caster unitId does not throw or create phantom units', () => {
|
||
const { world } = createOnlineHudSession()
|
||
expect(() =>
|
||
world.apply({
|
||
type: 'SkillCast',
|
||
kind: 'atLocation',
|
||
unitType: 1,
|
||
unitId: 88888,
|
||
skillId: 36,
|
||
skillLevel: 1,
|
||
target: { x: 5100, y: 5050 },
|
||
}),
|
||
).not.toThrow()
|
||
expect(world.getUnit('monster', 88888)).toBeUndefined()
|
||
})
|
||
|
||
it('F13.B2: SkillCast onTarget when target unit is missing still updates caster castState cleanly', () => {
|
||
const { world } = createOnlineHudSession({ selfId: 1, x: 5100, y: 5050 })
|
||
expect(() =>
|
||
world.apply({
|
||
type: 'SkillCast',
|
||
kind: 'onTarget',
|
||
unitType: 0,
|
||
unitId: 1,
|
||
skillId: 36,
|
||
skillLevel: 1,
|
||
targetType: 1,
|
||
targetId: 77777,
|
||
}),
|
||
).not.toThrow()
|
||
})
|
||
|
||
it('F13.B3: SkillCast at caster exact coordinates (dx=0, dy=0) produces valid finite direction', () => {
|
||
const { world } = createOnlineHudSession({ selfId: 1, x: 5100, y: 5050 })
|
||
world.apply({
|
||
type: 'SkillCast',
|
||
kind: 'atLocation',
|
||
unitType: 0,
|
||
unitId: 1,
|
||
skillId: 42,
|
||
skillLevel: 1,
|
||
target: { x: 5100, y: 5050 },
|
||
})
|
||
const selfUnit = world.getUnit('player', 1)
|
||
expect(Number.isFinite(selfUnit?.direction ?? 0)).toBe(true)
|
||
})
|
||
|
||
it('F13.B4: outgoing SkillAt/SkillOn via noteOutgoingCommand updates local player castState immediately', () => {
|
||
const { world } = createOnlineHudSession({ selfId: 1, x: 5100, y: 5050 })
|
||
world.noteOutgoingCommand({
|
||
type: 'SkillAt',
|
||
hand: 'right',
|
||
at: { x: 5115, y: 5060 },
|
||
shift: false,
|
||
})
|
||
const selfUnit = world.getUnit('player', 1) as Record<string, any> | undefined
|
||
const castState = selfUnit?.castState ?? (world.self as Record<string, any>).castState
|
||
expect(castState).toBeDefined()
|
||
})
|
||
|
||
it('F13.B5: monster corpse state (mode 12 / lifePct 0) takes precedence over active castState in buildRenderableUnits', () => {
|
||
const { world, onlineSession } = createOnlineHudSession({ selfId: 1, x: 5100, y: 5050 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 3305, classId: 1, name: 'Fallen', x: 5105, y: 5055 },
|
||
})
|
||
world.apply({
|
||
type: 'SkillCast',
|
||
kind: 'monsterAttack',
|
||
monsterId: 3305,
|
||
attackType: 0,
|
||
targetId: 1,
|
||
targetType: 0,
|
||
target: { x: 5100, y: 5050 },
|
||
})
|
||
world.apply({
|
||
type: 'Damage',
|
||
unitType: 1,
|
||
unitId: 3305,
|
||
lifePct: 0,
|
||
raw: new Uint8Array(9),
|
||
})
|
||
|
||
const renderable = onlineSession.buildRenderableUnits().find((u) => u.id === 3305)
|
||
expect(renderable?.isCorpse).toBe(true)
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F14 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F14 Boundary — Player Death Overlay & Resurrect Edge Cases', () => {
|
||
it('F14.B1: before initial stats arrive (life=0, maxLife=0 on new ClientWorld), isDead is false (no false death screen)', () => {
|
||
const { world } = createOnlineHudSession({ initPlayer: false })
|
||
expect(world.self.life).toBe(0)
|
||
expect(world.self.maxLife).toBe(0)
|
||
expect(
|
||
Boolean((world as Record<string, any>).isDead || (world as Record<string, any>).isSelfDead),
|
||
).toBe(false)
|
||
})
|
||
|
||
it('F14.B2: pressing Escape while player is alive and inventory is open closes inventory without emitting 0x41 Resurrect', () => {
|
||
const { hud, commandMapper, sentCommands } = createOnlineHudSession({
|
||
selfId: 1,
|
||
life: 100,
|
||
maxLife: 100,
|
||
})
|
||
hud.rightPanel = 'inv'
|
||
commandMapper.handleKeyDown({ key: 'Escape' })
|
||
expect(hud.rightPanel).toBe('none')
|
||
expect(sentCommands.some((c) => c.type === 'Resurrect')).toBe(false)
|
||
})
|
||
|
||
it('F14.B3: pressing Escape while player is alive with no open panels toggles Escape menu without emitting 0x41 Resurrect', () => {
|
||
const { commandMapper, sentCommands } = createOnlineHudSession({
|
||
selfId: 1,
|
||
life: 100,
|
||
maxLife: 100,
|
||
})
|
||
commandMapper.handleKeyDown({ key: 'Escape' })
|
||
expect(sentCommands.some((c) => c.type === 'Resurrect')).toBe(false)
|
||
})
|
||
|
||
it('F14.B4: StatChange HITPOINTS=0 while MAXHP>0 sets isDead=true, and HITPOINTS>0 restores isDead=false', () => {
|
||
const { world } = createOnlineHudSession({ selfId: 1, life: 100, maxLife: 100 })
|
||
world.apply({
|
||
type: 'StatChange',
|
||
kind: 'base',
|
||
statId: D2StatId.HITPOINTS,
|
||
value: 0,
|
||
})
|
||
expect(
|
||
Boolean((world as Record<string, any>).isDead || (world as Record<string, any>).isSelfDead),
|
||
).toBe(true)
|
||
|
||
world.apply({
|
||
type: 'StatChange',
|
||
kind: 'base',
|
||
statId: D2StatId.HITPOINTS,
|
||
value: 80 << 8,
|
||
})
|
||
expect(
|
||
Boolean((world as Record<string, any>).isDead || (world as Record<string, any>).isSelfDead),
|
||
).toBe(false)
|
||
})
|
||
|
||
it('F14.B5: local player unit mode transitioning to death mode (16) sets isDead=true', () => {
|
||
const { world } = createOnlineHudSession({ selfId: 1, life: 100, maxLife: 100 })
|
||
const selfUnit = world.getUnit('player', 1)!
|
||
selfUnit.mode = 16
|
||
world.apply({
|
||
type: 'PlayerLifeManaChange',
|
||
life: 0,
|
||
mana: 10,
|
||
stamina: 10,
|
||
x: 5100,
|
||
y: 5050,
|
||
vx: 0,
|
||
vy: 0,
|
||
})
|
||
expect(
|
||
Boolean((world as Record<string, any>).isDead || (world as Record<string, any>).isSelfDead),
|
||
).toBe(true)
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F15 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F15 Boundary — Multiplayer Party State & Party Panel Edge Cases', () => {
|
||
it('F15.B1: 0x8B PartyRelation transitions partyState between 0 (unpartied), 2 (invited), and 1 (in party)', () => {
|
||
const { world } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({
|
||
type: 'Party',
|
||
kind: 'roster',
|
||
playerId: 2,
|
||
charClass: 3,
|
||
charLevel: 30,
|
||
partyId: 0xffff,
|
||
charName: 'Uther',
|
||
})
|
||
|
||
for (const state of [2, 1, 0]) {
|
||
world.apply({
|
||
type: 'Party',
|
||
kind: 'relation',
|
||
subKind: 'partyState',
|
||
playerId: 2,
|
||
state,
|
||
})
|
||
const m = world.party.get(2) as Record<string, any>
|
||
expect(m.partyState ?? m.relation).toBe(state)
|
||
}
|
||
})
|
||
|
||
it('F15.B2: 0x8C RelationshipUpdate updates hostility and loot permission flags on world.party entry', () => {
|
||
const { world } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({
|
||
type: 'Party',
|
||
kind: 'roster',
|
||
playerId: 2,
|
||
charClass: 3,
|
||
charLevel: 30,
|
||
partyId: 1,
|
||
charName: 'Uther',
|
||
})
|
||
world.apply({
|
||
type: 'Party',
|
||
kind: 'relation',
|
||
subKind: 'relations',
|
||
subjectId: 1,
|
||
objectId: 2,
|
||
relations: 8, // hostile bit
|
||
})
|
||
|
||
const m = world.party.get(2) as Record<string, any>
|
||
expect(m).toBeDefined()
|
||
})
|
||
|
||
it('F15.B3: setHostile(targetId, false, 1) encodes 0x5D with button=1 and state=0', () => {
|
||
const { hudModel, sentPackets } = createOnlineHudSession({ selfId: 1 })
|
||
;(hudModel as Record<string, any>).setHostile(2, false, 1)
|
||
expect(sentPackets[0]![0]).toBe(0x5d)
|
||
expect(sentPackets[0]![1]).toBe(1)
|
||
expect(sentPackets[0]![2]).toBe(0)
|
||
})
|
||
|
||
it('F15.B4: setHostile(targetId, false, 2) encodes 0x5D with button=2 and state=0', () => {
|
||
const { hudModel, sentPackets } = createOnlineHudSession({ selfId: 1 })
|
||
;(hudModel as Record<string, any>).setHostile(2, false, 2)
|
||
expect(sentPackets[0]![0]).toBe(0x5d)
|
||
expect(sentPackets[0]![1]).toBe(2)
|
||
expect(sentPackets[0]![2]).toBe(0)
|
||
})
|
||
|
||
it('F15.B5: rendering party panel and top-left party portraits ignores local player selfId', () => {
|
||
const { world, hudModel, renderFrame } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({
|
||
type: 'Party',
|
||
kind: 'roster',
|
||
playerId: 1,
|
||
charClass: 0,
|
||
charLevel: 20,
|
||
partyId: 1,
|
||
charName: 'Hero',
|
||
})
|
||
world.apply({
|
||
type: 'Party',
|
||
kind: 'roster',
|
||
playerId: 2,
|
||
charClass: 1,
|
||
charLevel: 22,
|
||
partyId: 1,
|
||
charName: 'Ally',
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(() => renderFrame()).not.toThrow()
|
||
})
|
||
})
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// F16 Boundary & Corner Cases — 5 tests
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
describe('F16 Boundary — Player Trade UI & Quest Reward Panels Edge Cases', () => {
|
||
it('F16.B1: 0x77 ButtonActions action 0x05 disables Trade Accept button and resets accepted flags', () => {
|
||
const { world, hudModel } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({ type: 'Trade', kind: 'button', action: 0x01 })
|
||
world.apply({ type: 'Trade', kind: 'button', action: 0x09 })
|
||
world.apply({ type: 'Trade', kind: 'button', action: 0x05 })
|
||
hudModel.syncFromWorld()
|
||
|
||
const trade = world.trade as Record<string, any>
|
||
expect(trade.acceptDisabled).toBe(true)
|
||
expect(Boolean(trade.partnerAccepted)).toBe(false)
|
||
})
|
||
|
||
it('F16.B2: 0x77 ButtonActions action 0x02/0x06/0x0C closes trade session and clears trade grids', () => {
|
||
const { world, hud, worldPanels, hudModel } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({ type: 'Trade', kind: 'button', action: 0x01 })
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 3602,
|
||
code: 'rin',
|
||
location: 0,
|
||
container: 'trade',
|
||
storePage: 2,
|
||
storagePage: 2,
|
||
gridX: 0,
|
||
gridY: 0,
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(world.trade.active).toBe(true)
|
||
|
||
world.apply({ type: 'Trade', kind: 'button', action: 0x06 })
|
||
hudModel.syncFromWorld()
|
||
expect(world.trade.active).toBe(false)
|
||
expect((hud.leftPanel as string) === 'trade' || (worldPanels.activePanel as string) === 'trade').toBe(
|
||
false,
|
||
)
|
||
})
|
||
|
||
it('F16.B3: 0x77 ButtonActions action 0x10 opens Stash without activating player-to-player trade', () => {
|
||
const { world, hudModel } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({ type: 'Trade', kind: 'button', action: 0x10 })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(world.npcInteraction.stashOpen).toBe(true)
|
||
expect(world.trade.active).toBe(false)
|
||
})
|
||
|
||
it('F16.B4: closing a Quest Reward panel (imbue/add_sockets/personalize) dispatches 0x30 NpcCancel', () => {
|
||
const { world, worldPanels, hudModel, sentCommands } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 3604, classId: 154, name: 'Charsi', x: 5105, y: 5055 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 3604 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
worldPanels.clickNpcMenuOption('imbue' as any)
|
||
hudModel.closeActiveNpcPanel()
|
||
expect(sentCommands).toContainEqual({ type: 'NpcCancel', npcId: 3604 })
|
||
})
|
||
|
||
it('F16.B5: S2C 0x78 TradeAccepted decoder extracts null-terminated partnerName and partnerId', () => {
|
||
const raw0x78 = new Uint8Array(21)
|
||
raw0x78[0] = 0x78
|
||
const nameBytes = new TextEncoder().encode('Arthas')
|
||
raw0x78.set(nameBytes, 1)
|
||
// partnerId = 42 at offset 17
|
||
raw0x78[17] = 42
|
||
|
||
const ev = decodeD2gsS2cPacket(raw0x78, {
|
||
tables: {
|
||
itemStatCost: new Map(),
|
||
armor: new Map(),
|
||
weapons: new Map(),
|
||
misc: new Map(),
|
||
getStatMeta: () => undefined,
|
||
getStatCost: () => undefined,
|
||
getItemBaseMeta: () => undefined,
|
||
getItemMeta: () => undefined,
|
||
},
|
||
encoding: 'gbk',
|
||
rttMs: 0,
|
||
})
|
||
expect(ev.type).toBe('Trade')
|
||
if (ev.type === 'Trade') {
|
||
expect((ev as Record<string, any>).partnerName).toBe('Arthas')
|
||
expect((ev as Record<string, any>).partnerId).toBe(42)
|
||
}
|
||
})
|
||
})
|
||
})
|