diablo2-web/tests/e2e-d2gs-online/tier1-feature-coverage.test.ts

2246 lines
85 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 {
assertNoUndefinedText,
bridgeOnlineItemToUi,
buildNpcMenuOptions,
buildSceneDrawables,
C2S_PACKET_SIZES,
createOnlineHudSession,
D2StatId,
decodeD2gsS2cPacket,
encodeClientCommandBytes,
extractStatStrings,
findDrawnTextCoords,
getEmbeddedDropTables,
getLeftPanelOrigin,
getNpcHcIdxToDescriptorIdMap,
getTownNpcDescriptorByHcIdx,
layoutGroundItemLabels,
makeItemData,
readU16LE,
readU32LE,
resolveGroundItemMetadata,
} from './helpers.ts'
describe('Tier 1 — Core Feature Coverage (F1–F16, 80 Tests)', () => {
// ═══════════════════════════════════════════════════════════════════════════
// F1: Canonical 1.13c Online Item Tooltip Bridge (R1.1) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F1 — Canonical 1.13c Online Item Tooltip Bridge (R1.1)', () => {
it('F1.1: resolves normal Armor (cap) and Weapon (hax) localized CHI/ENG base names, defense, durability, and 1H damage', () => {
const capItem = makeItemData({
id: 101,
code: 'cap',
quality: 2,
identified: true,
invWidth: 2,
invHeight: 2,
stats: [
{ statId: D2StatId.ARMORCLASS, param: 0, value: 5 },
{ statId: D2StatId.DURABILITY, param: 0, value: 10 },
{ statId: D2StatId.MAXDURABILITY, param: 0, value: 12 },
],
})
const uiCap = bridgeOnlineItemToUi(capItem)
expect(uiCap.nameEn ?? uiCap.baseName).toMatch(/Cap/i)
expect(uiCap.name).toMatch(/帽|Cap/)
expect(uiCap.defense).toBe(5)
expect(uiCap.durability).toEqual({ current: 10, max: 12 })
const haxItem = makeItemData({
id: 102,
code: 'hax',
quality: 2,
identified: true,
invWidth: 1,
invHeight: 3,
stats: [
{ statId: D2StatId.DURABILITY, param: 0, value: 24 },
{ statId: D2StatId.MAXDURABILITY, param: 0, value: 28 },
],
})
const uiHax = bridgeOnlineItemToUi(haxItem)
expect(uiHax.nameEn ?? uiHax.baseName).toMatch(/Hand Axe/i)
expect(uiHax.damage).toBeDefined()
expect(uiHax.damageRange!.min).toBeGreaterThanOrEqual(3)
expect(uiHax.damageRange!.max).toBeGreaterThanOrEqual(6)
})
it('F1.2: formats Magic and Rare item titles and ItemStatCost property lines without raw Stat #N fallbacks', () => {
const magicRing = makeItemData({
id: 103,
code: 'rin',
quality: 4,
identified: true,
magicPrefixIds: [100],
magicSuffixIds: [200],
stats: [
{ statId: D2StatId.STRENGTH, param: 0, value: 15 },
{ statId: D2StatId.MAXHP, param: 0, value: 40 << 8 },
{ statId: D2StatId.FIRERESIST, param: 0, value: 25 },
],
})
const uiMagic = bridgeOnlineItemToUi(magicRing)
expect(uiMagic.quality).toBe('magic')
const statLinesEn = extractStatStrings((uiMagic as Record<string, any>).statsEn ?? uiMagic.stats)
const statLinesZh = extractStatStrings(uiMagic.stats)
const combined = [...statLinesEn, ...statLinesZh].join('\n')
expect(combined).not.toMatch(/Stat #\d+/i)
expect(combined).toMatch(/15/)
expect(combined).toMatch(/25%/)
const rareBoots = makeItemData({
id: 104,
code: 'lbt',
quality: 6,
identified: true,
rareName1: 10,
rareName2: 20,
stats: [
{ statId: D2StatId.ARMORCLASS, param: 0, value: 18 },
{ statId: 96, param: 0, value: 20 }, // item_fastermovevelocity (+20% Faster Run/Walk)
],
})
const uiRare = bridgeOnlineItemToUi(rareBoots)
expect(uiRare.quality).toBe('rare')
const rareStats = extractStatStrings(uiRare.stats).join('\n')
expect(rareStats).not.toMatch(/Stat #\d+/i)
expect(rareStats).toMatch(/20%/)
})
it('F1.3: resolves Set (setItemId) and Unique (uniqueItemId) display names and requirement metadata', () => {
const setShield = makeItemData({
id: 105,
code: 'lrg',
quality: 5,
identified: true,
setItemId: 0, // Civerb's Ward
stats: [{ statId: D2StatId.ARMORCLASS, param: 0, value: 29 }],
})
const uiSet = bridgeOnlineItemToUi(setShield)
expect(uiSet.quality).toBe('set')
expect((uiSet.nameEn ?? uiSet.name).length).toBeGreaterThan(0)
expect(uiSet.reqStr).toBeGreaterThan(0)
const uniqueAxe = makeItemData({
id: 106,
code: 'hax',
quality: 7,
identified: true,
uniqueItemId: 0, // The Gnasher
stats: [
{ statId: 17, param: 0, value: 65 }, // item_maxdamage_percent
{ statId: 18, param: 0, value: 65 }, // item_mindamage_percent
{ statId: 135, param: 0, value: 50 }, // item_openwounds
{ statId: 136, param: 0, value: 20 }, // item_crushingblow
],
})
const uiUnique = bridgeOnlineItemToUi(uniqueAxe)
expect(uiUnique.quality).toBe('unique')
expect(uiUnique.nameEn ?? uiUnique.name).toMatch(/Gnasher|啮齿|手斧/i)
const uniqueStats = extractStatStrings(uiUnique.stats).join('\n')
expect(uniqueStats).not.toMatch(/Stat #\d+/i)
expect(uniqueStats).toMatch(/65%|50%|20%/)
})
it('F1.4: populates sockets, usedSockets, and socketedItems previews from world.containers.sockets', () => {
const { world, hud, hudModel } = createOnlineHudSession()
const socketedHelm = makeItemData({
id: 201,
code: 'cap',
quality: 2,
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
socketed: true,
totalSockets: 2,
usedSockets: 1,
stats: [{ statId: D2StatId.ARMORCLASS, param: 0, value: 5 }],
})
const childTopaz = makeItemData({
id: 202,
opcode: 0x9d,
action: 0x0b,
code: 'gcy',
quality: 2,
location: 6,
container: 'socket',
inSocket: true,
ownerType: 4,
ownerId: 201,
gridX: 0,
})
world.apply({ type: 'ItemEvent', item: socketedHelm })
world.apply({ type: 'ItemEvent', item: childTopaz })
hudModel.syncFromWorld()
const syncedHelm = hud.inventory.inventory.find((i) => i.id === 201)
expect(syncedHelm).toBeDefined()
expect(syncedHelm!.sockets).toBe(2)
expect(syncedHelm!.usedSockets).toBe(1)
expect(syncedHelm!.socketedItems).toBeDefined()
expect(syncedHelm!.socketedItems!.length).toBe(1)
})
it('F1.5: resolves Runeword name and merges runewordStats for socketed runeword items (Stealth Tal+Eth)', () => {
const stealthArmor = makeItemData({
id: 210,
code: 'stu',
quality: 2,
identified: true,
socketed: true,
runeword: true,
totalSockets: 2,
usedSockets: 2,
stats: [{ statId: D2StatId.ARMORCLASS, param: 0, value: 35 }],
runewordStats: [
{ statId: 96, param: 0, value: 25 }, // +25% Faster Run/Walk
{ statId: 105, param: 0, value: 25 }, // +25% Faster Cast Rate
{ statId: 99, param: 0, value: 25 }, // +25% Faster Hit Recovery
],
})
const talRune = makeItemData({ id: 211, code: 'r07', location: 6, container: 'socket', inSocket: true })
const ethRune = makeItemData({ id: 212, code: 'r05', location: 6, container: 'socket', inSocket: true })
const uiStealth = bridgeOnlineItemToUi(stealthArmor, [talRune, ethRune])
const allStats = extractStatStrings(uiStealth.stats).join('\n')
expect(allStats).not.toMatch(/Stat #\d+/i)
expect(allStats).toMatch(/25%/)
expect(uiStealth.nameEn ?? uiStealth.name).toMatch(/Stealth|隐秘|Studded Leather/i)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F2: C2S Packet Encoders (R1.2, R2–R5) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F2 — C2S Container, Belt, NPC, Merc, Warp, Party & Trade Packet Encoders', () => {
it('F2.1: encodes WarpTo (0x09, 9B), ItemSwap (0x1D, 9B), and SwapContainerItem (0x1F, 17B) matching C2S_PACKET_SIZES', () => {
const warpPkts = encodeClientCommandBytes({ type: 'TakeWarp', warpId: 0x11223344 })
expect(warpPkts.length).toBe(1)
expect(warpPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x09])
expect(warpPkts[0]![0]).toBe(0x09)
expect(readU32LE(warpPkts[0]!, 1)).toBe(5)
expect(readU32LE(warpPkts[0]!, 5)).toBe(0x11223344)
const swapBodyPkts = encodeClientCommandBytes({ type: 'ItemSwap', itemId: 0x000001f4, slot: 4 })
expect(swapBodyPkts.length).toBe(1)
expect(swapBodyPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x1d])
expect(swapBodyPkts[0]![0]).toBe(0x1d)
expect(readU32LE(swapBodyPkts[0]!, 1)).toBe(0x000001f4)
expect(readU16LE(swapBodyPkts[0]!, 5)).toBe(4)
const swapContainerPkts = encodeClientCommandBytes({
type: 'SwapContainerItem',
cursorItemId: 501,
targetItemId: 502,
x: 3,
y: 2,
})
expect(swapContainerPkts.length).toBe(1)
expect(swapContainerPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x1f])
expect(swapContainerPkts[0]![0]).toBe(0x1f)
expect(readU32LE(swapContainerPkts[0]!, 1)).toBe(501)
expect(readU32LE(swapContainerPkts[0]!, 5)).toBe(502)
expect(readU32LE(swapContainerPkts[0]!, 9)).toBe(3)
expect(readU32LE(swapContainerPkts[0]!, 13)).toBe(2)
})
it('F2.2: encodes BeltPut (0x23, 9B), BeltRemove (0x24, 5B), and BeltSwap (0x25, 9B) matching C2S_PACKET_SIZES', () => {
const putPkts = encodeClientCommandBytes({ type: 'BeltPut', itemId: 601, slot: 2 })
expect(putPkts.length).toBe(1)
expect(putPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x23])
expect(putPkts[0]![0]).toBe(0x23)
expect(readU32LE(putPkts[0]!, 1)).toBe(601)
expect(readU32LE(putPkts[0]!, 5)).toBe(2)
const remPkts = encodeClientCommandBytes({ type: 'BeltRemove', itemId: 601 })
expect(remPkts.length).toBe(1)
expect(remPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x24])
expect(remPkts[0]![0]).toBe(0x24)
expect(readU32LE(remPkts[0]!, 1)).toBe(601)
const swapPkts = encodeClientCommandBytes({ type: 'BeltSwap', cursorItemId: 602, beltItemId: 601 })
expect(swapPkts.length).toBe(1)
expect(swapPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x25])
expect(swapPkts[0]![0]).toBe(0x25)
expect(readU32LE(swapPkts[0]!, 1)).toBe(602)
expect(readU32LE(swapPkts[0]!, 5)).toBe(601)
})
it('F2.3: encodes IdentifyItem (0x27, 9B), SocketItem (0x28, 9B), and ScrollToBook (0x29, 9B) matching C2S_PACKET_SIZES', () => {
const idPkts = encodeClientCommandBytes({ type: 'IdentifyItem', itemId: 701, scrollId: 702 })
expect(idPkts.length).toBe(1)
expect(idPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x27])
expect(idPkts[0]![0]).toBe(0x27)
expect(readU32LE(idPkts[0]!, 1)).toBe(701)
expect(readU32LE(idPkts[0]!, 5)).toBe(702)
const sockPkts = encodeClientCommandBytes({ type: 'SocketItem', itemId: 703, targetItemId: 704 })
expect(sockPkts.length).toBe(1)
expect(sockPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x28])
expect(sockPkts[0]![0]).toBe(0x28)
expect(readU32LE(sockPkts[0]!, 1)).toBe(703)
expect(readU32LE(sockPkts[0]!, 5)).toBe(704)
const bookPkts = encodeClientCommandBytes({ type: 'ScrollToBook', scrollId: 705, bookId: 706 })
expect(bookPkts.length).toBe(1)
expect(bookPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x29])
expect(bookPkts[0]![0]).toBe(0x29)
expect(readU32LE(bookPkts[0]!, 1)).toBe(705)
expect(readU32LE(bookPkts[0]!, 5)).toBe(706)
})
it('F2.4: encodes QuestMessage (0x31, 9B), MercHire (0x36, 9B), NpcEntityAction (0x38, 13B), MercItem (0x61, 3B), and MercResurrect (0x62, 5B)', () => {
const qmPkts = encodeClientCommandBytes({ type: 'QuestMessage', npcId: 801, messageId: 25 })
expect(qmPkts.length).toBe(1)
expect(qmPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x31])
expect(qmPkts[0]![0]).toBe(0x31)
expect(readU32LE(qmPkts[0]!, 1)).toBe(801)
expect(readU32LE(qmPkts[0]!, 5)).toBe(25)
const hirePkts = encodeClientCommandBytes({ type: 'MercHire', npcId: 802, mercId: 3 })
expect(hirePkts.length).toBe(1)
expect(hirePkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x36])
expect(hirePkts[0]![0]).toBe(0x36)
expect(readU32LE(hirePkts[0]!, 1)).toBe(802)
expect(readU32LE(hirePkts[0]!, 5)).toBe(3)
const entityPkts = encodeClientCommandBytes({
type: 'NpcEntityAction',
action: 3,
npcId: 802,
complement: 40,
})
expect(entityPkts.length).toBe(1)
expect(entityPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x38])
expect(entityPkts[0]![0]).toBe(0x38)
expect(readU32LE(entityPkts[0]!, 1)).toBe(3)
expect(readU32LE(entityPkts[0]!, 5)).toBe(802)
expect(readU32LE(entityPkts[0]!, 9)).toBe(40)
const mercItemPkts = encodeClientCommandBytes({ type: 'MercItem', slot: 3 })
expect(mercItemPkts.length).toBe(1)
expect(mercItemPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x61])
expect(mercItemPkts[0]![0]).toBe(0x61)
expect(readU16LE(mercItemPkts[0]!, 1)).toBe(3)
const revPkts = encodeClientCommandBytes({ type: 'MercResurrect', npcId: 802 })
expect(revPkts.length).toBe(1)
expect(revPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x62])
expect(revPkts[0]![0]).toBe(0x62)
expect(readU32LE(revPkts[0]!, 1)).toBe(802)
})
it('F2.5: encodes Resurrect (0x41, 1B), ClickButton (0x4F, 7B), Hostile/Loot (0x5D, 7B), and PartyAction (0x5E, 6B)', () => {
const resPkts = encodeClientCommandBytes({ type: 'Resurrect' })
expect(resPkts.length).toBe(1)
expect(resPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x41])
expect(resPkts[0]![0]).toBe(0x41)
const btnPkts = encodeClientCommandBytes({
type: 'ClickButton',
button: 0x08,
buttonId: 0x08,
complement: 0x000186a0,
})
expect(btnPkts.length).toBe(1)
expect(btnPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x4f])
expect(btnPkts[0]![0]).toBe(0x4f)
expect(readU16LE(btnPkts[0]!, 1)).toBe(0x08)
const hostilePkts = encodeClientCommandBytes({
type: 'Hostile',
targetPlayerId: 99,
hostile: true,
button: 1,
})
expect(hostilePkts.length).toBe(1)
expect(hostilePkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x5d])
expect(hostilePkts[0]![0]).toBe(0x5d)
expect(hostilePkts[0]![1]).toBe(1)
expect(hostilePkts[0]![2]).toBe(1)
expect(readU32LE(hostilePkts[0]!, 3)).toBe(99)
const lootPkts = encodeClientCommandBytes({
type: 'Hostile',
targetPlayerId: 99,
hostile: true,
button: 2,
})
expect(lootPkts.length).toBe(1)
expect(lootPkts[0]![1]).toBe(2)
const partyPkts = encodeClientCommandBytes({
type: 'PartyAction',
action: 0x06,
targetPlayerId: 99,
})
expect(partyPkts.length).toBe(1)
expect(partyPkts[0]!.byteLength).toBe(C2S_PACKET_SIZES[0x5e])
expect(partyPkts[0]![0]).toBe(0x5e)
expect(partyPkts[0]![1]).toBe(0x06)
expect(readU32LE(partyPkts[0]!, 2)).toBe(99)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F3: Server-Authoritative Container, Belt, Identify, Socket & Tome UI Wiring (R1.2) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F3 — Server-Authoritative Container, Belt, Identify, Socket & Tome UI Wiring', () => {
it('F3.1: swapEquipItem emits 0x1D ItemSwap (9B) without mutating local equipment ahead of the server', () => {
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 301,
code: 'cap',
location: 1,
container: 'equipped',
bodyLoc: 1,
equippedSlot: 1,
equipped: true,
}),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 302,
code: 'skp',
location: 4,
container: 'cursor',
}),
})
hudModel.syncFromWorld()
;(hudModel as Record<string, any>).swapEquipItem(302, 'head')
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'ItemSwap', itemId: 302, slot: 1 }),
)
expect(sentPackets.some((p) => p[0] === 0x1d && p.byteLength === 9)).toBe(true)
// Server authority invariant: head still holds 301 until S2C ItemEvent arrives
expect(hud.inventory.equipped.head?.id).toBe(301)
expect(hud.inventory.cursorItem?.id).toBe(302)
})
it('F3.2: swapContainerItem emits 0x1F SwapContainerItem (17B) without mutating local inventory ahead of the server', () => {
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 310,
code: 'rin',
location: 0,
container: 'inventory',
gridX: 2,
gridY: 1,
}),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 311,
code: 'amu',
location: 4,
container: 'cursor',
}),
})
hudModel.syncFromWorld()
;(hudModel as Record<string, any>).swapContainerItem(311, 310, 2, 1)
expect(sentCommands).toContainEqual(
expect.objectContaining({
type: 'SwapContainerItem',
cursorItemId: 311,
targetItemId: 310,
x: 2,
y: 1,
}),
)
expect(sentPackets.some((p) => p[0] === 0x1f && p.byteLength === 17)).toBe(true)
expect(hud.inventory.inventory.find((i) => i.id === 310)).toBeDefined()
})
it('F3.3: scrollToBook emits 0x29 ScrollToBook (9B) and socketItem emits 0x28 SocketItem (9B)', () => {
const { hudModel, sentCommands, sentPackets, clearRecorded } = createOnlineHudSession()
;(hudModel as Record<string, any>).scrollToBook(320, 321)
expect(sentCommands).toContainEqual({
type: 'ScrollToBook',
scrollId: 320,
bookId: 321,
})
expect(sentPackets[0]![0]).toBe(0x29)
expect(sentPackets[0]!.byteLength).toBe(9)
clearRecorded()
;(hudModel as Record<string, any>).socketItem(322, 323)
expect(sentCommands).toContainEqual({
type: 'SocketItem',
itemId: 322,
targetItemId: 323,
})
expect(sentPackets[0]![0]).toBe(0x28)
expect(sentPackets[0]!.byteLength).toBe(9)
})
it('F3.4: putBeltItem (0x23), removeBeltItem (0x24), swapBeltItem (0x25), and Shift+useBeltSlot (0x26 useOnMerc=1) emit authoritative packets', () => {
const { world, hudModel, sentCommands, sentPackets, clearRecorded } = createOnlineHudSession()
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 330,
code: 'hp4',
location: 2,
container: 'belt',
gridX: 0,
x: 0,
}),
})
hudModel.syncFromWorld()
;(hudModel as Record<string, any>).putBeltItem(331, 1)
expect(sentCommands).toContainEqual({ type: 'BeltPut', itemId: 331, slot: 1 })
expect(sentPackets[0]![0]).toBe(0x23)
clearRecorded()
;(hudModel as Record<string, any>).removeBeltItem(330)
expect(sentCommands).toContainEqual({ type: 'BeltRemove', itemId: 330 })
expect(sentPackets[0]![0]).toBe(0x24)
clearRecorded()
;(hudModel as Record<string, any>).swapBeltItem(332, 330)
expect(sentCommands).toContainEqual({
type: 'BeltSwap',
cursorItemId: 332,
beltItemId: 330,
})
expect(sentPackets[0]![0]).toBe(0x25)
clearRecorded()
hudModel.useBeltSlot(0, true)
expect(sentCommands).toContainEqual({
type: 'BeltUse',
itemId: 330,
useOnMerc: 1,
})
expect(sentPackets[0]![0]).toBe(0x26)
expect(readU32LE(sentPackets[0]!, 5)).toBe(1)
})
it('F3.5: identifyItem emits 0x27 IdentifyItem (9B) with target itemId and scroll/tome id', () => {
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 340,
code: 'ibk',
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
invWidth: 1,
invHeight: 2,
}),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 341,
code: 'rin',
quality: 7,
identified: false,
location: 0,
container: 'inventory',
gridX: 2,
gridY: 0,
}),
})
hudModel.syncFromWorld()
;(hudModel as Record<string, any>).identifyItem(341, 340)
expect(sentCommands).toContainEqual({
type: 'IdentifyItem',
itemId: 341,
scrollId: 340,
})
expect(sentPackets[0]![0]).toBe(0x27)
expect(sentPackets[0]!.byteLength).toBe(9)
expect(readU32LE(sentPackets[0]!, 1)).toBe(341)
expect(readU32LE(sentPackets[0]!, 5)).toBe(340)
expect(hud.inventory.inventory.find((i) => i.id === 341)?.identified).toBe(false)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F4: Weapon Set II (bodyLoc 11 & 12) Sync (R1.3) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F4 — Weapon Set II (bodyLoc 11 & 12) Sync (R1.3)', () => {
it('F4.1: maps bodyLoc 11 (rarm2) to hud.inventory.swapWeapons.weapon1', () => {
const { world, hud, hudModel } = createOnlineHudSession()
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 401,
code: 'bsd',
location: 1,
container: 'equipped',
bodyLoc: 11,
equippedSlot: 11,
equipped: true,
}),
})
hudModel.syncFromWorld()
expect(hud.inventory.swapWeapons.weapon1).not.toBeNull()
expect(hud.inventory.swapWeapons.weapon1?.id).toBe(401)
})
it('F4.2: maps bodyLoc 12 (larm2) to hud.inventory.swapWeapons.weapon2', () => {
const { world, hud, hudModel } = createOnlineHudSession()
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 402,
code: 'buc',
location: 1,
container: 'equipped',
bodyLoc: 12,
equippedSlot: 12,
equipped: true,
}),
})
hudModel.syncFromWorld()
expect(hud.inventory.swapWeapons.weapon2).not.toBeNull()
expect(hud.inventory.swapWeapons.weapon2?.id).toBe(402)
})
it('F4.3: maintains primary weapons (bodyLoc 4 & 5) and secondary swapWeapons (bodyLoc 11 & 12) simultaneously', () => {
const { world, hud, hudModel } = createOnlineHudSession()
world.apply({
type: 'ItemEvent',
item: makeItemData({ id: 411, code: 'hax', location: 1, container: 'equipped', bodyLoc: 4, equippedSlot: 4 }),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({ id: 412, code: 'sml', location: 1, container: 'equipped', bodyLoc: 5, equippedSlot: 5 }),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({ id: 413, code: 'wnd', location: 1, container: 'equipped', bodyLoc: 11, equippedSlot: 11 }),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({ id: 414, code: 'lrg', location: 1, container: 'equipped', bodyLoc: 12, equippedSlot: 12 }),
})
hudModel.syncFromWorld()
expect(hud.inventory.equipped.weapon1?.id).toBe(411)
expect(hud.inventory.equipped.weapon2?.id).toBe(412)
expect(hud.inventory.swapWeapons.weapon1?.id).toBe(413)
expect(hud.inventory.swapWeapons.weapon2?.id).toBe(414)
})
it('F4.4: swapWeapons() emits 0x60 SwapWeapons (1B) and WeaponSwitch event toggles activeWeaponSlot while preserving Set II', () => {
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'ItemEvent',
item: makeItemData({ id: 421, code: 'hax', location: 1, container: 'equipped', bodyLoc: 4, equippedSlot: 4 }),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({ id: 422, code: 'bsd', location: 1, container: 'equipped', bodyLoc: 11, equippedSlot: 11 }),
})
hudModel.syncFromWorld()
hudModel.swapWeapons()
expect(sentCommands).toContainEqual({ type: 'SwapWeapons' })
expect(sentPackets[0]![0]).toBe(0x60)
expect(sentPackets[0]!.byteLength).toBe(1)
world.apply({ type: 'WeaponSwitch' })
hudModel.syncFromWorld()
expect(hud.inventory.activeWeaponSlot).toBe(1)
expect(hud.inventory.swapWeapons.weapon1?.id).toBe(422)
})
it('F4.5: removing an item from bodyLoc 11 clears swapWeapons.weapon1 while preserving swapWeapons.weapon2', () => {
const { world, hud, hudModel } = createOnlineHudSession()
const w1 = makeItemData({ id: 431, code: 'bsd', location: 1, container: 'equipped', bodyLoc: 11, equippedSlot: 11 })
const w2 = makeItemData({ id: 432, code: 'buc', location: 1, container: 'equipped', bodyLoc: 12, equippedSlot: 12 })
world.apply({ type: 'ItemEvent', item: w1 })
world.apply({ type: 'ItemEvent', item: w2 })
hudModel.syncFromWorld()
expect(hud.inventory.swapWeapons.weapon1?.id).toBe(431)
expect(hud.inventory.swapWeapons.weapon2?.id).toBe(432)
world.apply({
type: 'ItemEvent',
item: makeItemData({ ...w1, action: 0x05, location: 4, container: 'cursor' }),
})
hudModel.syncFromWorld()
expect(hud.inventory.swapWeapons.weapon1).toBeNull()
expect(hud.inventory.swapWeapons.weapon2?.id).toBe(432)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F5: Act 1–5 Town & Quest NPC Descriptors (R2.1) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F5 — Act 1–5 Town & Quest NPC Descriptors (R2.1)', () => {
it('F5.1: resolves all 6 Deckard Cain hcIdx variants (146, 244, 245, 246, 520, 265) with canIdentify: true', () => {
const cainHcIndices = [146, 244, 245, 246, 520, 265]
const hcIdxMap = getNpcHcIdxToDescriptorIdMap()
for (const hcIdx of cainHcIndices) {
const desc = getTownNpcDescriptorByHcIdx(hcIdx)
expect(desc, `Expected Cain descriptor for hcIdx=${hcIdx}`).not.toBeNull()
expect(desc!.canIdentify).toBe(true)
expect(hcIdxMap[hcIdx]).toBeDefined()
}
})
it('F5.2: resolves Act travel NPCs Warriv (155, 175), Meshif (210, 258), and Tyrael (251, 367) with travelActions', () => {
const warriv1 = getTownNpcDescriptorByHcIdx(155)
const warriv2 = getTownNpcDescriptorByHcIdx(175)
const meshif1 = getTownNpcDescriptorByHcIdx(210)
const meshif2 = getTownNpcDescriptorByHcIdx(258)
const tyrael1 = getTownNpcDescriptorByHcIdx(251)
const tyrael2 = getTownNpcDescriptorByHcIdx(367)
expect(warriv1?.travelActions).toContain('travel_east')
expect(warriv2?.travelActions).toContain('travel_west')
expect(meshif1?.travelActions).toContain('travel_east')
expect(meshif2?.travelActions).toContain('travel_west')
expect(tyrael1).not.toBeNull()
expect(tyrael2?.travelActions).toContain('travel_harrogath')
})
it('F5.3: resolves Mercenary Captains Kashya (150), Greiz (198), Asheara (252), and Qual-Kehk (515) with canHire: true', () => {
for (const hcIdx of [150, 198, 252, 515]) {
const desc = getTownNpcDescriptorByHcIdx(hcIdx)
expect(desc, `Expected Merc Captain descriptor for hcIdx=${hcIdx}`).not.toBeNull()
expect((desc as Record<string, any>).canHire).toBe(true)
}
})
it('F5.4: resolves Quest Reward NPCs Charsi (154 imbue), Larzuk (511 add_sockets), and Anya (512 personalize)', () => {
const charsi = getTownNpcDescriptorByHcIdx(154) as Record<string, any>
const larzuk = getTownNpcDescriptorByHcIdx(511) as Record<string, any>
const anya = getTownNpcDescriptorByHcIdx(512) as Record<string, any>
expect(charsi?.questRewardActions).toContain('imbue')
expect(larzuk?.questRewardActions).toContain('add_sockets')
expect(anya?.questRewardActions).toContain('personalize')
})
it('F5.5: resolves Kaelan (203) and Hadriel (259) dialogue NPCs with greetingKey and gossipLines', () => {
const kaelan = getTownNpcDescriptorByHcIdx(203)
const hadriel = getTownNpcDescriptorByHcIdx(259)
expect(kaelan).not.toBeNull()
expect(hadriel).not.toBeNull()
expect(kaelan!.greetingKey.length).toBeGreaterThan(0)
expect(hadriel!.greetingKey.length).toBeGreaterThan(0)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F6: NPC Info (0x27), SetNPCTalk (0x91), Talk Submenu & 0x31 QuestMessage (R2.2) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F6 — NPC Info (0x27), SetNPCTalk (0x91), Talk Submenu & 0x31 QuestMessage', () => {
it('F6.1: ClientWorld.apply handles Npc info (0x27 NPCInfo) and stores raw payload while preserving npcId', () => {
const { world } = createOnlineHudSession()
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 50 } })
const rawInfo = new Uint8Array([0x27, 0x01, 50, 0, 0, 0, 1, 2, 3, 4])
world.apply({
type: 'Npc',
kind: 'info',
unitType: 1,
unitId: 50,
raw: rawInfo,
})
expect(world.npcInteraction.npcId).toBe(50)
const interaction = world.npcInteraction as Record<string, any>
expect(interaction.npcInfoRaw ?? (world as Record<string, any>).npcInfo?.get?.(50)).toBeDefined()
})
it('F6.2: ClientWorld.apply handles Npc menu (0x91 SetNPCTalk) and sets active npcId and talk flags', () => {
const { world } = createOnlineHudSession()
const rawTalk = new Uint8Array(26)
rawTalk[0] = 0x91
rawTalk[1] = 55
world.apply({
type: 'Npc',
kind: 'menu',
npcUnitId: 55,
raw: rawTalk,
})
expect(world.npcInteraction.npcId).toBe(55)
const interaction = world.npcInteraction as Record<string, any>
expect(interaction.npcTalkRaw).toBeDefined()
})
it('F6.3: ClientWorld.apply handles Npc heal (0xAB NPCHeal) by updating the unit lifePct', () => {
const { world } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 60, classId: 148, name: 'Akara', x: 5105, y: 5055, lifePct: 64 },
})
world.apply({
type: 'Npc',
kind: 'heal',
unitType: 1,
unitId: 60,
lifePct: 128,
})
expect(world.getUnit('monster', 60)?.lifePct).toBe(128)
})
it('F6.4: clicking talk in NPC menu populates localized speech dialog and/or Talk submenu topics', () => {
const { world, worldPanels, hudModel } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 61, classId: 148, name: 'Akara', x: 5105, y: 5055 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 61 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu).not.toBeNull()
worldPanels.clickNpcMenuOption('talk')
expect(
worldPanels.npcSpeech !== null ||
worldPanels.npcMenu?.options.some((o) => String(o.id).startsWith('talk_')),
).toBe(true)
})
it('F6.5: triggering NPC talk / quest speech dispatches C2S 0x31 QuestMessage (9B)', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 62, classId: 244, name: 'Deckard Cain', x: 5105, y: 5055 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 62 } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption('talk')
if (!sentCommands.some((c) => c.type === 'QuestMessage')) {
const talkTopic = worldPanels.npcMenu?.options.find((o) => String(o.id).startsWith('talk_'))
if (talkTopic) {
worldPanels.clickNpcMenuOption(talkTopic.id)
} else if (typeof (hudModel as Record<string, any>).questMessage === 'function') {
;(hudModel as Record<string, any>).questMessage(62, 0)
}
}
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'QuestMessage', npcId: 62 }),
)
expect(sentPackets.some((p) => p[0] === 0x31 && p.byteLength === 9)).toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F7: Act Travel Menu Options & C2S 0x38 EntityAction (R2.3) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F7 — Act Travel Menu Options & C2S 0x38 EntityAction (R2.3)', () => {
it('F7.1: Warriv Act 1 (hcIdx 155) exposes travel_east and dispatches 0x38 NpcEntityAction', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 71, classId: 155, name: 'Warriv', x: 5105, y: 5055 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 71 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('travel_east' as any))).toBe(true)
worldPanels.clickNpcMenuOption('travel_east' as any)
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'NpcEntityAction', npcId: 71 }),
)
expect(sentPackets.some((p) => p[0] === 0x38 && p.byteLength === 13)).toBe(true)
})
it('F7.2: Warriv Act 2 (hcIdx 175) exposes travel_west and dispatches 0x38 NpcEntityAction', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 72, classId: 175, name: 'Warriv', x: 5105, y: 5055 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 72 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('travel_west' as any))).toBe(true)
worldPanels.clickNpcMenuOption('travel_west' as any)
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'NpcEntityAction', npcId: 72 }),
)
expect(sentPackets.some((p) => p[0] === 0x38 && p.byteLength === 13)).toBe(true)
})
it('F7.3: Meshif Act 2 (hcIdx 210) exposes travel_east and dispatches 0x38 NpcEntityAction', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 73, classId: 210, name: 'Meshif', x: 5105, y: 5055 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 73 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('travel_east' as any))).toBe(true)
worldPanels.clickNpcMenuOption('travel_east' as any)
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'NpcEntityAction', npcId: 73 }),
)
expect(sentPackets.some((p) => p[0] === 0x38 && p.byteLength === 13)).toBe(true)
})
it('F7.4: Meshif Act 3 (hcIdx 258) exposes travel_west and dispatches 0x38 NpcEntityAction', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 74, classId: 258, name: 'Meshif', x: 5105, y: 5055 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 74 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('travel_west' as any))).toBe(true)
worldPanels.clickNpcMenuOption('travel_west' as any)
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'NpcEntityAction', npcId: 74 }),
)
expect(sentPackets.some((p) => p[0] === 0x38 && p.byteLength === 13)).toBe(true)
})
it('F7.5: Tyrael Act 4 (hcIdx 367) exposes travel_harrogath and dispatches 0x38 NpcEntityAction', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 75, classId: 367, name: 'Tyrael', x: 5105, y: 5055 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 75 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('travel_harrogath' as any))).toBe(true)
worldPanels.clickNpcMenuOption('travel_harrogath' as any)
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'NpcEntityAction', npcId: 75 }),
)
expect(sentPackets.some((p) => p[0] === 0x38 && p.byteLength === 13)).toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F8: Mercenary World State (0x4E, 0x4F, 0x9B & lifePct) (R3.1) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F8 — Mercenary World State (0x4E, 0x4F, 0x9B & lifePct) (R3.1)', () => {
it('F8.1: 0x4F MercForHireListStart clears candidate list and 0x4E MercForHire appends { nameId, seed }', () => {
const { world } = createOnlineHudSession()
world.apply({ type: 'Merc', kind: 'forHire', nameId: 10, seed: 0x1111 })
world.apply({ type: 'Merc', kind: 'hireListStart' })
world.apply({ type: 'Merc', kind: 'forHire', nameId: 21, seed: 0x2222 })
world.apply({ type: 'Merc', kind: 'forHire', nameId: 22, seed: 0x3333 })
const hireList =
(world as Record<string, any>).mercHireList ??
(world.merc as Record<string, any> | null)?.hireList ??
[]
expect(hireList).toEqual([
{ nameId: 21, seed: 0x2222 },
{ nameId: 22, seed: 0x3333 },
])
})
it('F8.2: 0x81 AssignMerc sets active world.merc for local player with lifePct 128 and isDead false', () => {
const { world } = createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 88,
classId: 271,
nameId: 14,
})
expect(world.merc).not.toBeNull()
expect(world.merc!.mercId).toBe(88)
expect(world.merc!.classId).toBe(271)
expect((world.merc as Record<string, any>).lifePct).toBe(128)
expect((world.merc as Record<string, any>).isDead).toBe(false)
})
it('F8.3: 0x9B MercReviveCost records reviveCost and deadNameId and marks mercenary dead', () => {
const { world } = createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 88,
classId: 271,
nameId: 14,
})
world.apply({
type: 'Merc',
kind: 'reviveCost',
deadNameId: 14,
reviveCost: 12500,
})
const merc = world.merc as Record<string, any>
expect(merc).not.toBeNull()
expect(merc.reviveCost).toBe(12500)
expect(merc.deadNameId ?? merc.reviveNameId).toBe(14)
expect(merc.isDead).toBe(true)
})
it('F8.4: updates mercenary lifePct on Damage (0x0C), NPCHeal (0xAB), and MercStatChange (0x9E..0xA0)', () => {
const { world } = createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 88, classId: 271, name: 'Rogue', x: 5102, y: 5052, lifePct: 128 },
})
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 88,
classId: 271,
})
world.apply({
type: 'Damage',
unitType: 1,
unitId: 88,
lifePct: 48,
raw: new Uint8Array(9),
})
expect((world.merc as Record<string, any>).lifePct).toBe(48)
world.apply({
type: 'Npc',
kind: 'heal',
unitType: 1,
unitId: 88,
lifePct: 128,
})
expect((world.merc as Record<string, any>).lifePct).toBe(128)
})
it('F8.5: 0x9D ItemEvent on container merc populates world.containers.merc and action 0x08 unequips to cursor', () => {
const { world } = createOnlineHudSession({ selfId: 1 })
const mercHelm = makeItemData({
id: 805,
opcode: 0x9d,
action: 0x06,
code: 'cap',
location: 1,
container: 'merc',
ownerType: 1,
ownerId: 88,
bodyLoc: 1,
equippedSlot: 1,
})
world.apply({ type: 'ItemEvent', item: mercHelm })
expect(world.containers.merc.get(1)?.id).toBe(805)
world.apply({
type: 'ItemEvent',
item: makeItemData({
...mercHelm,
action: 0x08,
location: 4,
container: 'merc',
}),
})
expect(world.containers.merc.get(1)).toBeUndefined()
expect(world.containers.cursor?.id).toBe(805)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F9: Mercenary Hire (0x36) & Resurrect (0x62) UI (R3.2) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F9 — Mercenary Hire (0x36) & Resurrect (0x62) UI (R3.2)', () => {
it('F9.1: buildNpcMenuOptions includes hire for Kashya (150), Greiz (198), Asheara (252), and Qual-Kehk (515)', () => {
for (const hcIdx of [150, 198, 252, 515]) {
const desc = getTownNpcDescriptorByHcIdx(hcIdx)!
const opts = buildNpcMenuOptions(desc)
expect(opts.some((o) => o.id === ('hire' as any))).toBe(true)
}
})
it('F9.2: clicking hire in NPC menu dispatches 0x38 NpcEntityAction (action=3) and opens hire panel', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 91, classId: 150, name: 'Kashya', x: 5105, y: 5055 },
})
world.apply({ type: 'Merc', kind: 'hireListStart' })
world.apply({ type: 'Merc', kind: 'forHire', nameId: 5, seed: 0x1234 })
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 91 } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption('hire' as any)
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'NpcEntityAction', action: 3, npcId: 91 }),
)
expect(sentPackets.some((p) => p[0] === 0x38 && p.byteLength === 13)).toBe(true)
})
it('F9.3: selecting a hire candidate via canvas click on the hire panel dispatches 0x36 MercHire (9B)', () => {
const { world, hud, worldPanels, hudModel, sentCommands, sentPackets, clearRecorded, renderFrame } =
createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 91, classId: 150, name: 'Kashya', x: 5105, y: 5055 },
})
world.apply({ type: 'Merc', kind: 'hireListStart' })
world.apply({ type: 'Merc', kind: 'forHire', nameId: 5, seed: 0x1234 })
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 91 } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption('hire' as any)
const ctx = renderFrame()
assertNoUndefinedText(ctx)
expect(ctx.drawnTexts.some((t) => t.text.includes('Merc #5') || t.text.includes('Gold'))).toBe(
true,
)
clearRecorded()
const { ox, oy } = getLeftPanelOrigin()
hud.handleClick(ox + 100, oy + 78)
expect(sentCommands).toContainEqual({
type: 'MercHire',
npcId: 91,
mercId: 5,
})
expect(sentPackets.some((p) => p[0] === 0x36 && p.byteLength === 9)).toBe(true)
})
it('F9.4: when dead mercenary has reviveCost > 0, mercenary captain menu exposes resurrect option', () => {
const { world, worldPanels, hudModel } = createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 92, classId: 198, name: 'Greiz', x: 5105, y: 5055 },
})
world.apply({
type: 'Merc',
kind: 'reviveCost',
deadNameId: 11,
reviveCost: 9000,
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 92 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('resurrect' as any))).toBe(true)
})
it('F9.5: clicking resurrect or calling resurrectMerc dispatches 0x62 MercResurrect (5B)', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession({
selfId: 1,
})
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 92, classId: 198, name: 'Greiz', x: 5105, y: 5055 },
})
world.apply({
type: 'Merc',
kind: 'reviveCost',
deadNameId: 11,
reviveCost: 9000,
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 92 } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption('resurrect' as any)
expect(sentCommands).toContainEqual({
type: 'MercResurrect',
npcId: 92,
})
expect(sentPackets.some((p) => p[0] === 0x62 && p.byteLength === 5)).toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F10: Mercenary HUD Portrait & Paperdoll (O Hotkey, 0x61 MercItem) (R3.3) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F10 — Mercenary HUD Portrait & Paperdoll (O Hotkey, 0x61 MercItem) (R3.3)', () => {
it('F10.1: renders top-left mercenary portrait and life bar when an active mercenary exists', () => {
const { world, hudModel, renderFrame } = createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 88,
classId: 271,
nameId: 14,
})
hudModel.syncFromWorld()
const ctx = renderFrame()
expect(ctx.drawnRects.some((r) => r.x <= 24 && r.y <= 24 && r.w >= 32)).toBe(true)
})
it('F10.2: pressing O hotkey toggles the merc left-dock paperdoll panel', () => {
const { world, hud, hudModel, dom, commandMapper } = createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 88,
classId: 271,
})
hudModel.syncFromWorld()
dom.dispatchKeyDown('o')
if ((hud.leftPanel as string) !== 'merc' && (hud.worldPanels.activePanel as string) !== 'merc') {
commandMapper.handleKeyDown({ key: 'o' })
}
expect(
(hud.leftPanel as string) === 'merc' || (hud.worldPanels.activePanel as string) === 'merc',
).toBe(true)
})
it('F10.3: syncs world.containers.merc items (helm, armor, weapon1, weapon2) into mercenary paperdoll state', () => {
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: 1001,
opcode: 0x9d,
action: 0x06,
code: 'cap',
location: 1,
container: 'merc',
bodyLoc: 1,
equippedSlot: 1,
}),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 1002,
opcode: 0x9d,
action: 0x06,
code: 'qui',
location: 1,
container: 'merc',
bodyLoc: 3,
equippedSlot: 3,
}),
})
hudModel.syncFromWorld()
const mercState =
(hud as Record<string, any>).merc ?? (hud.worldPanels as Record<string, any>).merc
expect(mercState).toBeDefined()
expect(mercState.equipped?.head?.id ?? mercState.equipped?.helm?.id ?? mercState.helm?.id).toBe(
1001,
)
expect(
mercState.equipped?.torso?.id ?? mercState.equipped?.armor?.id ?? mercState.armor?.id,
).toBe(1002)
})
it('F10.4: clicking mercenary equipment slot (Head -> slot 1) on the merc panel dispatches 0x61 MercItem (3B)', () => {
const { world, hud, hudModel, dom, sentCommands, sentPackets, clearRecorded, renderFrame } =
createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 88,
classId: 271,
nameId: 14,
})
hudModel.syncFromWorld()
dom.dispatchKeyDown('o')
const ctx = renderFrame()
assertNoUndefinedText(ctx)
clearRecorded()
const { ox, oy } = getLeftPanelOrigin()
hud.handleClick(ox + 160, oy + 72)
expect(sentCommands).toContainEqual({
type: 'MercItem',
slot: 1,
})
expect(sentPackets.some((p) => p[0] === 0x61 && p.byteLength === 3)).toBe(true)
expect(readU16LE(sentPackets[0]!, 1)).toBe(1)
})
it('F10.5: clicking mercenary weapon slot (Right Hand -> slot 4) on the merc panel encodes 0x61 with slot=4', () => {
const { world, hud, hudModel, dom, sentCommands, sentPackets, clearRecorded, renderFrame } =
createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 88,
classId: 271,
nameId: 14,
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 1004,
opcode: 0x9d,
action: 0x06,
code: 'hax',
location: 1,
container: 'merc',
ownerType: 1,
ownerId: 88,
bodyLoc: 4,
equippedSlot: 4,
}),
})
hudModel.syncFromWorld()
dom.dispatchKeyDown('o')
const ctx = renderFrame()
assertNoUndefinedText(ctx)
expect(
ctx.drawnTexts.some(
(t) => t.text.includes('hax') || t.text.includes('Hand Axe') || t.text.includes('手斧'),
),
).toBe(true)
clearRecorded()
const { ox, oy } = getLeftPanelOrigin()
hud.handleClick(ox + 64, oy + 154)
expect(sentCommands).toContainEqual({ type: 'MercItem', slot: 4 })
expect(readU16LE(sentPackets[0]!, 1)).toBe(4)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F11: Dungeon/Stair Warp Clicking (0x09 AssignWarp → C2S 0x09 WarpTo) (R4.1) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F11 — Dungeon/Stair Warp Clicking (0x09 AssignWarp → C2S 0x09 WarpTo) (R4.1)', () => {
it('F11.1: S2C 0x09 AssignWarp decoder preserves warpId (byte 6) on UnitAssign and ClientUnit', () => {
const raw0x09 = new Uint8Array(11)
raw0x09[0] = 0x09
raw0x09[1] = 0x05 // unitType = tile
raw0x09[2] = 0x2a // unitId = 42
raw0x09[6] = 0x04 // warpId = 4
raw0x09[7] = 0xf6
raw0x09[8] = 0x13 // x = 5110
raw0x09[9] = 0xc4
raw0x09[10] = 0x13 // y = 5060
const ev = decodeD2gsS2cPacket(raw0x09, {
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('UnitAssign')
if (ev.type === 'UnitAssign') {
expect((ev.unit as Record<string, any>).warpId).toBe(4)
}
const { world } = createOnlineHudSession()
world.apply(ev)
const tileUnit = world.getUnit('tile', 42)
expect(tileUnit).toBeDefined()
expect((tileUnit as Record<string, any>).warpId).toBe(4)
})
it('F11.2: OnlineSession.buildRenderableUnits emits warp/tile units with localized destination label', () => {
const { world, onlineSession } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: {
unitType: 5,
id: 42,
classId: 4,
warpId: 4,
name: '',
x: 5120,
y: 5070,
} as any,
})
const renderables = onlineSession.buildRenderableUnits()
const warpRenderable = renderables.find((u) => u.id === 42)
expect(warpRenderable).toBeDefined()
expect(['warp', 'tile']).toContain(warpRenderable!.kind as string)
expect(warpRenderable!.name).toMatch(/To |通往 |Entrance|Den of Evil/i)
})
it('F11.3: buildSceneDrawables projects warp/tile units into unitDrawables', () => {
const res = buildSceneDrawables(
[
{
id: 42,
kind: 'warp' as any,
classId: 4,
name: 'To Den of Evil',
x: 100,
y: 100,
},
],
{ originX: 0, originY: 0, tick: 1 },
)
const warpDrawable = res.unitDrawables.find((d) => d.id === 42)
expect(warpDrawable).toBeDefined()
expect(['warp', 'tile']).toContain(warpDrawable!.unitKind as string)
})
it('F11.4: ClientWorldSceneSource.pickUnitAtScreen picks a warp/tile unit at its projected screen coordinates', () => {
const { world, sceneSource } = createOnlineHudSession({ x: 5100, y: 5050 })
world.apply({
type: 'UnitAssign',
unit: {
unitType: 5,
id: 42,
classId: 4,
warpId: 4,
name: 'To Den of Evil',
x: 5100,
y: 5050,
} as any,
})
const picked = sceneSource.pickUnitAtScreen(400, 300 - 24, 48)
expect(picked).not.toBeNull()
expect(picked!.id).toBe(42)
})
it('F11.5: clicking a hovered tile/warp unit in CommandMapper emits MoveToUnit (0x04) and TakeWarp/WarpTo (0x09)', () => {
const { world, commandMapper, sentCommands, sentPackets } = createOnlineHudSession({
x: 5100,
y: 5050,
})
world.apply({
type: 'UnitAssign',
unit: {
unitType: 5,
id: 42,
classId: 4,
warpId: 4,
name: 'To Den of Evil',
x: 5104,
y: 5054,
} as any,
})
const warpUnit = world.getUnit('tile', 42)!
commandMapper.triggerPrimaryWorldAction(5104, 5054, warpUnit, 2000)
expect(sentCommands).toContainEqual(
expect.objectContaining({
type: 'MoveToUnit',
target: { unitType: 5, id: 42 },
}),
)
expect(
sentCommands.some((c) => (c.type === 'TakeWarp' || (c as any).type === 'WarpTo') && (c as any).warpId === 42),
).toBe(true)
expect(sentPackets.some((p) => p[0] === 0x09 && p.byteLength === 9)).toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F12: Portal Ownership Hover (0x60/0x82) & Ground Item Labels (0x16) (R4.2) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F12 — Portal Ownership Hover (0x60/0x82) & Ground Item Labels (0x16) (R4.2)', () => {
it('F12.1: enriches Town Portal objects (classId 59/60) with ownerName and destination area from world.portals', () => {
const { world, onlineSession } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: {
unitType: 2,
id: 120,
classId: 59,
name: 'Town Portal',
x: 5105,
y: 5055,
},
})
world.apply({
type: 'Portal',
kind: 'state',
localId: 120,
areaId: 2,
flags: 0,
})
world.apply({
type: 'Portal',
kind: 'ownership',
ownerId: 1,
ownerName: 'Sorceress',
localId: 120,
remoteId: 121,
})
const units = onlineSession.buildRenderableUnits()
const portalUnit = units.find((u) => u.id === 120)
expect(portalUnit).toBeDefined()
expect(portalUnit!.name).toContain("Sorceress's Town Portal")
})
it('F12.2: ground gold piles propagate quantity/goldAmount to ClientUnit and resolveGroundItemMetadata (<N> Gold)', () => {
const { world } = createOnlineHudSession()
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 1201,
action: 0x01,
code: 'gld',
location: 3,
container: 'ground',
worldX: 5106,
worldY: 5056,
x: 5106,
y: 5056,
quantity: 750,
stats: [{ statId: D2StatId.GOLD, param: 0, value: 750 }],
}),
})
const groundGold = world.getUnit('item', 1201) as Record<string, any>
expect(groundGold).toBeDefined()
expect(groundGold.goldAmount).toBe(750)
const meta = resolveGroundItemMetadata({
id: 1201,
code: 'gld',
goldAmount: 750,
})
expect(meta.labelEn).toBe('750 Gold')
expect(meta.labelZh).toContain('750')
})
it('F12.3: ground non-gold items resolve localized base/quality labels and itemCode on ClientUnit', () => {
const { world, onlineSession } = createOnlineHudSession()
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 1202,
action: 0x01,
code: 'cap',
quality: 7,
uniqueItemId: 0,
location: 3,
container: 'ground',
worldX: 5108,
worldY: 5058,
x: 5108,
y: 5058,
}),
})
const unit = world.getUnit('item', 1202) as Record<string, any>
expect(unit).toBeDefined()
expect(unit.itemCode ?? unit.code).toBe('cap')
const renderable = onlineSession.buildRenderableUnits().find((u) => u.id === 1202)
expect(renderable).toBeDefined()
})
it('F12.4: holding Alt activates ground labels and layoutGroundItemLabels avoids overlapping label boxes', () => {
const { commandMapper } = createOnlineHudSession()
commandMapper.handleKeyDown({ key: 'Alt', altKey: true })
expect(commandMapper.isAltHeld).toBe(true)
const boxes = layoutGroundItemLabels([
{ id: 1, screenX: 400, screenY: 300, label: '750 Gold', color: '#d8b864' },
{ id: 2, screenX: 400, screenY: 300, label: 'Cap', color: '#ffffff' },
{ id: 3, screenX: 400, screenY: 300, label: 'Short Sword', color: '#6868e8' },
])
expect(boxes.length).toBe(3)
expect(boxes[0]!.y).not.toBe(boxes[1]!.y)
expect(boxes[1]!.y).not.toBe(boxes[2]!.y)
})
it('F12.5: clicking a ground item in CommandMapper emits ItemPickup (0x16, 13B with toCursor: false)', () => {
const { world, commandMapper, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 1205,
action: 0x01,
code: 'hp1',
location: 3,
container: 'ground',
worldX: 5102,
worldY: 5052,
x: 5102,
y: 5052,
}),
})
const itemUnit = world.getUnit('item', 1205)!
commandMapper.triggerPrimaryWorldAction(5102, 5052, itemUnit, 3000)
expect(sentCommands).toContainEqual({
type: 'ItemPickup',
itemId: 1205,
toCursor: false,
})
expect(sentPackets.some((p) => p[0] === 0x16 && p.byteLength === 13)).toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F13: SkillCast (0x4C/0x4D/0x6C/0x99/0x9A) Animation (R4.3) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F13 — SkillCast (0x4C/0x4D/0x6C/0x99/0x9A) Animation (R4.3)', () => {
it('F13.1: SkillCast onTarget (0x4C) updates caster unit castState and facing direction toward target unit', () => {
const { world } = createOnlineHudSession({ selfId: 1, x: 5100, y: 5050 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 300, classId: 1, name: 'Fallen', x: 5120, y: 5050 },
})
world.apply({
type: 'SkillCast',
kind: 'onTarget',
unitType: 0,
unitId: 1,
skillId: 36, // Fire Bolt
skillLevel: 1,
targetType: 1,
targetId: 300,
})
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()
expect(castState.skillId).toBe(36)
})
it('F13.2: SkillCast atLocation (0x4D) transitions player caster mode to spellcast (10) or attack (7) and updates facing direction', () => {
const { world } = createOnlineHudSession({ selfId: 1, x: 5100, y: 5050 })
world.apply({
type: 'SkillCast',
kind: 'atLocation',
unitType: 0,
unitId: 1,
skillId: 47, // Fire Ball
skillLevel: 5,
target: { x: 5115, y: 5065 },
})
const selfUnit = world.getUnit('player', 1)
expect([7, 8, 10]).toContain(selfUnit?.mode)
})
it('F13.3: SkillCast monsterAttack (0x6C) transitions monster mode to attack (4) or cast (7) and orients monster toward target', () => {
const { world } = createOnlineHudSession({ selfId: 1, x: 5100, y: 5050 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 301, classId: 1, name: 'Fallen', x: 5110, y: 5060 },
})
world.apply({
type: 'SkillCast',
kind: 'monsterAttack',
monsterId: 301,
attackType: 0,
targetId: 1,
targetType: 0,
target: { x: 5100, y: 5050 },
})
const monster = world.getUnit('monster', 301)
expect(monster).toBeDefined()
expect([4, 5, 7]).toContain(monster!.mode)
expect((monster as Record<string, any>).castState).toBeDefined()
})
it('F13.4: SkillCast triggeredOnUnit (0x99) and triggeredAtLocation (0x9A) update caster castState', () => {
const { world } = createOnlineHudSession({ selfId: 1, x: 5100, y: 5050 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 302, classId: 1, name: 'Fallen', x: 5112, y: 5052 },
})
world.apply({
type: 'SkillCast',
kind: 'triggeredOnUnit',
attackerType: 0,
attackerId: 1,
targetType: 1,
targetId: 302,
skillId: 42, // Static Field
skillLevel: 3,
})
const selfUnit = world.getUnit('player', 1) as Record<string, any> | undefined
const castState = selfUnit?.castState ?? (world.self as Record<string, any>).castState
expect(castState?.skillId).toBe(42)
})
it('F13.5: OnlineSession.buildRenderableUnits and buildSceneDrawables reflect active SkillCast animation mode', () => {
const { world, onlineSession } = createOnlineHudSession({ selfId: 1, x: 5100, y: 5050 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 303, classId: 1, name: 'Fallen', x: 5108, y: 5050 },
})
world.apply({
type: 'SkillCast',
kind: 'monsterAttack',
monsterId: 303,
attackType: 0,
targetId: 1,
targetType: 0,
target: { x: 5100, y: 5050 },
})
const renderables = onlineSession.buildRenderableUnits()
const monsterRenderable = renderables.find((u) => u.id === 303)
expect(monsterRenderable).toBeDefined()
expect([4, 5, 7]).toContain(monsterRenderable!.mode)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F14: Player Death Overlay & C2S 0x41 Resurrect (R4.4) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F14 — Player Death Overlay & C2S 0x41 Resurrect (R4.4)', () => {
it('F14.1: sets world.isDead / world.isSelfDead to true when local player life drops to 0 while maxLife > 0', () => {
const { world } = createOnlineHudSession({ selfId: 1, life: 100, maxLife: 100 })
expect((world as Record<string, any>).isDead || (world as Record<string, any>).isSelfDead).toBe(
false,
)
world.apply({
type: 'PlayerLifeManaChange',
life: 0,
mana: 50,
stamina: 80,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
expect(
Boolean((world as Record<string, any>).isDead || (world as Record<string, any>).isSelfDead),
).toBe(true)
})
it('F14.2: HudModel.syncFromWorld propagates isDead === true to HudManager', () => {
const { world, hud, hudModel } = createOnlineHudSession({ selfId: 1, life: 100, maxLife: 100 })
world.apply({
type: 'PlayerLifeManaChange',
life: 0,
mana: 50,
stamina: 80,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
expect(
Boolean(
(hud as Record<string, any>).isDead ||
(hud.worldPanels as Record<string, any>).isDead ||
(hud.worldPanels.snapshot as Record<string, any>).isDead,
),
).toBe(true)
})
it('F14.3: HudManager.render draws classic death overlay banner ("You Have Died" / "你挂了") when isDead is true', () => {
const { world, hudModel, renderFrame } = createOnlineHudSession({
selfId: 1,
life: 100,
maxLife: 100,
})
world.apply({
type: 'PlayerLifeManaChange',
life: 0,
mana: 0,
stamina: 0,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
const ctx = renderFrame()
const allText = ctx.drawnTexts.map((t) => t.text).join('\n')
expect(allText).toMatch(/You Have Died|你挂了|Press ESC/i)
})
it('F14.4: pressing Escape while player is dead dispatches { type: "Resurrect" } (0x41, 1B)', () => {
const { world, hudModel, commandMapper, dom, sentCommands, sentPackets } =
createOnlineHudSession({ selfId: 1, life: 100, maxLife: 100 })
world.apply({
type: 'PlayerLifeManaChange',
life: 0,
mana: 0,
stamina: 0,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
commandMapper.handleKeyDown({ key: 'Escape' })
if (!sentCommands.some((c) => c.type === 'Resurrect')) {
dom.dispatchKeyDown('Escape')
}
expect(sentCommands).toContainEqual({ type: 'Resurrect' })
expect(sentPackets.some((p) => p[0] === 0x41 && p.byteLength === 1)).toBe(true)
})
it('F14.5: clicking the death overlay banner dispatches Resurrect (0x41) and server respawn clears isDead', () => {
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession({
selfId: 1,
life: 100,
maxLife: 100,
})
world.apply({
type: 'PlayerLifeManaChange',
life: 0,
mana: 0,
stamina: 0,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
hud.handleClick(400, 300)
if (!sentCommands.some((c) => c.type === 'Resurrect')) {
;(hudModel as Record<string, any>).resurrect?.()
}
expect(sentCommands).toContainEqual({ type: 'Resurrect' })
expect(sentPackets.some((p) => p[0] === 0x41 && p.byteLength === 1)).toBe(true)
world.apply({
type: 'PlayerLifeManaChange',
life: 100,
mana: 80,
stamina: 100,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
expect(
Boolean((world as Record<string, any>).isDead || (world as Record<string, any>).isSelfDead),
).toBe(false)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F15: Multiplayer Party State & Party Panel (P Hotkey, 0x5D/0x5E) (R5.1) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F15 — Multiplayer Party State & Party Panel (P Hotkey, 0x5D/0x5E) (R5.1)', () => {
it('F15.1: 0x5B roster with partyId 0xffff and non-empty charName retains unpartied player, while 0x5C removes leaving player', () => {
const { world } = createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Party',
kind: 'roster',
playerId: 2,
charClass: 1,
charLevel: 24,
partyId: 0xffff,
charName: 'Jaina',
})
expect(world.party.has(2)).toBe(true)
expect(world.party.get(2)?.charName).toBe('Jaina')
world.apply({
type: 'Party',
kind: 'roster',
playerId: 2,
partyId: 0xffff,
charName: '',
})
expect(world.party.has(2)).toBe(false)
})
it('F15.2: Party relation (0x8B/0x8C), memberState (0x7F), and memberPos (0x90) update world.party entry', () => {
const { world } = createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Party',
kind: 'roster',
playerId: 2,
charClass: 1,
charLevel: 24,
partyId: 1,
charName: 'Jaina',
})
world.apply({
type: 'Party',
kind: 'relation',
subKind: 'partyState',
playerId: 2,
state: 1,
})
world.apply({
type: 'Party',
kind: 'memberState',
isSelf: false,
unitId: 2,
lifePct: 96,
areaId: 3,
})
world.apply({
type: 'Party',
kind: 'memberPos',
playerId: 2,
at: { x: 5200, y: 5150 },
})
const member = world.party.get(2)
expect(member).toBeDefined()
expect(member!.lifePct).toBe(96)
expect(member!.areaId).toBe(3)
expect(member!.at).toEqual({ x: 5200, y: 5150 })
})
it('F15.3: pressing P hotkey toggles the party panel and syncs party roster into WorldPanelsHud', () => {
const { world, hud, worldPanels, hudModel, commandMapper, dom } = createOnlineHudSession({
selfId: 1,
})
world.apply({
type: 'Party',
kind: 'roster',
playerId: 2,
charClass: 1,
charLevel: 24,
partyId: 0xffff,
charName: 'Jaina',
})
hudModel.syncFromWorld()
commandMapper.handleKeyDown({ key: 'p' })
if ((hud.leftPanel as string) !== 'party' && (worldPanels.activePanel as string) !== 'party') {
dom.dispatchKeyDown('p')
}
expect(
(hud.leftPanel as string) === 'party' || (worldPanels.activePanel as string) === 'party',
).toBe(true)
})
it('F15.4: clicking Party Action button in party panel (Invite 0x06, Cancel 0x07, Accept 0x08, Leave 0x09) emits 0x5E PartyAction (6B)', () => {
const { world, hud, hudModel, dom, sentCommands, sentPackets, clearRecorded, renderFrame } =
createOnlineHudSession({
selfId: 1,
})
world.apply({
type: 'Party',
kind: 'roster',
playerId: 2,
charClass: 1,
charLevel: 24,
partyId: 0xffff,
charName: 'Sorceress',
})
hudModel.syncFromWorld()
dom.dispatchKeyDown('p')
const cases: ReadonlyArray<{ rel: number; expectedAction: number }> = [
{ rel: 0, expectedAction: 0x06 }, // none -> Invite
{ rel: 2, expectedAction: 0x08 }, // invitedByThem -> Accept
{ rel: 1, expectedAction: 0x07 }, // invitedByMe -> Cancel
{ rel: 3, expectedAction: 0x09 }, // inParty -> Leave
]
const { ox, oy } = getLeftPanelOrigin()
for (const { rel, expectedAction } of cases) {
world.apply({
type: 'Party',
kind: 'relation',
subKind: 'partyState',
playerId: 2,
state: rel,
})
hudModel.syncFromWorld()
const ctx = renderFrame()
assertNoUndefinedText(ctx)
clearRecorded()
// Row 0 Party Action button box: ox + 156..208, oy + 56..88
hud.handleClick(ox + 180, oy + 72)
expect(sentCommands).toContainEqual({
type: 'PartyAction',
action: expectedAction,
targetPlayerId: 2,
})
expect(sentPackets[0]![0]).toBe(0x5e)
expect(sentPackets[0]!.byteLength).toBe(6)
expect(sentPackets[0]![1]).toBe(expectedAction)
expect(readU32LE(sentPackets[0]!, 2)).toBe(2)
}
})
it('F15.5: clicking Hostile and Loot buttons in party panel emits 0x5D Hostile (7B) with button=1 and button=2', () => {
const { world, hud, hudModel, dom, sentCommands, sentPackets, clearRecorded, renderFrame } =
createOnlineHudSession({
selfId: 1,
})
world.apply({
type: 'Party',
kind: 'roster',
playerId: 2,
charClass: 1,
charLevel: 24,
partyId: 0xffff,
charName: 'Sorceress',
})
hudModel.syncFromWorld()
dom.dispatchKeyDown('p')
const ctx = renderFrame()
assertNoUndefinedText(ctx)
const { ox, oy } = getLeftPanelOrigin()
clearRecorded()
// Row 0 Hostile button box: ox + 212..256, oy + 56..88
hud.handleClick(ox + 234, oy + 72)
expect(sentCommands).toContainEqual(
expect.objectContaining({
type: 'Hostile',
targetPlayerId: 2,
hostile: true,
}),
)
expect(sentPackets[0]![0]).toBe(0x5d)
expect(sentPackets[0]!.byteLength).toBe(7)
expect(sentPackets[0]![1]).toBe(1)
clearRecorded()
// Row 0 Loot button box: ox + 258..300, oy + 56..88
hud.handleClick(ox + 279, oy + 72)
expect(sentPackets[0]![0]).toBe(0x5d)
expect(sentPackets[0]![1]).toBe(2)
expect(sentPackets[0]![2]).toBe(1)
expect(readU32LE(sentPackets[0]!, 3)).toBe(2)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// F16: Player-to-Player Trade UI (0x77–0x79 & 0x4F) & Quest Reward Panels (R5.2, R5.3) — 5 tests
// ═══════════════════════════════════════════════════════════════════════════
describe('F16 — Player-to-Player Trade UI (0x77–0x79 & C2S 0x4F) & Quest Reward Panels (R5.2, R5.3)', () => {
it('F16.1: S2C 0x77 ButtonActions (0x01), 0x78 TradeAccepted (partnerName), and 0x79 GoldInTrade update world.trade and open trade panel', () => {
const { world, hud, worldPanels, hudModel } = createOnlineHudSession({ selfId: 1 })
world.apply({ type: 'Trade', kind: 'button', action: 0x01 })
world.apply({
type: 'Trade',
kind: 'accepted',
partnerName: 'Paladin',
partnerId: 2,
} as any)
world.apply({
type: 'Trade',
kind: 'gold',
isSelf: false,
amount: 25000,
})
world.apply({
type: 'Trade',
kind: 'gold',
isSelf: true,
amount: 10000,
})
hudModel.syncFromWorld()
expect(world.trade.active).toBe(true)
expect((world.trade as Record<string, any>).partnerName).toBe('Paladin')
expect(world.trade.partnerGold).toBe(25000)
expect(world.trade.selfGold).toBe(10000)
expect(
(hud.leftPanel as string) === 'trade' || (worldPanels.activePanel as string) === 'trade',
).toBe(true)
})
it('F16.2: items in container trade (tradeSelf) and tradePartner sync into the 10x4 self and partner 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: 1601,
code: 'rin',
location: 0,
container: 'trade',
storePage: 2,
storagePage: 2,
gridX: 0,
gridY: 0,
}),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 1602,
code: 'amu',
location: 0,
container: 'tradePartner',
storePage: 2,
storagePage: 2,
gridX: 1,
gridY: 0,
}),
})
hudModel.syncFromWorld()
const tradeState =
(worldPanels as Record<string, any>).trade ?? (hud as Record<string, any>).trade
expect(world.containers.tradeGrid.some((i) => i.id === 1601)).toBe(true)
expect(world.containers.partnerTradeGrid.some((i) => i.id === 1602)).toBe(true)
expect(tradeState).toBeDefined()
})
it('F16.3: clicking Trade Accept (0x04/0x07), Gold (0x08), and Cancel (0x02) buttons in trade panel emits C2S 0x4F ClickButton (7B)', () => {
const { world, hud, hudModel, sentPackets, clearRecorded, renderFrame } = createOnlineHudSession({
selfId: 1,
})
world.apply({ type: 'Trade', kind: 'button', action: 0x01 })
world.apply({
type: 'Trade',
kind: 'accepted',
partnerName: 'Paladin',
partnerId: 2,
} as any)
world.apply({
type: 'Trade',
kind: 'gold',
isSelf: true,
amount: 10000,
})
hudModel.syncFromWorld()
const ctx = renderFrame()
assertNoUndefinedText(ctx)
const { ox, oy } = getLeftPanelOrigin()
// Accept button box: ox + 184..256, oy + 340..366
clearRecorded()
hud.handleClick(ox + 220, oy + 353)
expect(sentPackets[0]![0]).toBe(0x4f)
expect(sentPackets[0]!.byteLength).toBe(7)
expect(readU16LE(sentPackets[0]!, 1)).toBe(0x04)
// Gold button box: ox + 16..176, oy + 340..366
clearRecorded()
hud.handleClick(ox + 96, oy + 353)
expect(sentPackets[0]![0]).toBe(0x4f)
expect(readU16LE(sentPackets[0]!, 1)).toBe(0x08)
expect(
((readU16LE(sentPackets[0]!, 3) << 16) | readU16LE(sentPackets[0]!, 5)) >>> 0,
).toBe(10000)
// Cancel button box: ox + 260..312, oy + 380..408
clearRecorded()
hud.handleClick(ox + 286, oy + 394)
expect(sentPackets[0]![0]).toBe(0x4f)
expect(readU16LE(sentPackets[0]!, 1)).toBe(0x02)
})
it('F16.4: clicking imbue (Charsi 154), add_sockets (Larzuk 511), or personalize (Anya 512) opens Quest Reward panel', () => {
const rewardNpcs = [
{ hcIdx: 154, name: 'Charsi', action: 'imbue' },
{ hcIdx: 511, name: 'Larzuk', action: 'add_sockets' },
{ hcIdx: 512, name: 'Anya', action: 'personalize' },
] as const
for (const npc of rewardNpcs) {
const { world, worldPanels, hudModel } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 160 + npc.hcIdx, classId: npc.hcIdx, name: npc.name, x: 5105, y: 5055 },
})
world.noteOutgoingCommand({
type: 'NpcInit',
target: { unitType: 1, id: 160 + npc.hcIdx },
})
hudModel.syncFromWorld()
expect(worldPanels.npcMenu?.options.some((o) => o.id === (npc.action as any))).toBe(true)
worldPanels.clickNpcMenuOption(npc.action as any)
expect(
['imbue', 'add_sockets', 'personalize', 'reward'].includes(
worldPanels.activePanel as string,
) ||
['imbue', 'add_sockets', 'personalize'].includes(world.npcInteraction.window as string),
).toBe(true)
}
})
it('F16.5: placing item into Quest Reward slot and clicking Confirm button dispatches C2S 0x38 NpcEntityAction (action=0)', () => {
const { world, hud, worldPanels, hudModel, sentCommands, sentPackets, clearRecorded, renderFrame } =
createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 671, classId: 511, name: 'Larzuk', x: 5105, y: 5055 },
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 1610,
code: 'cap',
location: 4,
container: 'cursor',
}),
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 671 } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption('add_sockets' as any)
const ctx = renderFrame()
assertNoUndefinedText(ctx)
const { ox, oy } = getLeftPanelOrigin()
// Place cursor item into 1-item slot box (ox + 116..204, oy + 120..236)
hud.handleClick(ox + 160, oy + 178)
const ctx2 = renderFrame()
assertNoUndefinedText(ctx2)
clearRecorded()
// Click Confirm button box (ox + 112..208, oy + 280..312)
hud.handleClick(ox + 160, oy + 296)
expect(sentCommands).toContainEqual(
expect.objectContaining({
type: 'NpcEntityAction',
action: 0,
npcId: 671,
complement: 1610,
}),
)
const pkt = sentPackets.find((p) => p[0] === 0x38 && p.byteLength === 13)
expect(pkt).toBeDefined()
expect(readU32LE(pkt!, 1)).toBe(0)
expect(readU32LE(pkt!, 5)).toBe(671)
expect(readU32LE(pkt!, 9)).toBe(1610)
})
})
})