diablo2-web/tests/e2e-d2gs-online/tier2-boundary-corner.test.ts

1322 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { describe, expect, it } from 'vitest'
import {
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)
}
})
})
})