636 lines
21 KiB
TypeScript
636 lines
21 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
createOnlineHudSession,
|
|
D2StatId,
|
|
extractStatStrings,
|
|
layoutGroundItemLabels,
|
|
makeItemData,
|
|
readU16LE,
|
|
readU32LE,
|
|
resolveGroundItemMetadata,
|
|
} from './helpers.ts'
|
|
|
|
describe('Tier 4 — Real-World Online Gameplay Session Scenarios (8 Scenarios)', () => {
|
|
it('Scenario 1: Town Identification, Tome Packing, Rune Socketing & Weapon Set II Persistence Loop (F1 + F2 + F3 + F4)', () => {
|
|
const { world, hud, hudModel, sentCommands, sentPackets, clearRecorded } =
|
|
createOnlineHudSession({ selfId: 1 })
|
|
|
|
// 1. Server populates inventory with Tome of Identify (ibk), Scroll (isc), Unidentified Unique Cap, 2-socket Armor (stu), and Weapon Set II (bodyLoc 11 & 12)
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
item: makeItemData({
|
|
id: 5001,
|
|
code: 'ibk',
|
|
location: 0,
|
|
container: 'inventory',
|
|
gridX: 0,
|
|
gridY: 0,
|
|
invWidth: 1,
|
|
invHeight: 2,
|
|
}),
|
|
})
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
item: makeItemData({
|
|
id: 5002,
|
|
code: 'isc',
|
|
location: 4,
|
|
container: 'cursor',
|
|
}),
|
|
})
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
item: makeItemData({
|
|
id: 5003,
|
|
code: 'cap',
|
|
quality: 7,
|
|
uniqueItemId: 0, // Biggin's Bonnet
|
|
identified: false,
|
|
location: 0,
|
|
container: 'inventory',
|
|
gridX: 2,
|
|
gridY: 0,
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
}),
|
|
})
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
item: makeItemData({
|
|
id: 5004,
|
|
code: 'stu',
|
|
quality: 2,
|
|
location: 0,
|
|
container: 'inventory',
|
|
gridX: 4,
|
|
gridY: 0,
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
socketed: true,
|
|
totalSockets: 2,
|
|
usedSockets: 0,
|
|
stats: [{ statId: D2StatId.ARMORCLASS, param: 0, value: 35 }],
|
|
}),
|
|
})
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
item: makeItemData({
|
|
id: 5005,
|
|
code: 'bsd',
|
|
location: 1,
|
|
container: 'equipped',
|
|
bodyLoc: 11,
|
|
equippedSlot: 11,
|
|
}),
|
|
})
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
item: makeItemData({
|
|
id: 5006,
|
|
code: 'buc',
|
|
location: 1,
|
|
container: 'equipped',
|
|
bodyLoc: 12,
|
|
equippedSlot: 12,
|
|
}),
|
|
})
|
|
hudModel.syncFromWorld()
|
|
|
|
// 2. Pack loose Scroll (5002) into Tome (5001) -> 0x29 ScrollToBook (9B)
|
|
;(hudModel as Record<string, any>).scrollToBook(5002, 5001)
|
|
expect(sentCommands).toContainEqual({ type: 'ScrollToBook', scrollId: 5002, bookId: 5001 })
|
|
expect(sentPackets[0]![0]).toBe(0x29)
|
|
expect(sentPackets[0]!.byteLength).toBe(9)
|
|
|
|
// 3. Identify Unique Cap (5003) using Tome (5001) -> 0x27 IdentifyItem (9B)
|
|
clearRecorded()
|
|
;(hudModel as Record<string, any>).identifyItem(5003, 5001)
|
|
expect(sentCommands).toContainEqual({ type: 'IdentifyItem', itemId: 5003, scrollId: 5001 })
|
|
expect(sentPackets[0]![0]).toBe(0x27)
|
|
|
|
// Server sends identified Unique Cap with canonical 1.13c stats
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
item: makeItemData({
|
|
id: 5003,
|
|
code: 'cap',
|
|
quality: 7,
|
|
uniqueItemId: 0,
|
|
identified: true,
|
|
location: 0,
|
|
container: 'inventory',
|
|
gridX: 2,
|
|
gridY: 0,
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
stats: [
|
|
{ statId: 17, param: 0, value: 30 },
|
|
{ statId: D2StatId.MAXHP, param: 0, value: 15 << 8 },
|
|
{ statId: D2StatId.MAXMANA, param: 0, value: 15 << 8 },
|
|
],
|
|
}),
|
|
})
|
|
hudModel.syncFromWorld()
|
|
const idCap = hud.inventory.inventory.find((i) => i.id === 5003)!
|
|
expect(idCap.identified).toBe(true)
|
|
expect(extractStatStrings(idCap.stats).join('\n')).not.toMatch(/Stat #\d+/i)
|
|
|
|
// 4. Socket Tal Rune (5007) into 2-socket Studded Leather (5004) -> 0x28 SocketItem (9B)
|
|
clearRecorded()
|
|
;(hudModel as Record<string, any>).socketItem(5007, 5004)
|
|
expect(sentCommands).toContainEqual({ type: 'SocketItem', itemId: 5007, targetItemId: 5004 })
|
|
expect(sentPackets[0]![0]).toBe(0x28)
|
|
|
|
// 5. Verify Weapon Set II (5005, 5006) across WeaponSwitch
|
|
expect(hud.inventory.swapWeapons.weapon1?.id).toBe(5005)
|
|
expect(hud.inventory.swapWeapons.weapon2?.id).toBe(5006)
|
|
hudModel.swapWeapons()
|
|
world.apply({ type: 'WeaponSwitch' })
|
|
hudModel.syncFromWorld()
|
|
expect(hud.inventory.activeWeaponSlot).toBe(1)
|
|
expect(hud.inventory.swapWeapons.weapon1?.id).toBe(5005)
|
|
})
|
|
|
|
it('Scenario 2: Belt Potion Management & Shift-Click Mercenary Healing Loop (F2 + F3 + F8 + F10)', () => {
|
|
const { world, hudModel, sentCommands, sentPackets, clearRecorded } = createOnlineHudSession({
|
|
selfId: 1,
|
|
})
|
|
|
|
// 1. Place potion (5101) into belt slot 0 -> 0x23 BeltPut (9B)
|
|
;(hudModel as Record<string, any>).putBeltItem(5101, 0)
|
|
expect(sentCommands).toContainEqual({ type: 'BeltPut', itemId: 5101, slot: 0 })
|
|
expect(sentPackets[0]![0]).toBe(0x23)
|
|
|
|
// Server confirms potion in belt slot 0
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
item: makeItemData({
|
|
id: 5101,
|
|
code: 'hp3',
|
|
location: 2,
|
|
container: 'belt',
|
|
gridX: 0,
|
|
x: 0,
|
|
}),
|
|
})
|
|
hudModel.syncFromWorld()
|
|
|
|
// 2. Swap belt potion (5101) with Super Healing Potion (5102) on cursor -> 0x25 BeltSwap (9B)
|
|
clearRecorded()
|
|
;(hudModel as Record<string, any>).swapBeltItem(5102, 5101)
|
|
expect(sentCommands).toContainEqual({
|
|
type: 'BeltSwap',
|
|
cursorItemId: 5102,
|
|
beltItemId: 5101,
|
|
})
|
|
expect(sentPackets[0]![0]).toBe(0x25)
|
|
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
item: makeItemData({
|
|
id: 5102,
|
|
code: 'hp5',
|
|
location: 2,
|
|
container: 'belt',
|
|
gridX: 0,
|
|
x: 0,
|
|
}),
|
|
})
|
|
hudModel.syncFromWorld()
|
|
|
|
// 3. Assign damaged mercenary (lifePct = 32) and feed belt slot 0 with Shift -> 0x26 BeltUse (useOnMerc=1)
|
|
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 88, classId: 338 })
|
|
world.apply({
|
|
type: 'Damage',
|
|
unitType: 1,
|
|
unitId: 88,
|
|
lifePct: 32,
|
|
raw: new Uint8Array(9),
|
|
})
|
|
hudModel.syncFromWorld()
|
|
|
|
clearRecorded()
|
|
hudModel.useBeltSlot(0, true)
|
|
expect(sentCommands).toContainEqual({
|
|
type: 'BeltUse',
|
|
itemId: 5102,
|
|
useOnMerc: 1,
|
|
})
|
|
expect(sentPackets[0]![0]).toBe(0x26)
|
|
expect(readU32LE(sentPackets[0]!, 5)).toBe(1)
|
|
|
|
// 4. Remove potion from belt -> 0x24 BeltRemove (5B)
|
|
clearRecorded()
|
|
;(hudModel as Record<string, any>).removeBeltItem(5102)
|
|
expect(sentCommands).toContainEqual({ type: 'BeltRemove', itemId: 5102 })
|
|
expect(sentPackets[0]![0]).toBe(0x24)
|
|
})
|
|
|
|
it('Scenario 3: Multi-Act Town NPC Dialogue, Cain Identification & Act Travel Loop (F2 + F5 + F6 + F7)', () => {
|
|
const { world, worldPanels, hudModel, sentCommands, sentPackets, clearRecorded } =
|
|
createOnlineHudSession({ act: 0, areaId: 1 })
|
|
|
|
// 1. Interact with Deckard Cain in Act 1 (hcIdx 244)
|
|
world.apply({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 5201, classId: 244, name: 'Deckard Cain', x: 5104, y: 5054 },
|
|
})
|
|
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 5201 } })
|
|
hudModel.syncFromWorld()
|
|
|
|
// Click Identify Items -> 0x38 (action=4)
|
|
worldPanels.clickNpcMenuOption('identify')
|
|
expect(sentCommands).toContainEqual({
|
|
type: 'NpcIdentifyItems',
|
|
npcId: 5201,
|
|
})
|
|
|
|
// Re-open Cain menu and trigger Talk -> 0x31 QuestMessage
|
|
clearRecorded()
|
|
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 5201 } })
|
|
hudModel.syncFromWorld()
|
|
worldPanels.clickNpcMenuOption('talk')
|
|
if (!sentCommands.some((c) => c.type === 'QuestMessage')) {
|
|
const topic = worldPanels.npcMenu?.options.find((o) => String(o.id).startsWith('talk_'))
|
|
if (topic) worldPanels.clickNpcMenuOption(topic.id)
|
|
}
|
|
expect(sentPackets.some((p) => p[0] === 0x31 && p.byteLength === 9)).toBe(true)
|
|
|
|
// 2. Travel East with Warriv (155) -> Act 2 -> Travel East with Meshif (210) -> Act 3 -> Travel to Harrogath with Tyrael (367)
|
|
for (const step of [
|
|
{ id: 5202, hcIdx: 155, name: 'Warriv', action: 'travel_east', nextAct: 1 as const, nextArea: 40 },
|
|
{ id: 5203, hcIdx: 210, name: 'Meshif', action: 'travel_east', nextAct: 2 as const, nextArea: 75 },
|
|
{ id: 5204, hcIdx: 367, name: 'Tyrael', action: 'travel_harrogath', nextAct: 4 as const, nextArea: 109 },
|
|
]) {
|
|
clearRecorded()
|
|
world.apply({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: step.id, classId: step.hcIdx, name: step.name, x: 5104, y: 5054 },
|
|
})
|
|
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: step.id } })
|
|
hudModel.syncFromWorld()
|
|
|
|
worldPanels.clickNpcMenuOption(step.action as any)
|
|
expect(sentCommands).toContainEqual(
|
|
expect.objectContaining({ type: 'NpcEntityAction', npcId: step.id }),
|
|
)
|
|
expect(sentPackets.some((p) => p[0] === 0x38 && p.byteLength === 13)).toBe(true)
|
|
|
|
world.apply({
|
|
type: 'LoadAct',
|
|
act: step.nextAct,
|
|
mapSeed: 0x9999,
|
|
areaId: step.nextArea,
|
|
objectSeed: 0x8888,
|
|
})
|
|
hudModel.syncFromWorld()
|
|
expect(world.act).toBe(step.nextAct)
|
|
expect(world.areaId).toBe(step.nextArea)
|
|
}
|
|
})
|
|
|
|
it('Scenario 4: Mercenary Captain Hiring, Equipment Paperdoll (O) & Post-Combat Resurrection Loop (F1 + F2 + F8 + F9 + F10)', () => {
|
|
const { world, hud, worldPanels, hudModel, dom, sentCommands, sentPackets, clearRecorded } =
|
|
createOnlineHudSession({ selfId: 1 })
|
|
|
|
// 1. Open Greiz (198) menu -> click Hire -> 0x38 (action=3)
|
|
world.apply({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 5301, classId: 198, name: 'Greiz', x: 5104, y: 5054 },
|
|
})
|
|
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 5301 } })
|
|
hudModel.syncFromWorld()
|
|
worldPanels.clickNpcMenuOption('hire' as any)
|
|
expect(sentCommands).toContainEqual(
|
|
expect.objectContaining({ type: 'NpcEntityAction', action: 3, npcId: 5301 }),
|
|
)
|
|
|
|
// 2. Receive 0x4F + 0x4E candidates and hire mercId=9 -> 0x36 MercHire (9B)
|
|
world.apply({ type: 'Merc', kind: 'hireListStart' })
|
|
world.apply({ type: 'Merc', kind: 'forHire', nameId: 9, seed: 0x445566 })
|
|
hudModel.syncFromWorld()
|
|
clearRecorded()
|
|
;(hudModel as Record<string, any>).hireMerc(5301, 9)
|
|
expect(sentCommands).toContainEqual({ type: 'MercHire', npcId: 5301, mercId: 9 })
|
|
expect(sentPackets[0]![0]).toBe(0x36)
|
|
|
|
// 3. Server assigns merc (unitId=600) and equips polearm (bodyLoc=4)
|
|
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 600, classId: 338, nameId: 9 })
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
item: makeItemData({
|
|
id: 5310,
|
|
opcode: 0x9d,
|
|
action: 0x06,
|
|
code: 'vou', // Voulge
|
|
location: 1,
|
|
container: 'merc',
|
|
bodyLoc: 4,
|
|
equippedSlot: 4,
|
|
}),
|
|
})
|
|
hudModel.syncFromWorld()
|
|
|
|
// Open 'O' Mercenary Paperdoll and click weapon slot -> 0x61 MercItem (3B)
|
|
dom.dispatchKeyDown('o')
|
|
clearRecorded()
|
|
;(hudModel as Record<string, any>).mercItem(4)
|
|
expect(sentCommands).toContainEqual({ type: 'MercItem', slot: 4 })
|
|
expect(sentPackets[0]![0]).toBe(0x61)
|
|
|
|
// 4. Mercenary dies in combat (0x9B MercReviveCost) -> Resurrect at Greiz -> 0x62 MercResurrect (5B)
|
|
world.apply({ type: 'Merc', kind: 'reviveCost', deadNameId: 9, reviveCost: 14000 })
|
|
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 5301 } })
|
|
hudModel.syncFromWorld()
|
|
clearRecorded()
|
|
worldPanels.clickNpcMenuOption('resurrect' as any)
|
|
expect(sentCommands).toContainEqual({ type: 'MercResurrect', npcId: 5301 })
|
|
expect(sentPackets[0]![0]).toBe(0x62)
|
|
})
|
|
|
|
it('Scenario 5: Dungeon Warp Traversal, Town Portal Ownership Hover & Ground Loot Pickup Loop (F2 + F11 + F12)', () => {
|
|
const {
|
|
world,
|
|
onlineSession,
|
|
sceneSource,
|
|
commandMapper,
|
|
sentCommands,
|
|
sentPackets,
|
|
clearRecorded,
|
|
} = createOnlineHudSession({ x: 5100, y: 5050 })
|
|
|
|
// 1. Server assigns Den of Evil warp tile (unitId=5401, warpId=4)
|
|
world.apply({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 5,
|
|
id: 5401,
|
|
classId: 4,
|
|
warpId: 4,
|
|
name: 'To Den of Evil',
|
|
x: 5100,
|
|
y: 5050,
|
|
} as any,
|
|
})
|
|
|
|
// Pick warp at screen center and click -> MoveToUnit (0x04) + TakeWarp (0x09)
|
|
const pickedWarp = sceneSource.pickUnitAtScreen(400, 300 - 24, 48)
|
|
expect(pickedWarp?.id).toBe(5401)
|
|
const warpUnit = world.getUnit('tile', 5401)!
|
|
commandMapper.triggerPrimaryWorldAction(5100, 5050, warpUnit, 7000)
|
|
expect(sentCommands).toContainEqual(
|
|
expect.objectContaining({ type: 'MoveToUnit', target: { unitType: 5, id: 5401 } }),
|
|
)
|
|
expect(sentPackets.some((p) => p[0] === 0x09 && p.byteLength === 9)).toBe(true)
|
|
|
|
// 2. Allied Town Portal (5402) and Ground Gold (5403: 1250 Gold) spawn
|
|
clearRecorded()
|
|
world.apply({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 2, id: 5402, classId: 59, name: 'Town Portal', x: 5104, y: 5054 },
|
|
})
|
|
world.apply({
|
|
type: 'Portal',
|
|
kind: 'ownership',
|
|
ownerId: 2,
|
|
ownerName: 'Sorceress',
|
|
localId: 5402,
|
|
remoteId: 5403,
|
|
})
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
item: makeItemData({
|
|
id: 5404,
|
|
action: 0x01,
|
|
code: 'gld',
|
|
location: 3,
|
|
container: 'ground',
|
|
worldX: 5102,
|
|
worldY: 5052,
|
|
x: 5102,
|
|
y: 5052,
|
|
quantity: 1250,
|
|
}),
|
|
})
|
|
|
|
const renderables = onlineSession.buildRenderableUnits()
|
|
expect(renderables.find((u) => u.id === 5402)?.name).toContain("Sorceress's Town Portal")
|
|
|
|
const goldMeta = resolveGroundItemMetadata({ id: 5404, code: 'gld', goldAmount: 1250 })
|
|
expect(goldMeta.labelEn).toBe('1250 Gold')
|
|
const boxes = layoutGroundItemLabels([
|
|
{ id: 5404, screenX: 400, screenY: 300, label: goldMeta.labelEn, color: goldMeta.color },
|
|
])
|
|
expect(boxes.length).toBe(1)
|
|
|
|
// 3. Click ground gold -> 0x16 ItemPickup (13B)
|
|
const goldUnit = world.getUnit('item', 5404)!
|
|
commandMapper.triggerPrimaryWorldAction(5102, 5052, goldUnit, 8000)
|
|
expect(sentCommands).toContainEqual({ type: 'ItemPickup', itemId: 5404, toCursor: false })
|
|
expect(sentPackets.some((p) => p[0] === 0x16 && p.byteLength === 13)).toBe(true)
|
|
})
|
|
|
|
it('Scenario 6: Combat SkillCast Animation, Player Death Overlay & Town Corpse Resurrection Loop (F2 + F13 + F14)', () => {
|
|
const {
|
|
world,
|
|
onlineSession,
|
|
hudModel,
|
|
commandMapper,
|
|
renderFrame,
|
|
sentCommands,
|
|
sentPackets,
|
|
} = createOnlineHudSession({ selfId: 1, life: 120, maxLife: 120, x: 5100, y: 5050 })
|
|
|
|
// 1. Spawn monster (5501) and exchange SkillCast packets (0x4D player cast + 0x6C monster attack)
|
|
world.apply({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: 5501, classId: 1, name: 'Fallen', x: 5110, y: 5060 },
|
|
})
|
|
world.apply({
|
|
type: 'SkillCast',
|
|
kind: 'atLocation',
|
|
unitType: 0,
|
|
unitId: 1,
|
|
skillId: 47,
|
|
skillLevel: 5,
|
|
target: { x: 5110, y: 5060 },
|
|
})
|
|
world.apply({
|
|
type: 'SkillCast',
|
|
kind: 'monsterAttack',
|
|
monsterId: 5501,
|
|
attackType: 0,
|
|
targetId: 1,
|
|
targetType: 0,
|
|
target: { x: 5100, y: 5050 },
|
|
})
|
|
|
|
const monsterRenderable = onlineSession.buildRenderableUnits().find((u) => u.id === 5501)
|
|
expect([4, 5, 7]).toContain(monsterRenderable?.mode)
|
|
|
|
// 2. Player takes lethal damage (life -> 0)
|
|
world.apply({
|
|
type: 'PlayerLifeManaChange',
|
|
life: 0,
|
|
mana: 10,
|
|
stamina: 20,
|
|
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)
|
|
|
|
// 3. Player presses Escape -> 0x41 Resurrect (1B)
|
|
commandMapper.handleKeyDown({ key: 'Escape' })
|
|
expect(sentCommands).toContainEqual({ type: 'Resurrect' })
|
|
expect(sentPackets.some((p) => p[0] === 0x41 && p.byteLength === 1)).toBe(true)
|
|
|
|
// 4. Server respawns player in town with full life
|
|
world.apply({
|
|
type: 'PlayerLifeManaChange',
|
|
life: 120,
|
|
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)
|
|
})
|
|
|
|
it('Scenario 7: Multiplayer Party Roster, Invite/Accept, Hostile & Corpse Loot Permissions Loop (F2 + F15)', () => {
|
|
const { world, hud, worldPanels, hudModel, commandMapper, sentCommands, sentPackets, clearRecorded } =
|
|
createOnlineHudSession({ selfId: 1 })
|
|
|
|
// 1. Peer player (id=2, Amazon) joins game unpartied (partyId=0xffff, charName='Valkyrie')
|
|
world.apply({
|
|
type: 'Party',
|
|
kind: 'roster',
|
|
playerId: 2,
|
|
charClass: 0,
|
|
charLevel: 42,
|
|
partyId: 0xffff,
|
|
charName: 'Valkyrie',
|
|
})
|
|
hudModel.syncFromWorld()
|
|
expect(world.party.get(2)?.charName).toBe('Valkyrie')
|
|
|
|
// 2. Open 'P' Party panel and invite peer -> 0x5E (action=0x06)
|
|
commandMapper.handleKeyDown({ key: 'p' })
|
|
expect(
|
|
(hud.leftPanel as string) === 'party' || (worldPanels.activePanel as string) === 'party',
|
|
).toBe(true)
|
|
|
|
;(hudModel as Record<string, any>).partyAction(0x06, 2)
|
|
expect(sentCommands).toContainEqual({ type: 'PartyAction', action: 0x06, targetPlayerId: 2 })
|
|
expect(sentPackets[0]![0]).toBe(0x5e)
|
|
|
|
// 3. Server updates party relation to allied (state=1), lifePct=100, areaId=2, pos=(5150, 5090)
|
|
world.apply({ type: 'Party', kind: 'relation', subKind: 'partyState', playerId: 2, state: 1 })
|
|
world.apply({
|
|
type: 'Party',
|
|
kind: 'memberState',
|
|
isSelf: false,
|
|
unitId: 2,
|
|
lifePct: 100,
|
|
areaId: 2,
|
|
})
|
|
world.apply({ type: 'Party', kind: 'memberPos', playerId: 2, at: { x: 5150, y: 5090 } })
|
|
hudModel.syncFromWorld()
|
|
|
|
// 4. Grant Corpse Loot permission (button=2) and toggle Hostile (button=1) -> 0x5D (7B)
|
|
clearRecorded()
|
|
;(hudModel as Record<string, any>).setHostile(2, true, 2)
|
|
expect(sentPackets[0]![0]).toBe(0x5d)
|
|
expect(sentPackets[0]![1]).toBe(2)
|
|
|
|
clearRecorded()
|
|
;(hudModel as Record<string, any>).setHostile(2, true, 1)
|
|
expect(sentPackets[0]![0]).toBe(0x5d)
|
|
expect(sentPackets[0]![1]).toBe(1)
|
|
|
|
// 5. Peer leaves game (0x5C: partyId=0xffff, charName='')
|
|
world.apply({ type: 'Party', kind: 'roster', playerId: 2, partyId: 0xffff, charName: '' })
|
|
hudModel.syncFromWorld()
|
|
expect(world.party.has(2)).toBe(false)
|
|
})
|
|
|
|
it('Scenario 8: Player-to-Player Trade Session & Act 1/5 Quest Reward Item Upgrading Loop (F1 + F2 + F5 + F16)', () => {
|
|
const { world, worldPanels, hudModel, sentPackets, clearRecorded } = createOnlineHudSession({
|
|
selfId: 1,
|
|
})
|
|
|
|
// 1. Server opens trade session (0x77 action 0x01) with partner 'Deckard' (0x78) and gold offers (0x79)
|
|
world.apply({ type: 'Trade', kind: 'button', action: 0x01 })
|
|
world.apply({
|
|
type: 'Trade',
|
|
kind: 'accepted',
|
|
partnerName: 'Deckard',
|
|
partnerId: 3,
|
|
} as any)
|
|
world.apply({ type: 'Trade', kind: 'gold', isSelf: false, amount: 50000 })
|
|
world.apply({ type: 'Trade', kind: 'gold', isSelf: true, amount: 20000 })
|
|
world.apply({
|
|
type: 'ItemEvent',
|
|
item: makeItemData({
|
|
id: 5701,
|
|
code: 'cap',
|
|
quality: 7,
|
|
uniqueItemId: 0,
|
|
location: 0,
|
|
container: 'tradePartner',
|
|
storePage: 2,
|
|
storagePage: 2,
|
|
gridX: 0,
|
|
gridY: 0,
|
|
}),
|
|
})
|
|
hudModel.syncFromWorld()
|
|
expect(world.trade.active).toBe(true)
|
|
expect(world.containers.partnerTradeGrid.length).toBe(1)
|
|
|
|
// 2. Click Trade Accept -> 0x4F (buttonId=0x04)
|
|
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)
|
|
|
|
// 3. Complete trade (0x77 action 0x06) and open Charsi (154 imbue), Larzuk (511 add_sockets), and Anya (512 personalize)
|
|
world.apply({ type: 'Trade', kind: 'button', action: 0x06 })
|
|
hudModel.syncFromWorld()
|
|
expect(world.trade.active).toBe(false)
|
|
|
|
for (const reward of [
|
|
{ id: 5710, hcIdx: 154, name: 'Charsi', action: 'imbue' },
|
|
{ id: 5711, hcIdx: 511, name: 'Larzuk', action: 'add_sockets' },
|
|
{ id: 5712, hcIdx: 512, name: 'Anya', action: 'personalize' },
|
|
]) {
|
|
clearRecorded()
|
|
world.apply({
|
|
type: 'UnitAssign',
|
|
unit: { unitType: 1, id: reward.id, classId: reward.hcIdx, name: reward.name, x: 5104, y: 5054 },
|
|
})
|
|
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: reward.id } })
|
|
hudModel.syncFromWorld()
|
|
expect(worldPanels.npcMenu?.options.some((o) => o.id === (reward.action as any))).toBe(true)
|
|
worldPanels.clickNpcMenuOption(reward.action as any)
|
|
hudModel.closeActiveNpcPanel()
|
|
}
|
|
})
|
|
})
|