diablo2-web/tests/client/hud-server-authority.test.ts

598 lines
22 KiB
TypeScript

/**
* Regression tests for the client hard-coded/demo value audit (server-authoritative HUD).
*
* Covers: neutral level-1 self, class id -> tree/quickbar mapping for all 7 classes, HUD clicks that
* only emit `ClientCommand`s, empty equipment/belt and the plain-integer stat 6-11 wire format.
*/
import { describe, expect, it, vi } from 'vitest'
import {
HudModel,
CLASS_ID_TO_CODE,
nextLevelExperience,
itemDataToUiInventoryItem,
} from '../../src/client/ui-model/hud-model.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { EQUIP_SLOTS_LAYOUT, INV_GRID_ORIGIN } from '../../src/client/ui/inventory.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { D2StatId } from '../../src/client/world/stat-list.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../src/common/skills/formulas.ts'
import type { ClientCommand, ItemData } from '../../src/netproto/index.ts'
function setup() {
const world = new ClientWorld()
const hud = new HudManager()
const commands: ClientCommand[] = []
const model = new HudModel({ world, hudManager: hud, onCommand: (c) => commands.push(c) })
return { world, hud, model, commands }
}
function assignSelf(world: ClientWorld, classId: number): void {
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId, name: 'WebBotOne', x: 5000, y: 5000 },
isSelf: true,
})
}
function makeOnlineItem(overrides: Partial<ItemData>): ItemData {
const id = overrides.id ?? 100
const code = overrides.code ?? 'cap'
const x = overrides.gridX ?? overrides.x ?? 0
const y = overrides.gridY ?? overrides.y ?? 0
const bodyLoc = overrides.bodyLoc ?? overrides.equippedSlot ?? 0
return {
id,
itemId: overrides.itemId ?? id,
opcode: overrides.opcode ?? 0x9c,
code,
rawCode: overrides.rawCode ?? `${code} `,
action: overrides.action ?? 0,
category: overrides.category ?? 1,
location: overrides.location ?? 0,
container: overrides.container ?? 'inventory',
bodyLoc,
equippedSlot: bodyLoc,
gridX: x,
gridY: y,
storePage: overrides.storePage ?? 1,
storagePage: overrides.storagePage ?? 1,
worldX: overrides.worldX ?? 0,
worldY: overrides.worldY ?? 0,
x,
y,
invWidth: overrides.invWidth ?? 2,
invHeight: overrides.invHeight ?? 2,
quality: overrides.quality ?? 2,
ilvl: overrides.ilvl ?? 10,
identified: overrides.identified ?? true,
ethereal: overrides.ethereal ?? false,
socketed: overrides.socketed ?? false,
totalSockets: overrides.totalSockets ?? 0,
usedSockets: overrides.usedSockets ?? 0,
socketedChildCount: overrides.socketedChildCount ?? 0,
broken: overrides.broken ?? false,
compact: overrides.compact ?? false,
simple: overrides.simple ?? false,
isEar: overrides.isEar ?? false,
newItem: overrides.newItem ?? false,
starterItem: overrides.starterItem ?? false,
inSocket: overrides.inSocket ?? false,
equipped: overrides.equipped ?? false,
runeword: overrides.runeword ?? false,
personalized: overrides.personalized ?? false,
goldAmount: overrides.goldAmount ?? 0,
durability: overrides.durability ?? 12,
curDurability: overrides.curDurability ?? overrides.durability ?? 12,
maxDurability: overrides.maxDurability ?? 12,
quantity: overrides.quantity ?? 0,
defense: overrides.defense ?? 5,
setItemId: overrides.setItemId ?? 0,
uniqueItemId: overrides.uniqueItemId ?? 0,
stats: overrides.stats ?? [{ statId: D2StatId.ArmorClass, param: 0, value: 5 }],
setBonusStats: overrides.setBonusStats ?? [],
runewordStats: overrides.runewordStats ?? [],
ownerType: overrides.ownerType ?? 0,
ownerId: overrides.ownerId ?? 1,
flags: overrides.flags ?? 0x10,
rawFlags: overrides.rawFlags ?? 0x10,
version: overrides.version ?? 100,
...overrides,
}
}
describe('client hardcode audit — server-authoritative HUD', () => {
it('level-1 self with only non-zero stats shows 0 stat/skill points, 0 gold and table XP thresholds', () => {
const { world, hud, model } = setup()
assignSelf(world, 6)
// Values exactly as sent by the 1.13c server for a fresh Assassin (tests/fixtures/netproto/02*.d2cap):
// stats 6..11 arrive as 8.8 fixed point (ItemStatCost ValShift 8).
world.apply({ type: 'StatSet', stat: D2StatId.Strength, value: 20 })
world.apply({ type: 'StatSet', stat: D2StatId.Energy, value: 25 })
world.apply({ type: 'StatSet', stat: D2StatId.Dexterity, value: 20 })
world.apply({ type: 'StatSet', stat: D2StatId.Vitality, value: 20 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxHp, value: 12800 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxMana, value: 6400 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 24320 })
world.apply({ type: 'StatSet', stat: D2StatId.Level, value: 1 })
model.syncFromWorld()
expect(hud.charSheet.attrs.statPoints).toBe(0)
expect(hud.skillTree.unspentSkillPoints).toBe(0)
expect(hud.inventory.gold).toBe(0)
expect(hud.worldPanels.stashGold).toBe(0)
expect(hud.charSheet.attrs.level).toBe(1)
expect(hud.charSheet.attrs.xp).toBe(0)
expect(hud.charSheet.attrs.nextLevelXp).toBe(D2_EXPERIENCE_TABLE_99[2])
expect(nextLevelExperience(1)).toBe(500)
expect(nextLevelExperience(99)).toBe(0)
expect(hud.maxHp).toBe(50)
expect(hud.maxMana).toBe(25)
expect(hud.maxStamina).toBe(95)
})
it('stat 6-11 values are 8.8 fixed point on the wire (ValShift 8, unconditional)', () => {
const { world } = setup()
assignSelf(world, 6)
world.apply({ type: 'StatSet', stat: D2StatId.MaxHp, value: 12800 }) // raw 1e 07 00 32
world.apply({ type: 'StatSet', stat: D2StatId.MaxMana, value: (420 << 8) + 128 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 24320 }) // raw 1e 0b 00 5f
world.apply({ type: 'StatSet', stat: D2StatId.HitPoints, value: 128 }) // 0.5 hp: no magnitude heuristic
expect(world.self.maxLife).toBe(50)
expect(world.self.maxMana).toBe(420)
expect(world.self.maxStamina).toBe(95)
expect(world.self.life).toBe(0)
expect(world.self.stats.get(D2StatId.MaxHp)).toBe(12800)
})
it('maps every 1.13c class id to its own skill tree and an empty server-driven quickbar', () => {
for (const [idStr, code] of Object.entries(CLASS_ID_TO_CODE)) {
const { world, hud, model } = setup()
assignSelf(world, Number(idStr))
model.syncFromWorld()
expect(hud.currentClass).toBe(code)
expect(hud.skillTree.characterClass).toBe(code)
expect(hud.skillTree.getSkillTree().length).toBeGreaterThan(0)
expect(hud.hotkeys.availableSkills).toEqual([])
}
const { world, hud, model } = setup()
assignSelf(world, 6)
model.syncFromWorld()
expect(hud.currentClass).toBe('ass')
expect(hud.charSheet.attrs.classNameEn).toBe('Assassin')
})
it('is not ready (no class, no fabricated name/class) before the server assigns the class', () => {
const { hud, model } = setup()
model.syncFromWorld()
expect(hud.currentClass).toBeNull()
expect(hud.skillTree.characterClass).toBeNull()
const snap = model.getSnapshot()
expect(snap.charClass).toBeNull()
expect(snap.classCode).toBeNull()
expect(snap.charName).toBe('')
expect(snap.hp).toBe(0)
})
it('HUD interactions only emit ClientCommands and never mutate local state', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
world.apply({ type: 'StatSet', stat: D2StatId.StatPts, value: 5 })
world.apply({ type: 'StatSet', stat: D2StatId.NewSkills, value: 2 })
world.apply({ type: 'StatSet', stat: D2StatId.Vitality, value: 10 })
model.syncFromWorld()
expect(hud.commandSink).toBe(model)
expect(hud.allocateStat('vit')).toBe(true)
expect(hud.charSheet.attrs.vit).toBe(10)
expect(hud.charSheet.attrs.statPoints).toBe(5)
;(hud as any).handleSkillAllocated(36)
expect(hud.skillTree.unspentSkillPoints).toBe(2)
expect(hud.skillTree.hardPoints.get(36)).toBeUndefined()
hud.hotkeys.onSelectSkill?.('right', 36)
expect(hud.hotkeys.rightSkillId).toBe(0)
expect(world.self.rightSkill).toBe(0)
// Empty belt: no BeltUse with a made-up item id.
expect(hud.useBeltSlot(0)).toBe(false)
expect(commands).toEqual([
{ type: 'AddStatPoint', statId: D2StatId.Vitality },
{ type: 'AddSkillPoint', skillId: 36 },
{ type: 'SelectSkill', hand: 'right', skill: 36, itemId: 0xffffffff },
])
})
it('no equipment / empty belt / empty containers show no items', () => {
const { world, hud, model } = setup()
assignSelf(world, 1)
model.syncFromWorld()
expect(Object.keys(hud.inventory.equipped)).toEqual([])
expect(hud.inventory.gridItems).toEqual([])
expect(hud.belt.grid.flat().every((p) => p === null)).toBe(true)
expect(hud.worldPanels.stashItems).toEqual([])
expect(hud.worldPanels.cubeItems).toEqual([])
expect(hud.inventory.cursorItem).toBeNull()
})
it('splits 0x79 GoldInTrade into self and partner gold', () => {
const world = new ClientWorld()
world.apply({ type: 'Trade', action: 0x7901, gold: 300 })
world.apply({ type: 'Trade', action: 0x7900, gold: 70 })
expect(world.trade.selfGold).toBe(300)
expect(world.trade.partnerGold).toBe(70)
})
it('R1.1 bridges online ItemData with socketed children into canonical 1.13c ItemTooltipModel', () => {
const gem = makeOnlineItem({
id: 201,
code: 'gcv',
location: 6,
simple: true,
})
const helm = makeOnlineItem({
id: 200,
code: 'cap',
quality: 4,
identified: true,
ethereal: true,
socketed: true,
totalSockets: 2,
usedSockets: 1,
defense: 18,
durability: 9,
maxDurability: 12,
stats: [
{ statId: D2StatId.ArmorClass, param: 0, value: 18 },
{ statId: D2StatId.MaxHp, param: 0, value: 15 << 8 },
{ statId: D2StatId.SocketCount, param: 0, value: 2 },
],
})
const uiItem = itemDataToUiInventoryItem(helm, [gem])
expect(uiItem.defense).toBe(18)
expect(uiItem.durability).toEqual({ current: 9, max: 12 })
expect(uiItem.ethereal).toBe(true)
expect(uiItem.sockets).toBe(2)
expect(uiItem.socketedGems).toEqual(['gcv'])
expect(uiItem.affixes?.some((a: string) => a.includes('+15') && a.includes('Life'))).toBe(true)
})
it('R1.3 syncs Weapon Set II (bodyLoc 11 & 12) into hud.inventory.swapWeapons and weaponSwapSet', () => {
const { world, hud, model } = setup()
assignSelf(world, 1)
const primarySword = makeOnlineItem({ id: 301, code: 'ssd', location: 1, bodyLoc: 4, container: 'equipment' })
const swapWand = makeOnlineItem({ id: 302, code: 'wnd', location: 1, bodyLoc: 11, container: 'equipment' })
const swapShield = makeOnlineItem({ id: 303, code: 'buc', location: 1, bodyLoc: 12, container: 'equipment' })
world.apply({ type: 'ItemEvent', action: 6, item: primarySword, container: 'equipment' })
world.apply({ type: 'ItemEvent', action: 6, item: swapWand, container: 'equipment' })
world.apply({ type: 'ItemEvent', action: 6, item: swapShield, container: 'equipment' })
world.self.weaponSet = 1
model.syncFromWorld()
expect(hud.inventory.equipped.weapon1?.id).toBe('301')
expect(hud.inventory.swapWeapons.weapon1?.id).toBe('302')
expect(hud.inventory.swapWeapons.weapon2?.id).toBe('303')
expect(hud.inventory.weaponSwapSet).toBe(1)
})
it('R1.2 emits container, belt, identify, socket, and tome commands (0x1D, 0x1F, 0x23-0x29)', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
const equippedHelm = makeOnlineItem({ id: 401, code: 'cap', location: 1, bodyLoc: 1, container: 'equipment' })
const beltPot = makeOnlineItem({
id: 402,
code: 'hp1',
location: 2,
container: 'belt',
gridX: 0,
gridY: 0,
invWidth: 1,
invHeight: 1,
simple: true,
})
world.apply({ type: 'ItemEvent', action: 6, item: equippedHelm, container: 'equipment' })
world.apply({ type: 'ItemEvent', action: 0x0e, item: beltPot, container: 'belt' })
model.syncFromWorld()
model.swapEquipItem(500, 'helm')
model.swapContainerItem(500, 401, 2, 1, 0)
model.putBeltItem(501, 3)
model.removeBeltItem(402)
model.swapBeltItem(501, 402)
expect(model.useBeltSlot(0, true)).toBe(true)
model.identifyItem(601, 602)
model.socketItem(603, 604)
model.scrollToBook(605, 606)
expect(commands).toEqual([
{ type: 'ItemSwap', itemId: 500, slot: 1 },
{ type: 'SwapContainerItem', cursorItemId: 500, targetItemId: 401, replacedItemId: 401, x: 2, y: 1 },
{ type: 'BeltPut', itemId: 501, slot: 3 },
{ type: 'BeltRemove', itemId: 402 },
{ type: 'BeltSwap', cursorItemId: 501, beltItemId: 402 },
{ type: 'BeltUse', itemId: 402, useOnMerc: 1 },
{ type: 'IdentifyItem', itemId: 601, scrollId: 602 },
{ type: 'SocketItem', itemId: 603, targetItemId: 604 },
{ type: 'ScrollToBook', scrollId: 605, bookId: 606 },
])
// Verify HudManager click routing for identify scroll -> unidentified item, socket item, and scroll to tome
commands.length = 0
const idScroll = makeOnlineItem({
id: 701,
code: 'isc',
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
invWidth: 1,
invHeight: 1,
simple: true,
})
const unidRing = makeOnlineItem({
id: 702,
code: 'rin',
location: 0,
container: 'inventory',
gridX: 1,
gridY: 0,
invWidth: 1,
invHeight: 1,
quality: 4,
identified: false,
})
const idTome = makeOnlineItem({
id: 703,
code: 'ibk',
location: 0,
container: 'inventory',
gridX: 2,
gridY: 0,
invWidth: 1,
invHeight: 2,
simple: true,
})
const socketedCap = makeOnlineItem({
id: 704,
code: 'cap',
location: 0,
container: 'inventory',
gridX: 4,
gridY: 0,
invWidth: 2,
invHeight: 2,
socketed: true,
totalSockets: 2,
usedSockets: 0,
stats: [{ statId: D2StatId.SocketCount, param: 0, value: 2 }],
})
world.apply({ type: 'ItemEvent', action: 4, item: idScroll, container: 'inventory' })
world.apply({ type: 'ItemEvent', action: 4, item: unidRing, container: 'inventory' })
world.apply({ type: 'ItemEvent', action: 4, item: idTome, container: 'inventory' })
world.apply({ type: 'ItemEvent', action: 4, item: socketedCap, container: 'inventory' })
model.syncFromWorld()
hud.toggleRightPanel('inv')
// Right-click identify scroll at (0, 0), then left-click unidentified ring at (1, 0)
const cellX = (col: number) => INV_GRID_ORIGIN.x + col * INV_GRID_ORIGIN.cellPx + 10
const cellY = (row: number) => INV_GRID_ORIGIN.y + row * INV_GRID_ORIGIN.cellPx + 10
hud.handleClick(cellX(0), cellY(0), 2)
expect(hud.pendingIdentifyScrollId).toBe(701)
hud.handleClick(cellX(1), cellY(0), 0)
expect(hud.pendingIdentifyScrollId).toBeNull()
expect(commands).toContainEqual({ type: 'IdentifyItem', itemId: 702, scrollId: 701 })
// Cursor holds identify scroll (701) -> click on identify tome (703) at col 2, row 0
commands.length = 0
world.apply({ type: 'ItemEvent', action: 2, item: idScroll, container: 'cursor' })
model.syncFromWorld()
hud.handleClick(cellX(2), cellY(0), 0)
expect(commands).toEqual([{ type: 'ScrollToBook', scrollId: 701, bookId: 703 }])
// Cursor holds chipped amethyst (705) -> click on socketed cap (704) at col 4, row 0
commands.length = 0
world.apply({
type: 'ItemEvent',
action: 2,
item: makeOnlineItem({ id: 705, code: 'gcv', location: 4, container: 'cursor', invWidth: 1, invHeight: 1, simple: true }),
container: 'cursor',
})
model.syncFromWorld()
hud.handleClick(cellX(4), cellY(0), 0)
expect(commands).toEqual([{ type: 'SocketItem', itemId: 705, targetItemId: 704 }])
// Cursor holds cap (706) -> click on occupied helm slot
commands.length = 0
world.apply({
type: 'ItemEvent',
action: 2,
item: makeOnlineItem({ id: 706, code: 'cap', location: 4, container: 'cursor', invWidth: 2, invHeight: 2 }),
container: 'cursor',
})
model.syncFromWorld()
const helmRect = EQUIP_SLOTS_LAYOUT.helm
hud.handleClick(helmRect.x + 10, helmRect.y + 10, 0)
expect(commands).toEqual([{ type: 'ItemSwap', itemId: 706, slot: 1 }])
})
it('R2 dispatches NPC dialogue, travel, hire, resurrect merc, and quest reward menu actions', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 88, classId: 150, x: 5000, y: 5000 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 88 } })
model.syncFromWorld()
model.handleNpcMenuAction('talk', 88)
expect(commands).toContainEqual({ type: 'QuestMessage', npcId: 88, messageId: 0 })
commands.length = 0
model.handleNpcMenuAction('hire', 88)
expect(hud.leftPanel).toBe('hire')
expect(commands).toContainEqual({ type: 'NpcEntityAction', action: 3, npcId: 88, complement: 0 })
commands.length = 0
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 89, classId: 155, x: 5000, y: 5000 }, // Warriv Act I
})
model.handleNpcMenuAction('travel_east', 89)
expect(commands).toEqual([{ type: 'NpcEntityAction', action: 0, npcId: 89, complement: 40 }])
commands.length = 0
model.handleNpcMenuAction('resurrect_merc', 88)
expect(commands).toEqual([{ type: 'MercResurrect', npcId: 88 }])
commands.length = 0
model.handleNpcMenuAction('imbue', 88)
expect(hud.leftPanel).toBe('imbue')
model.confirmQuestReward(901)
expect(commands).toContainEqual({ type: 'NpcEntityAction', action: 0, npcId: 88, complement: 901 })
})
it('R3.2 & R3.3 syncs mercenary equipment, renders top-left portrait, and emits HireMerc/ResurrectMerc/MercItem', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
world.apply({
type: 'Merc',
kind: 'assign',
mercId: 50,
ownerId: 1,
classId: 338,
nameId: 1,
})
world.apply({
type: 'ItemEvent',
action: 6,
item: makeOnlineItem({ id: 801, code: 'cap', location: 1, bodyLoc: 1, ownerType: 1, container: 'merc' }),
container: 'merc',
})
world.apply({
type: 'ItemEvent',
action: 6,
item: makeOnlineItem({ id: 802, code: 'qui', location: 1, bodyLoc: 3, ownerType: 1, container: 'merc' }),
container: 'merc',
})
world.apply({
type: 'ItemEvent',
action: 6,
item: makeOnlineItem({ id: 803, code: 'spt', location: 1, bodyLoc: 4, ownerType: 1, container: 'merc' }),
container: 'merc',
})
model.syncFromWorld()
expect(hud.merc).not.toBeNull()
expect(hud.merc?.unitId).toBe(50)
expect(hud.merc?.equipment.helm?.id).toBe('801')
expect(hud.merc?.equipment.armor?.id).toBe('802')
expect(hud.merc?.equipment.weapon1?.id).toBe('803')
const ctx = { fillRect: vi.fn(), fillText: vi.fn() } as any
hud.ctx = ctx
hud.render()
expect(ctx.fillRect).toHaveBeenCalledWith(8, 8, 48, 48)
hud.handleKeyDown({ key: 'o', preventDefault: () => {} } as unknown as KeyboardEvent)
expect(hud.leftPanel).toBe('merc')
model.hireMerc(88, 2)
model.resurrectMerc(88)
model.mercItem(1)
expect(commands).toEqual([
{ type: 'MercHire', npcId: 88, mercId: 2 },
{ type: 'MercResurrect', npcId: 88 },
{ type: 'MercItem', slot: 1 },
])
})
it('R4.4 renders death overlay and sends Resurrect (0x41) on click or ESC', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
const ctx = {
save: vi.fn(),
restore: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
} as any
hud.ctx = ctx
world.self.isDead = true
model.syncFromWorld()
expect(hud.isDead).toBe(true)
const fontSpy = vi.spyOn(hud.font, 'drawText')
hud.render()
expect(fontSpy).toHaveBeenCalledWith(
ctx,
'YOU HAVE DIED',
400,
expect.any(Number),
expect.objectContaining({ color: 'red' }),
)
hud.handleClick(400, 300)
expect(commands).toEqual([{ type: 'Resurrect' }])
hud.handleKeyDown({ key: 'Escape', preventDefault: () => {} } as unknown as KeyboardEvent)
expect(commands).toHaveLength(2)
expect(commands[1]).toEqual({ type: 'Resurrect' })
})
it('R5.1, R5.2 & R5.3 syncs party & trade state, toggles Party panel (P), and emits PartyAction/Hostile/ClickButton', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
world.apply({
type: 'Party',
kind: 'roster',
unitId: 2,
charName: 'PaladinTwo',
charClass: 3,
charLevel: 20,
partyId: 1,
})
world.apply({ type: 'Trade', action: 0x01 })
world.apply({ type: 'Trade', action: 0x7901, gold: 500 })
world.apply({ type: 'Trade', action: 0x7900, gold: 1200 })
world.apply({
type: 'ItemEvent',
action: 4,
item: makeOnlineItem({ id: 901, code: 'rin', container: 'tradeSelf', gridX: 0, gridY: 0, invWidth: 1, invHeight: 1 }),
container: 'tradeSelf',
})
world.apply({
type: 'ItemEvent',
action: 4,
item: makeOnlineItem({ id: 902, code: 'amu', container: 'tradePartner', gridX: 1, gridY: 0, invWidth: 1, invHeight: 1 }),
container: 'tradePartner',
})
model.syncFromWorld()
expect(hud.partyMembers).toHaveLength(1)
expect(hud.partyMembers[0]?.name).toBe('PaladinTwo')
expect(hud.leftPanel).toBe('trade')
expect(hud.trade?.selfItems).toHaveLength(1)
expect(hud.trade?.partnerItems).toHaveLength(1)
// When trade ends, trade panel closes automatically
world.apply({ type: 'Trade', action: 0x0c })
model.syncFromWorld()
expect(hud.leftPanel).toBe('none')
hud.handleKeyDown({ key: 'p', preventDefault: () => {} } as unknown as KeyboardEvent)
expect(hud.leftPanel).toBe('party')
model.partyAction(0x06, 2)
model.setHostile(2, true)
model.clickButton(4, 0)
expect(commands).toEqual([
{ type: 'PartyAction', action: 0x06, targetPlayerId: 2 },
{ type: 'Hostile', targetPlayerId: 2, hostile: true, button: 1 },
{ type: 'ClickButton', button: 4, buttonId: 4, complement: 0, amount: 0 },
])
})
})