diablo2-web/tests/e2e-d2gs-online/tier4-real-world-scenarios....

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()
}
})
})