598 lines
22 KiB
TypeScript
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 },
|
|
])
|
|
})
|
|
})
|
|
|