2246 lines
85 KiB
TypeScript
2246 lines
85 KiB
TypeScript
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)
|
||
})
|
||
})
|
||
})
|