694 lines
23 KiB
TypeScript
694 lines
23 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
createOnlineHudSession,
|
||
D2StatId,
|
||
extractStatStrings,
|
||
layoutGroundItemLabels,
|
||
makeItemData,
|
||
readU16LE,
|
||
readU32LE,
|
||
resolveGroundItemMetadata,
|
||
} from './helpers.ts'
|
||
|
||
describe('Tier 3 — Pairwise Cross-Feature Integration Coverage (16 Tests)', () => {
|
||
it('CF1 (F1 × F3): identifying an unidentified Unique item via 0x27 IdentifyItem + S2C 0x9C reveals canonical 1.13c stats', () => {
|
||
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
|
||
const tome = makeItemData({
|
||
id: 4001,
|
||
code: 'ibk',
|
||
location: 0,
|
||
container: 'inventory',
|
||
gridX: 0,
|
||
gridY: 0,
|
||
invWidth: 1,
|
||
invHeight: 2,
|
||
})
|
||
const unidAxe = makeItemData({
|
||
id: 4002,
|
||
code: 'hax',
|
||
quality: 7,
|
||
uniqueItemId: 0, // The Gnasher
|
||
identified: false,
|
||
location: 0,
|
||
container: 'inventory',
|
||
gridX: 2,
|
||
gridY: 0,
|
||
invWidth: 1,
|
||
invHeight: 3,
|
||
})
|
||
world.apply({ type: 'ItemEvent', item: tome })
|
||
world.apply({ type: 'ItemEvent', item: unidAxe })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(hud.inventory.inventory.find((i) => i.id === 4002)?.identified).toBe(false)
|
||
|
||
;(hudModel as Record<string, any>).identifyItem(4002, 4001)
|
||
expect(sentCommands).toContainEqual({ type: 'IdentifyItem', itemId: 4002, scrollId: 4001 })
|
||
expect(sentPackets[0]![0]).toBe(0x27)
|
||
|
||
// Server responds with identified item and canonical 1.13c stats
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
...unidAxe,
|
||
action: 0x02,
|
||
identified: true,
|
||
stats: [
|
||
{ statId: 17, param: 0, value: 65 },
|
||
{ statId: 18, param: 0, value: 65 },
|
||
{ statId: 135, param: 0, value: 50 },
|
||
],
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
const identifiedAxe = hud.inventory.inventory.find((i) => i.id === 4002)!
|
||
expect(identifiedAxe.identified).toBe(true)
|
||
const statsText = extractStatStrings(identifiedAxe.stats).join('\n')
|
||
expect(statsText).not.toMatch(/Stat #\d+/i)
|
||
expect(statsText).toMatch(/65%|50%/)
|
||
})
|
||
|
||
it('CF2 (F1 × F3): socketing a Rune into a socketed helm via 0x28 SocketItem + S2C 0x9D updates socketedItems and tooltip', () => {
|
||
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
|
||
const helm = makeItemData({
|
||
id: 4010,
|
||
code: 'cap',
|
||
location: 0,
|
||
container: 'inventory',
|
||
gridX: 0,
|
||
gridY: 0,
|
||
invWidth: 2,
|
||
invHeight: 2,
|
||
socketed: true,
|
||
totalSockets: 2,
|
||
usedSockets: 0,
|
||
stats: [{ statId: D2StatId.ARMORCLASS, param: 0, value: 5 }],
|
||
})
|
||
const tirRune = makeItemData({
|
||
id: 4011,
|
||
code: 'r03',
|
||
location: 4,
|
||
container: 'cursor',
|
||
})
|
||
world.apply({ type: 'ItemEvent', item: helm })
|
||
world.apply({ type: 'ItemEvent', item: tirRune })
|
||
hudModel.syncFromWorld()
|
||
|
||
;(hudModel as Record<string, any>).socketItem(4011, 4010)
|
||
expect(sentCommands).toContainEqual({ type: 'SocketItem', itemId: 4011, targetItemId: 4010 })
|
||
expect(sentPackets[0]![0]).toBe(0x28)
|
||
|
||
// Server confirms socket insertion
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({ ...helm, usedSockets: 1, socketedChildCount: 1 }),
|
||
})
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
...tirRune,
|
||
opcode: 0x9d,
|
||
action: 0x0b,
|
||
location: 6,
|
||
container: 'socket',
|
||
inSocket: true,
|
||
ownerType: 4,
|
||
ownerId: 4010,
|
||
gridX: 0,
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
const syncedHelm = hud.inventory.inventory.find((i) => i.id === 4010)!
|
||
expect(syncedHelm.usedSockets).toBe(1)
|
||
expect(syncedHelm.socketedItems?.length).toBe(1)
|
||
})
|
||
|
||
it('CF3 (F2 × F3): packing Scroll of Identify (isc) into Tome of Identify (ibk) emits 0x29 ScrollToBook and syncs tome quantity', () => {
|
||
const { world, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
|
||
const tome = makeItemData({
|
||
id: 4020,
|
||
code: 'ibk',
|
||
location: 0,
|
||
container: 'inventory',
|
||
gridX: 0,
|
||
gridY: 0,
|
||
quantity: 5,
|
||
stats: [{ statId: D2StatId.QUANTITY, param: 0, value: 5 }],
|
||
})
|
||
const scroll = makeItemData({
|
||
id: 4021,
|
||
code: 'isc',
|
||
location: 4,
|
||
container: 'cursor',
|
||
})
|
||
world.apply({ type: 'ItemEvent', item: tome })
|
||
world.apply({ type: 'ItemEvent', item: scroll })
|
||
hudModel.syncFromWorld()
|
||
|
||
;(hudModel as Record<string, any>).scrollToBook(4021, 4020)
|
||
expect(sentCommands).toContainEqual({ type: 'ScrollToBook', scrollId: 4021, bookId: 4020 })
|
||
expect(sentPackets[0]![0]).toBe(0x29)
|
||
expect(readU32LE(sentPackets[0]!, 1)).toBe(4021)
|
||
expect(readU32LE(sentPackets[0]!, 5)).toBe(4020)
|
||
})
|
||
|
||
it('CF4 (F1 × F4): equipping Rare/Unique weapon in Weapon Set II (bodyLoc 11 & 12) syncs 1.13c stats across weapon switch', () => {
|
||
const { world, hud, hudModel } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 4030,
|
||
code: 'bsd',
|
||
quality: 6,
|
||
rareName1: 3,
|
||
rareName2: 8,
|
||
location: 1,
|
||
container: 'equipped',
|
||
bodyLoc: 11,
|
||
equippedSlot: 11,
|
||
stats: [
|
||
{ statId: 17, param: 0, value: 80 },
|
||
{ statId: 18, param: 0, value: 80 },
|
||
],
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(hud.inventory.swapWeapons.weapon1).not.toBeNull()
|
||
expect(hud.inventory.swapWeapons.weapon1?.quality).toBe('rare')
|
||
const statLines = extractStatStrings(hud.inventory.swapWeapons.weapon1?.stats).join('\n')
|
||
expect(statLines).not.toMatch(/Stat #\d+/i)
|
||
expect(statLines).toMatch(/80%/)
|
||
|
||
world.apply({ type: 'WeaponSwitch' })
|
||
hudModel.syncFromWorld()
|
||
expect(hud.inventory.activeWeaponSlot).toBe(1)
|
||
expect(hud.inventory.swapWeapons.weapon1?.id).toBe(4030)
|
||
})
|
||
|
||
it('CF5 (F5 × F6): Deckard Cain (hcIdx 244) interaction via 0x59 + S2C 0x27/0x91 opens Cain menu and Talk sends 0x31 QuestMessage', () => {
|
||
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 4040, classId: 244, name: 'Deckard Cain', x: 5104, y: 5054 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4040 } })
|
||
world.apply({
|
||
type: 'Npc',
|
||
kind: 'info',
|
||
unitType: 1,
|
||
unitId: 4040,
|
||
raw: new Uint8Array(40),
|
||
})
|
||
world.apply({
|
||
type: 'Npc',
|
||
kind: 'menu',
|
||
npcUnitId: 4040,
|
||
raw: new Uint8Array(26),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
const optIds = worldPanels.npcMenu?.options.map((o) => o.id) ?? []
|
||
expect(optIds).toContain('identify')
|
||
expect(optIds).toContain('talk')
|
||
|
||
worldPanels.clickNpcMenuOption('talk')
|
||
if (!sentCommands.some((c) => c.type === 'QuestMessage')) {
|
||
const subTopic = worldPanels.npcMenu?.options.find((o) => String(o.id).startsWith('talk_'))
|
||
if (subTopic) worldPanels.clickNpcMenuOption(subTopic.id)
|
||
}
|
||
expect(sentCommands.some((c) => c.type === 'QuestMessage')).toBe(true)
|
||
expect(sentPackets.some((p) => p[0] === 0x31 && p.byteLength === 9)).toBe(true)
|
||
})
|
||
|
||
it('CF6 (F5 × F7): Warriv Act 1 (155) travel_east (0x38) -> S2C LoadAct (Act 2) -> Warriv Act 2 (175) travel_west', () => {
|
||
const { world, worldPanels, hudModel, sentCommands, sentPackets, clearRecorded } =
|
||
createOnlineHudSession({ act: 0, areaId: 1 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 4050, classId: 155, name: 'Warriv', x: 5104, y: 5054 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4050 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
worldPanels.clickNpcMenuOption('travel_east' as any)
|
||
expect(sentCommands).toContainEqual(
|
||
expect.objectContaining({ type: 'NpcEntityAction', npcId: 4050 }),
|
||
)
|
||
expect(sentPackets.some((p) => p[0] === 0x38 && p.byteLength === 13)).toBe(true)
|
||
|
||
// Server transitions player to Act 2 Lut Gholein
|
||
clearRecorded()
|
||
world.apply({ type: 'LoadAct', act: 1, mapSeed: 0x2222, areaId: 40, objectSeed: 0x3333 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 4051, classId: 175, name: 'Warriv', x: 5104, y: 5054 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4051 } })
|
||
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: 4051 }),
|
||
)
|
||
})
|
||
|
||
it('CF7 (F8 × F9): Kashya (150) hire menu -> 0x38 (action=3) -> S2C 0x4F/0x4E candidates -> 0x36 MercHire -> 0x81 AssignMerc', () => {
|
||
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession({
|
||
selfId: 1,
|
||
})
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 4060, classId: 150, name: 'Kashya', x: 5104, y: 5054 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4060 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
worldPanels.clickNpcMenuOption('hire' as any)
|
||
expect(sentCommands).toContainEqual(
|
||
expect.objectContaining({ type: 'NpcEntityAction', action: 3, npcId: 4060 }),
|
||
)
|
||
|
||
world.apply({ type: 'Merc', kind: 'hireListStart' })
|
||
world.apply({ type: 'Merc', kind: 'forHire', nameId: 7, seed: 0xabcdef })
|
||
hudModel.syncFromWorld()
|
||
|
||
;(hudModel as Record<string, any>).hireMerc(4060, 7)
|
||
expect(sentCommands).toContainEqual({ type: 'MercHire', npcId: 4060, mercId: 7 })
|
||
expect(sentPackets.some((p) => p[0] === 0x36 && p.byteLength === 9)).toBe(true)
|
||
|
||
world.apply({
|
||
type: 'Merc',
|
||
kind: 'assign',
|
||
ownerId: 1,
|
||
mercId: 500,
|
||
classId: 271,
|
||
nameId: 7,
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(world.merc?.mercId).toBe(500)
|
||
})
|
||
|
||
it('CF8 (F8 × F9): mercenary lethal Damage (0x0C) + 0x9B MercReviveCost enables Greiz (198) resurrect -> 0x62 MercResurrect', () => {
|
||
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession({
|
||
selfId: 1,
|
||
})
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 500, classId: 338 })
|
||
world.apply({
|
||
type: 'Damage',
|
||
unitType: 1,
|
||
unitId: 500,
|
||
lifePct: 0,
|
||
raw: new Uint8Array(9),
|
||
})
|
||
world.apply({ type: 'Merc', kind: 'reviveCost', deadNameId: 12, reviveCost: 11000 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 4070, classId: 198, name: 'Greiz', x: 5104, y: 5054 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4070 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('resurrect' as any))).toBe(true)
|
||
worldPanels.clickNpcMenuOption('resurrect' as any)
|
||
expect(sentCommands).toContainEqual({ type: 'MercResurrect', npcId: 4070 })
|
||
expect(sentPackets.some((p) => p[0] === 0x62 && p.byteLength === 5)).toBe(true)
|
||
})
|
||
|
||
it('CF9 (F1 × F10): equipping 1.13c items onto mercenary (world.containers.merc) syncs tooltips in O paperdoll and emits 0x61 MercItem', () => {
|
||
const { world, hud, hudModel, dom, sentCommands, sentPackets } = createOnlineHudSession({
|
||
selfId: 1,
|
||
})
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 500, classId: 338 })
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 4080,
|
||
opcode: 0x9d,
|
||
action: 0x06,
|
||
code: 'scy', // Scythe
|
||
quality: 4,
|
||
location: 1,
|
||
container: 'merc',
|
||
bodyLoc: 4,
|
||
equippedSlot: 4,
|
||
stats: [
|
||
{ statId: 17, param: 0, value: 50 },
|
||
{ statId: 18, param: 0, value: 50 },
|
||
],
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
dom.dispatchKeyDown('o')
|
||
const mercState =
|
||
(hud as Record<string, any>).merc ?? (hud.worldPanels as Record<string, any>).merc
|
||
const weapon =
|
||
mercState?.equipped?.weapon1 ?? mercState?.equipped?.rightHand ?? mercState?.weapon1
|
||
expect(weapon).toBeDefined()
|
||
expect(weapon.twoHandDamage).toBeDefined()
|
||
|
||
;(hudModel as Record<string, any>).mercItem(4)
|
||
expect(sentCommands).toContainEqual({ type: 'MercItem', slot: 4 })
|
||
expect(sentPackets.some((p) => p[0] === 0x61 && p.byteLength === 3)).toBe(true)
|
||
})
|
||
|
||
it('CF10 (F3 × F8 × F10): Shift+useBeltSlot dispatches 0x26 BeltUse (useOnMerc=1) and S2C 0xAB NPCHeal restores merc portrait lifePct', () => {
|
||
const { world, hudModel, sentCommands, sentPackets } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 500, classId: 338, name: 'Emilio', x: 5102, y: 5052, lifePct: 40 },
|
||
})
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 500, classId: 338 })
|
||
world.apply({
|
||
type: 'Damage',
|
||
unitType: 1,
|
||
unitId: 500,
|
||
lifePct: 40,
|
||
raw: new Uint8Array(9),
|
||
})
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 4090,
|
||
code: 'hp4',
|
||
location: 2,
|
||
container: 'belt',
|
||
gridX: 0,
|
||
x: 0,
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
hudModel.useBeltSlot(0, true)
|
||
expect(sentCommands).toContainEqual({ type: 'BeltUse', itemId: 4090, useOnMerc: 1 })
|
||
expect(sentPackets[0]![0]).toBe(0x26)
|
||
expect(readU32LE(sentPackets[0]!, 5)).toBe(1)
|
||
|
||
world.apply({ type: 'Npc', kind: 'heal', unitType: 1, unitId: 500, lifePct: 128 })
|
||
hudModel.syncFromWorld()
|
||
expect((world.merc as Record<string, any>).lifePct).toBe(128)
|
||
})
|
||
|
||
it('CF11 (F11 × F12): clicking dungeon warp (0x04 + 0x09) and spawning Town Portal (0x60/0x82) resolves warp and portal hover labels', () => {
|
||
const { world, onlineSession, commandMapper, sentCommands, sentPackets } =
|
||
createOnlineHudSession({ x: 5100, y: 5050 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: {
|
||
unitType: 5,
|
||
id: 4100,
|
||
classId: 4,
|
||
warpId: 4,
|
||
name: 'To Den of Evil',
|
||
x: 5103,
|
||
y: 5053,
|
||
} as any,
|
||
})
|
||
const warpUnit = world.getUnit('tile', 4100)!
|
||
commandMapper.triggerPrimaryWorldAction(5103, 5053, warpUnit, 5000)
|
||
|
||
expect(sentCommands.some((c) => c.type === 'MoveToUnit')).toBe(true)
|
||
expect(sentPackets.some((p) => p[0] === 0x09 && p.byteLength === 9)).toBe(true)
|
||
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 2, id: 4101, classId: 59, name: 'Town Portal', x: 5105, y: 5055 },
|
||
})
|
||
world.apply({
|
||
type: 'Portal',
|
||
kind: 'ownership',
|
||
ownerId: 1,
|
||
ownerName: 'Hero',
|
||
localId: 4101,
|
||
remoteId: 4102,
|
||
})
|
||
|
||
const portalRenderable = onlineSession.buildRenderableUnits().find((u) => u.id === 4101)
|
||
expect(portalRenderable?.name).toContain("Hero's Town Portal")
|
||
})
|
||
|
||
it('CF12 (F1 × F12): ground item drop (750 Gold + Unique Cap) -> Alt label layout -> 0x16 ItemPickup -> S2C 0x9C inventory tooltip', () => {
|
||
const { world, hud, hudModel, commandMapper, sentCommands } = createOnlineHudSession()
|
||
const groundGold = makeItemData({
|
||
id: 4110,
|
||
action: 0x01,
|
||
code: 'gld',
|
||
location: 3,
|
||
container: 'ground',
|
||
worldX: 5102,
|
||
worldY: 5052,
|
||
x: 5102,
|
||
y: 5052,
|
||
quantity: 750,
|
||
})
|
||
const groundCap = makeItemData({
|
||
id: 4111,
|
||
action: 0x01,
|
||
code: 'cap',
|
||
quality: 7,
|
||
uniqueItemId: 0, // Biggin's Bonnet
|
||
location: 3,
|
||
container: 'ground',
|
||
worldX: 5102,
|
||
worldY: 5052,
|
||
x: 5102,
|
||
y: 5052,
|
||
invWidth: 2,
|
||
invHeight: 2,
|
||
})
|
||
world.apply({ type: 'ItemEvent', item: groundGold })
|
||
world.apply({ type: 'ItemEvent', item: groundCap })
|
||
|
||
const metaGold = resolveGroundItemMetadata({ id: 4110, code: 'gld', goldAmount: 750 })
|
||
const metaCap = resolveGroundItemMetadata({ id: 4111, code: 'cap', quality: 7 })
|
||
const boxes = layoutGroundItemLabels([
|
||
{ id: 4110, screenX: 400, screenY: 300, label: metaGold.labelEn, color: metaGold.color },
|
||
{ id: 4111, screenX: 400, screenY: 300, label: metaCap.labelEn, color: metaCap.color },
|
||
])
|
||
expect(boxes.length).toBe(2)
|
||
expect(boxes[0]!.y).not.toBe(boxes[1]!.y)
|
||
|
||
const capUnit = world.getUnit('item', 4111)!
|
||
commandMapper.triggerPrimaryWorldAction(5102, 5052, capUnit, 6000)
|
||
expect(sentCommands).toContainEqual({ type: 'ItemPickup', itemId: 4111, toCursor: false })
|
||
|
||
// Server moves Cap into player's inventory
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
...groundCap,
|
||
action: 0x00,
|
||
location: 0,
|
||
container: 'inventory',
|
||
gridX: 0,
|
||
gridY: 0,
|
||
stats: [{ statId: D2StatId.MAXHP, param: 0, value: 15 << 8 }],
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(hud.inventory.inventory.find((i) => i.id === 4111)?.quality).toBe('unique')
|
||
})
|
||
|
||
it('CF13 (F13 × F14): monster SkillCast (0x6C) + lethal PlayerLifeManaChange (0x95) triggers death overlay and Escape sends 0x41 Resurrect', () => {
|
||
const { world, hudModel, commandMapper, renderFrame, sentCommands, sentPackets } =
|
||
createOnlineHudSession({ selfId: 1, life: 100, maxLife: 100 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 4120, classId: 1, name: 'Fallen', x: 5105, y: 5055 },
|
||
})
|
||
world.apply({
|
||
type: 'SkillCast',
|
||
kind: 'monsterAttack',
|
||
monsterId: 4120,
|
||
attackType: 0,
|
||
targetId: 1,
|
||
targetType: 0,
|
||
target: { x: 5100, y: 5050 },
|
||
})
|
||
world.apply({
|
||
type: 'PlayerLifeManaChange',
|
||
life: 0,
|
||
mana: 20,
|
||
stamina: 40,
|
||
x: 5100,
|
||
y: 5050,
|
||
vx: 0,
|
||
vy: 0,
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
const ctx = renderFrame()
|
||
expect(ctx.drawnTexts.map((t) => t.text).join('\n')).toMatch(/You Have Died|你挂了|Press ESC/i)
|
||
|
||
commandMapper.handleKeyDown({ key: 'Escape' })
|
||
expect(sentCommands).toContainEqual({ type: 'Resurrect' })
|
||
expect(sentPackets.some((p) => p[0] === 0x41 && p.byteLength === 1)).toBe(true)
|
||
})
|
||
|
||
it('CF14 (F8 × F14): simultaneous mercenary death (0x9B) and player death -> Resurrect (0x41) -> Qual-Kehk (515) MercResurrect (0x62)', () => {
|
||
const { world, worldPanels, hudModel, commandMapper, sentCommands, sentPackets } =
|
||
createOnlineHudSession({ selfId: 1, life: 100, maxLife: 100 })
|
||
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 500, classId: 561 })
|
||
world.apply({ type: 'Merc', kind: 'reviveCost', deadNameId: 40, reviveCost: 20000 })
|
||
world.apply({
|
||
type: 'PlayerLifeManaChange',
|
||
life: 0,
|
||
mana: 0,
|
||
stamina: 0,
|
||
x: 5100,
|
||
y: 5050,
|
||
vx: 0,
|
||
vy: 0,
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
commandMapper.handleKeyDown({ key: 'Escape' })
|
||
expect(sentCommands).toContainEqual({ type: 'Resurrect' })
|
||
|
||
// Server respawns player in Harrogath with life=100
|
||
world.apply({
|
||
type: 'PlayerLifeManaChange',
|
||
life: 100,
|
||
mana: 80,
|
||
stamina: 100,
|
||
x: 5100,
|
||
y: 5050,
|
||
vx: 0,
|
||
vy: 0,
|
||
})
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 4130, classId: 515, name: 'Qual-Kehk', x: 5104, y: 5054 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4130 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
worldPanels.clickNpcMenuOption('resurrect' as any)
|
||
expect(sentCommands).toContainEqual({ type: 'MercResurrect', npcId: 4130 })
|
||
expect(sentPackets.some((p) => p[0] === 0x62 && p.byteLength === 5)).toBe(true)
|
||
})
|
||
|
||
it('CF15 (F12 × F15): allied party member joins (0x5B/0x8B/0x7F/0x90) and spawns Town Portal (0x82) with ownerName label', () => {
|
||
const { world, onlineSession, hudModel } = createOnlineHudSession({ selfId: 1 })
|
||
world.apply({
|
||
type: 'Party',
|
||
kind: 'roster',
|
||
playerId: 2,
|
||
charClass: 1,
|
||
charLevel: 35,
|
||
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: 120,
|
||
areaId: 2,
|
||
})
|
||
world.apply({
|
||
type: 'Party',
|
||
kind: 'memberPos',
|
||
playerId: 2,
|
||
at: { x: 5120, y: 5070 },
|
||
})
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 2, id: 4140, classId: 59, name: 'Town Portal', x: 5108, y: 5058 },
|
||
})
|
||
world.apply({
|
||
type: 'Portal',
|
||
kind: 'ownership',
|
||
ownerId: 2,
|
||
ownerName: 'Jaina',
|
||
localId: 4140,
|
||
remoteId: 4141,
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(world.party.get(2)?.charName).toBe('Jaina')
|
||
const portal = onlineSession.buildRenderableUnits().find((u) => u.id === 4140)
|
||
expect(portal?.name).toContain("Jaina's Town Portal")
|
||
})
|
||
|
||
it('CF16 (F1 × F5 × F16): player trade session (0x77/0x78/0x79 -> 0x4F) followed by Larzuk (511) add_sockets quest reward panel', () => {
|
||
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession({
|
||
selfId: 1,
|
||
})
|
||
world.apply({ type: 'Trade', kind: 'button', action: 0x01 })
|
||
world.apply({
|
||
type: 'Trade',
|
||
kind: 'accepted',
|
||
partnerName: 'TraderJoe',
|
||
partnerId: 2,
|
||
} as any)
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 4150,
|
||
code: 'bsd',
|
||
quality: 2,
|
||
location: 0,
|
||
container: 'tradePartner',
|
||
storePage: 2,
|
||
storagePage: 2,
|
||
gridX: 0,
|
||
gridY: 0,
|
||
}),
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(world.trade.active).toBe(true)
|
||
|
||
if (typeof (hudModel as Record<string, any>).clickButton === 'function') {
|
||
;(hudModel as Record<string, any>).clickButton(0x04, 0)
|
||
} else {
|
||
;(hudModel as Record<string, any>).sendCommand?.({
|
||
type: 'ClickButton',
|
||
button: 0x04,
|
||
buttonId: 0x04,
|
||
complement: 0,
|
||
})
|
||
}
|
||
expect(sentPackets.some((p) => p[0] === 0x4f && readU16LE(p, 1) === 0x04)).toBe(true)
|
||
|
||
// Trade completes (0x06) and item lands in player's inventory
|
||
world.apply({ type: 'Trade', kind: 'button', action: 0x06 })
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: 4150,
|
||
code: 'bsd',
|
||
quality: 2,
|
||
location: 0,
|
||
container: 'inventory',
|
||
storePage: 0,
|
||
storagePage: 0,
|
||
gridX: 0,
|
||
gridY: 0,
|
||
}),
|
||
})
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 4151, classId: 511, name: 'Larzuk', x: 5104, y: 5054 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4151 } })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('add_sockets' as any))).toBe(true)
|
||
worldPanels.clickNpcMenuOption('add_sockets' as any)
|
||
expect(sentCommands.length).toBeGreaterThan(0)
|
||
})
|
||
})
|