diablo2-web/tests/client/vendor-stash-ui.test.ts

663 lines
26 KiB
TypeScript

/**
* Server-driven store / stash / gold UI (D2Client 1.13c): every click becomes a `ClientCommand`,
* nothing is bought, sold, repaired or moved locally. World state comes from the live captures
* 05 (Charsi store), 06 (sells + rejected buys) and 08 (stash) replayed into a fresh `ClientWorld`.
*/
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { replayD2CapFile } from '../../tools/netproto-replay.ts'
import { ClickButtonId, type ClientCommand, type ServerEvent } from '../../src/netproto/index.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { D2StatId } from '../../src/client/world/stat-list.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import {
STASH_GOLD_BTN_HIT,
VENDOR_BUTTON_TABLE_X,
VENDOR_GRID_ORIGIN,
WorldPanelsHud,
buildNpcMenuOptions,
} from '../../src/client/ui/world-panels.ts'
import { getTownNpcDescriptorByHcIdx } from '../../src/common/units/npc-table.ts'
import { requireTbl } from '../../src/client/i18n/lang.ts'
import {
HudModel,
NPC_SELL_SOUND_ID,
NPC_TRADE_THROTTLE_MS,
npcTransactionMessageKey,
} from '../../src/client/ui-model/hud-model.ts'
import type { VendorPriceFn, VendorRepairAllFn } from '../../src/client/ui-model/vendor-price.ts'
if (typeof globalThis.window === 'undefined') {
;(globalThis as unknown as { window: Record<string, unknown> }).window = {
addEventListener: () => {},
removeEventListener: () => {},
}
}
const FIXTURES = path.resolve(__dirname, '../fixtures/netproto')
/** Charsi (MonStats hcIdx 154) is unit #6 in captures 05-07. */
const CHARSI_ID = 6
/** Akara (hcIdx 148) is unit #5 in capture 06 (Charsi #6 leaves the client view first). */
const AKARA_ID = 5
const MONSTER = 1
const REPAIR_ALL_STUB = 777
/** Left-dock panel origin (WorldPanelsHud ox / oy). */
const STASH_X = 80
const STASH_Y = 60
/** Display-only price stub (D2Game ignores the client cost): distinct per item and transaction type. */
const stubPrice: VendorPriceFn = (item, type) => 1000 * (type + 1) + item.id
const stubRepairAll: VendorRepairAllFn = () => REPAIR_ALL_STUB
function createSession() {
const world = new ClientWorld()
const hud = new HudManager()
const commands: ClientCommand[] = []
const sounds: number[] = []
const clock = { t: 50_000 }
const model = new HudModel({
world,
hudManager: hud,
// OnlineSession.forwardCommandToAdapter notes every sent command on the world.
onCommand: (c) => {
commands.push(c)
world.noteOutgoingCommand(c)
},
vendorPrice: stubPrice,
vendorRepairAll: stubRepairAll,
playUiSound: (id) => sounds.push(id),
now: () => clock.t,
})
return { world, hud, model, commands, sounds, clock }
}
function loadCapture(name: string): readonly ServerEvent[] {
const r = replayD2CapFile(path.join(FIXTURES, name), { strict: true })
expect(r.unknownPackets).toBe(0)
return r.events
}
const isShopItem = (e: ServerEvent): boolean => e.type === 'ItemEvent' && e.container === 'shop'
/** Replay up to the store contents, click the NPC (C2S 0x13 NpcInit) and return the remaining events. */
function walkUpToNpc(s: ReturnType<typeof createSession>, events: readonly ServerEvent[], npcId: number): ServerEvent[] {
const firstShop = events.findIndex(isShopItem)
expect(firstShop).toBeGreaterThan(0)
for (const e of events.slice(0, firstShop)) s.world.apply(e)
s.model.syncFromWorld()
s.world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: MONSTER, id: npcId } })
s.model.syncFromWorld()
return events.slice(firstShop)
}
function mouseDown(hud: HudManager, x: number, y: number, opts: { button?: number; shiftKey?: boolean } = {}): void {
hud.handleHudMouseDown({
clientX: x,
clientY: y,
button: opts.button ?? 0,
shiftKey: opts.shiftKey ?? false,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as MouseEvent)
}
function clickNpcOption(hud: HudManager, optionId: string): void {
const menu = hud.worldPanels.npcMenu
if (!menu?.options) throw new Error('no NPC menu')
const idx = menu.options.findIndex(
(o) => o.id === optionId || (optionId === 'trade' && o.id === 'trade_repair') || (optionId === 'trade_repair' && o.id === 'trade'),
)
expect(idx).toBeGreaterThanOrEqual(0)
const layout = hud.worldPanels.getNpcMenuLayout(hud.font)!
mouseDown(hud, layout.left + Math.trunc(layout.width / 2), layout.optionYs[idx]! - 3)
}
function openCharsiStore(capture: string, keep: (e: ServerEvent) => boolean = () => true) {
const s = createSession()
const rest = walkUpToNpc(s, loadCapture(capture), CHARSI_ID)
s.model.openNpcTrade(CHARSI_ID)
for (const e of rest) if (keep(e)) s.world.apply(e)
s.model.syncFromWorld()
return s
}
describe('NPC menu -> server store', () => {
it('NpcInit opens the descriptor menu; "trade/repair" sends 0x38 and the 0x9C store fills the panel with display prices', () => {
const s = createSession()
const rest = walkUpToNpc(s, loadCapture('05-d2gs-vendor-open.d2cap'), CHARSI_ID)
const menu = s.hud.worldPanels.npcMenu
expect(menu?.npcId).toBe(CHARSI_ID)
expect(menu?.options?.some((o) => o.id === 'trade' || o.id === 'trade_repair')).toBe(true)
clickNpcOption(s.hud, 'trade')
expect(s.commands).toEqual([{ type: 'NpcOpenTrade', npcId: CHARSI_ID }])
expect(s.hud.leftPanel).toBe('vendor')
expect(s.world.npcInteraction.window).toBe('trade')
for (const e of rest) s.world.apply(e)
s.model.syncFromWorld()
const wp = s.hud.worldPanels
const shown = Object.values(wp.vendorTabPlacements).flat()
expect(shown.length).toBe(s.world.containers.shop.size)
expect(shown.length).toBe(46)
for (const pl of shown) {
const raw = s.world.containers.shop.get(Number(pl.item.id))!
expect(pl.item.buyCost).toBe(stubPrice(raw, 0, { world: s.world, npcClass: 154 }))
expect(pl.col).toBe(raw.gridX)
expect(pl.row).toBe(raw.gridY)
}
expect(wp.vendorRepairAllCost).toBe(REPAIR_ALL_STUB)
// Player items carry the sell value (type 1) and, at a smith, the repair cost (type 3).
for (const pl of s.hud.inventory.gridItems) {
expect(pl.item.sellCost).toBe(1000 * 2 + Number(pl.item.id))
}
const axeRaw = [...s.world.containers.equipment.values()].find((i) => i.code === 'hax')!
const axe = s.hud.inventory.equipped.weapon1
expect(axe?.id).toBe(String(axeRaw.id))
expect(axe?.repairCost).toBe(1000 * 4 + axeRaw.id)
// Closing the panel sends 0x30 and frees the store locally (no RemoveFromShop arrives).
s.hud.closeAllPanels()
expect(s.commands.at(-1)).toEqual({ type: 'NpcCancel', target: { unitType: MONSTER, id: CHARSI_ID } })
expect(s.world.containers.shop.size).toBe(0)
expect(s.hud.leftPanel).toBe('none')
expect(wp.activeVendorDescriptor).toBeNull()
})
it('"cancel" and clicks outside the menu send NpcCancel', () => {
const s = createSession()
walkUpToNpc(s, loadCapture('05-d2gs-vendor-open.d2cap'), CHARSI_ID)
clickNpcOption(s.hud, 'cancel')
expect(s.commands).toEqual([{ type: 'NpcCancel', target: { unitType: MONSTER, id: CHARSI_ID } }])
expect(s.hud.worldPanels.npcMenu).toBeNull()
s.world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: MONSTER, id: CHARSI_ID } })
s.model.syncFromWorld()
expect(s.hud.worldPanels.npcMenu?.npcId).toBe(CHARSI_ID)
mouseDown(s.hud, 20, 20)
expect(s.commands.at(-1)).toEqual({ type: 'NpcCancel', target: { unitType: MONSTER, id: CHARSI_ID } })
expect(s.hud.worldPanels.npcMenu).toBeNull()
})
it('Charsi does not gamble: openNpcGamble fails fast', () => {
const s = createSession()
walkUpToNpc(s, loadCapture('05-d2gs-vendor-open.d2cap'), CHARSI_ID)
expect(() => s.model.openNpcGamble(CHARSI_ID)).toThrow(/does not gamble/)
expect(s.commands).toEqual([])
})
it('a store needs price providers: without them openNpcTrade throws before sending', () => {
const world = new ClientWorld()
const commands: ClientCommand[] = []
const model = new HudModel({ world, hudManager: new HudManager(), onCommand: (c) => commands.push(c) })
expect(() => model.openNpcTrade(CHARSI_ID)).toThrow(/vendorPrice/)
expect(commands).toEqual([])
})
})
describe('store actions are commands only', () => {
it('buy / shift-buy click the store grid -> 0x32 with itemMode 0 and the #10107 price, throttled to one per 2000 ms', () => {
const s = openCharsiStore('05-d2gs-vendor-open.d2cap')
const before = s.commands.length
const pl = s.hud.worldPanels.getActiveVendorPlacements()[0]!
const x = VENDOR_GRID_ORIGIN.x + pl.col * VENDOR_GRID_ORIGIN.cellPx + 5
const y = VENDOR_GRID_ORIGIN.y + pl.row * VENDOR_GRID_ORIGIN.cellPx + 5
const itemId = Number(pl.item.id)
const raw = s.world.containers.shop.get(itemId)!
const shopSize = s.world.containers.shop.size
const gold = s.world.self.gold
mouseDown(s.hud, x, y)
expect(s.commands.slice(before)).toEqual([
{ type: 'NpcBuy', npcId: CHARSI_ID, itemId, itemMode: 0, gamble: false, shiftFill: false, cost: stubPrice(raw, 0, { world: s.world, npcClass: 154 }) },
])
// Second click inside the throttle window is dropped.
mouseDown(s.hud, x, y, { shiftKey: true })
expect(s.commands.length).toBe(before + 1)
s.clock.t += NPC_TRADE_THROTTLE_MS
mouseDown(s.hud, x, y, { shiftKey: true })
expect(s.commands.at(-1)).toMatchObject({ type: 'NpcBuy', itemId, shiftFill: true, gamble: false })
// Nothing changed locally: the server answers with 0x2A / 0x9C / stat 14.
expect(s.world.containers.shop.size).toBe(shopSize)
expect(s.world.self.gold).toBe(gold)
expect(s.hud.inventory.cursorItem).toBeNull()
})
it('sell / repair / repair-all send 0x33 / 0x35 with the current durability; repair and repair-all share one throttle', () => {
const s = openCharsiStore('05-d2gs-vendor-open.d2cap')
const before = s.commands.length
const scroll = [...s.world.containers.inventory.values()].find((i) => i.code === 'tsc')!
const axe = [...s.world.containers.equipment.values()].find((i) => i.code === 'hax')!
expect(s.model.vendorSell(scroll.id)).toBe(true)
expect(s.model.vendorSell(scroll.id)).toBe(false)
expect(s.model.vendorRepairItem(axe.id)).toBe(true)
expect(s.model.vendorRepairAll()).toBe(false)
s.clock.t += NPC_TRADE_THROTTLE_MS
expect(s.model.vendorRepairAll()).toBe(true)
expect(s.commands.slice(before)).toEqual([
{ type: 'NpcSell', npcId: CHARSI_ID, itemId: scroll.id, itemMode: 0, cost: stubPrice(scroll, 1, { world: s.world, npcClass: 154 }) },
{ type: 'NpcRepairItem', npcId: CHARSI_ID, itemId: axe.id, itemMode: 0, durability: 28 },
{ type: 'NpcRepairAll', npcId: CHARSI_ID },
])
expect(s.world.containers.inventory.has(scroll.id)).toBe(true)
expect(s.world.containers.equipment.get(axe.id)?.curDurability).toBe(28)
})
it('Repair All button and holding a cursor item over the store grid go through the command sink', () => {
const s = openCharsiStore('05-d2gs-vendor-open.d2cap')
const before = s.commands.length
const wp = s.hud.worldPanels
// Charsi button table [buy, sell, repair, repairAll]; slot 3 = Repair All.
wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, {
onClose: () => {},
onWaypointTeleport: () => {},
onVendorRepairAll: () => s.model.vendorRepairAll(),
})
expect(s.commands.slice(before)).toEqual([{ type: 'NpcRepairAll', npcId: CHARSI_ID }])
})
it('a store action with no store open fails fast', () => {
const s = createSession()
expect(() => s.model.vendorBuy(1, false)).toThrow(/no store window/)
expect(() => s.model.vendorRepairAll()).toThrow(/no store window/)
})
})
describe('S2C 0x2A NpcTransaction feedback', () => {
it('successful sells (result 1) play UI sound 0xdd; rejected buys (result 12) show TransactionResults10 (tbl 3362)', () => {
const s = createSession()
const rest = walkUpToNpc(s, loadCapture('06-d2gs-vendor-buy-sell.d2cap'), AKARA_ID)
s.model.openNpcTrade(AKARA_ID)
expect(s.hud.worldPanels.vendorRepairAllCost).toBeNull()
for (const e of rest) {
s.world.apply(e)
s.model.syncFromWorld()
}
expect(s.sounds).toEqual([NPC_SELL_SOUND_ID, NPC_SELL_SOUND_ID])
expect(s.model.lastNpcTransactionMessage).toEqual({ tblKey: 'TransactionResults10', text: requireTbl('TransactionResults10', 'CHI') })
// Gold comes only from stat 14 (2 gold after the two sells).
expect(s.hud.inventory.gold).toBe(s.world.self.gold)
})
})
describe('NPC store tbl strings', () => {
it('every 0x2A message key and store/gold-dialog key is packed in both languages', () => {
const keys = new Set<string>()
for (let result = 0; result <= 20; result++) {
const k = npcTransactionMessageKey(result)
if (k !== null) keys.add(k)
}
expect([...keys].sort()).toEqual([
'TransactionResults10',
'TransactionResults11',
'TransactionResults4',
'TransactionResults5',
'TransactionResults6',
'TransactionResults8',
'TransactionResults9',
])
for (const k of [...keys, 'cost', 'Repair', 'Sell', 'ok', 'Back', 'strDropGoldHowMuch', 'strBankGoldDeposit', 'strBankGoldWithdraw']) {
expect(requireTbl(k, 'ENG').length).toBeGreaterThan(0)
expect(requireTbl(k, 'CHI').length).toBeGreaterThan(0)
}
expect(requireTbl('patchstring#95', 'ENG')).toBe('Repair all equipment: %d')
expect(requireTbl('patchstring#95', 'CHI')).toBe('修復所有裝備:%d')
expect(() => requireTbl('notAPackedKey', 'ENG')).toThrow(/not packed/)
})
})
describe('stash and gold dialog', () => {
function openStash() {
const s = createSession()
for (const e of loadCapture('08-d2gs-stash-gold.d2cap')) s.world.apply(e)
s.model.syncFromWorld()
expect(s.hud.leftPanel).toBe('stash')
s.world.apply({ type: 'StatSet', stat: D2StatId.Gold, value: 5000 })
s.world.apply({ type: 'StatSet', stat: D2StatId.GoldBank, value: 1200 })
s.model.syncFromWorld()
expect(s.hud.inventory.gold).toBe(5000)
expect(s.hud.worldPanels.stashGold).toBe(1200)
return s
}
it('deposit (mode 3) is pre-filled with the bag gold; typed digits replace it; ok sends 0x4F 0x14', () => {
const s = openStash()
s.hud.openGoldDialog(3)
expect(s.hud.goldDialog).toEqual({ mode: 3, amount: 5000, fresh: true })
s.hud.typeGoldDialogKey('7')
s.hud.typeGoldDialogKey('5')
s.hud.confirmGoldDialog()
expect(s.commands).toEqual([{ type: 'ClickButton', buttonId: ClickButtonId.DepositGold, amount: 75 }])
expect(s.hud.inventory.gold).toBe(5000)
expect(s.hud.worldPanels.stashGold).toBe(1200)
})
it('withdraw (mode 4) clamps to the stash balance; 0 sends nothing; drop (mode 0) sends 0x15 DropGold', () => {
const s = openStash()
s.hud.openGoldDialog(4)
s.hud.setGoldDialogAmount(99_999)
expect(s.hud.goldDialog?.amount).toBe(1200)
s.hud.confirmGoldDialog()
s.hud.openGoldDialog(4)
s.hud.setGoldDialogAmount(0)
s.hud.confirmGoldDialog()
s.hud.openGoldDialog(0)
s.hud.setGoldDialogAmount(10)
s.hud.confirmGoldDialog()
expect(s.commands).toEqual([
{ type: 'ClickButton', buttonId: ClickButtonId.WithdrawGold, amount: 1200 },
{ type: 'DropGold', unitId: s.world.selfId, amount: 10 },
])
expect(s.hud.goldDialog).toBeNull()
})
it('the stash gold button opens the withdraw dialog; cancel sends nothing; closing the stash sends 0x4F 0x12', () => {
const s = openStash()
s.hud.worldPanels.handleLeftDockClick('stash', STASH_X + STASH_GOLD_BTN_HIT.relX0 + 10, STASH_Y + STASH_GOLD_BTN_HIT.relY0 + 5, {
onClose: () => {},
onWaypointTeleport: () => {},
onStashGoldButton: () => s.hud.openGoldDialog(4),
})
expect(s.hud.goldDialog?.mode).toBe(4)
s.hud.cancelGoldDialog()
expect(s.commands).toEqual([])
s.hud.closeAllPanels()
expect(s.commands).toEqual([{ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 }])
expect(s.world.npcInteraction.stashOpen).toBe(false)
expect(() => s.hud.openGoldDialog(3)).toThrow(/stash open/)
})
})
function createMockCanvasContext(): CanvasRenderingContext2D & {
drawnTexts: string[]
fillRectCount: number
strokeRectCount: number
} {
const drawnTexts: string[] = []
let fillRectCount = 0
let strokeRectCount = 0
return {
drawnTexts,
get fillRectCount() {
return fillRectCount
},
get strokeRectCount() {
return strokeRectCount
},
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
font: '12px sans-serif',
textAlign: 'left',
textBaseline: 'alphabetic',
fillRect: () => {
fillRectCount++
},
strokeRect: () => {
strokeRectCount++
},
clearRect: () => {},
fillText: (text: string) => {
drawnTexts.push(String(text))
},
measureText: (text: string) => ({ width: text.length * 8 } as TextMetrics),
drawImage: () => {},
beginPath: () => {},
closePath: () => {},
moveTo: () => {},
lineTo: () => {},
stroke: () => {},
fill: () => {},
save: () => {},
restore: () => {},
} as unknown as CanvasRenderingContext2D & {
drawnTexts: string[]
fillRectCount: number
strokeRectCount: number
}
}
describe('M3B: NPC dialogue, travel, mercenary, party, trade, and quest reward panels', () => {
it('buildNpcMenuOptions produces full 1.13c options for hirelings, travel NPCs, vendors, and quest rewards', () => {
const kashya = getTownNpcDescriptorByHcIdx(150)!
expect(buildNpcMenuOptions(kashya, { hasDeadMercenary: false }).map((o) => o.id)).toEqual([
'talk',
'hire',
'cancel',
])
const kashyaDeadMerc = buildNpcMenuOptions(kashya, {
hasDeadMercenary: true,
deadMercenaryReviveCost: 1500,
})
expect(kashyaDeadMerc.map((o) => o.id)).toEqual(['talk', 'hire', 'resurrect', 'cancel'])
expect(kashyaDeadMerc.find((o) => o.id === 'resurrect')?.labelEn).toContain('1500')
const warrivAct1 = getTownNpcDescriptorByHcIdx(155)!
expect(buildNpcMenuOptions(warrivAct1).map((o) => o.id)).toEqual(['talk', 'travel_east', 'cancel'])
const warrivAct2 = getTownNpcDescriptorByHcIdx(175)!
expect(buildNpcMenuOptions(warrivAct2).map((o) => o.id)).toEqual(['talk', 'travel_west', 'cancel'])
const meshifAct2 = getTownNpcDescriptorByHcIdx(210)!
expect(buildNpcMenuOptions(meshifAct2).map((o) => o.id)).toEqual(['talk', 'travel_east', 'cancel'])
const meshifAct3 = getTownNpcDescriptorByHcIdx(258)!
expect(buildNpcMenuOptions(meshifAct3).map((o) => o.id)).toEqual(['talk', 'travel_west', 'cancel'])
const tyrael = getTownNpcDescriptorByHcIdx(367)!
expect(buildNpcMenuOptions(tyrael).map((o) => o.id)).toEqual(['talk', 'travel_harrogath', 'cancel'])
const charsi = getTownNpcDescriptorByHcIdx(154)!
expect(buildNpcMenuOptions(charsi).map((o) => o.id)).toEqual([
'talk',
'trade_repair',
'imbue',
'cancel',
])
const larzuk = getTownNpcDescriptorByHcIdx(511)!
expect(buildNpcMenuOptions(larzuk).map((o) => o.id)).toEqual([
'talk',
'trade_repair',
'add_sockets',
'cancel',
])
const anya = getTownNpcDescriptorByHcIdx(512)!
expect(buildNpcMenuOptions(anya).map((o) => o.id)).toEqual([
'talk',
'trade',
'gamble',
'personalize',
'cancel',
])
const cain = getTownNpcDescriptorByHcIdx(244)!
expect(buildNpcMenuOptions(cain).map((o) => o.id)).toEqual(['talk', 'identify', 'cancel'])
})
it('clickNpcMenuOption dispatches talk, hire, resurrect_merc, travel_east, and quest reward options; setLanguage toggles speech', () => {
const wp = new WorldPanelsHud()
const questMessages: Array<{ npcUnitId: number; messageIndex: number }> = []
const entityActions: Array<{ actionType: number; npcId: number; param: number | undefined }> = []
const resurrectedNpcs: number[] = []
const confirmedRewards: number[] = []
wp.onQuestMessage = (npcUnitId, messageIndex) => questMessages.push({ npcUnitId, messageIndex })
wp.onNpcEntityAction = (actionType, npcId, param) => entityActions.push({ actionType, npcId, param })
wp.onResurrectMerc = (npcId) => resurrectedNpcs.push(npcId)
wp.onConfirmQuestReward = (itemId) => confirmedRewards.push(itemId)
// 1. Talk + language toggle ('zh' <-> 'en')
const warriv = getTownNpcDescriptorByHcIdx(155)!
wp.setLanguage('zh')
wp.openNpcMenu(101, warriv)
expect(wp.clickNpcMenuOption('talk')).toBe(true)
expect(questMessages).toEqual([{ npcUnitId: 101, messageIndex: 0 }])
const zhLines = wp.npcSpeech?.lines
expect(zhLines?.length).toBeGreaterThan(0)
wp.setLanguage('en')
expect(wp.language).toBe('en')
expect(wp.npcSpeech?.lines).toEqual(warriv.greetingLines)
wp.setLanguage('zh')
expect(wp.language).toBe('zh')
expect(wp.npcSpeech?.lines).toEqual(zhLines)
// 2. Travel to Act II via Warriv
wp.openNpcMenu(101, warriv)
expect(wp.clickNpcMenuOption('travel_east')).toBe(true)
expect(entityActions.at(-1)).toEqual({ actionType: 0, npcId: 101, param: 40 })
expect(wp.npcMenu).toBeNull()
// 3. Hire and Resurrect Mercenary via Kashya
const kashya = getTownNpcDescriptorByHcIdx(150)!
wp.openNpcMenu(202, kashya, { hasDeadMercenary: true, deadMercenaryReviveCost: 900 })
expect(wp.clickNpcMenuOption('hire')).toBe(true)
expect(wp.activePanel).toBe('hire')
expect(entityActions.at(-1)).toEqual({ actionType: 3, npcId: 202, param: 0 })
wp.openNpcMenu(202, kashya, { hasDeadMercenary: true, deadMercenaryReviveCost: 900 })
expect(wp.clickNpcMenuOption('resurrect_merc')).toBe(true)
expect(resurrectedNpcs).toEqual([202])
// 4. Quest reward panels ('imbue', 'add_sockets', 'personalize') + confirmQuestReward
const charsi = getTownNpcDescriptorByHcIdx(154)!
wp.openNpcMenu(303, charsi)
expect(wp.clickNpcMenuOption('imbue')).toBe(true)
expect(wp.activePanel).toBe('imbue')
wp.questRewardItem = { id: '77', code: 'cap', name: 'Cap' } as any
expect(wp.confirmQuestReward('imbue', 77)).toBe(true)
expect(confirmedRewards).toEqual([77])
expect(wp.questRewardItem).toBeNull()
expect(wp.activePanel).toBe('none')
})
it('renderActiveLeftPanel and render draw merc, hire, party, trade, and quest reward panels without throwing', () => {
const wp = new WorldPanelsHud()
wp.setLanguage('en')
// 1. Mercenary paperdoll & stats panel ('merc')
wp.merc = {
unitId: 501,
hcIdx: 271,
name: 'amplisa',
level: 12,
experience: 14500,
life: 180,
maxLife: 200,
strength: 52,
dexterity: 68,
minDamage: 14,
maxDamage: 32,
defense: 95,
fireResist: 35,
coldResist: 35,
lightResist: 35,
poisonResist: 35,
alive: true,
reviveCost: 0,
headItem: { itemId: 1, code: 'cap', name: 'Tarnhelm' },
bodyItem: { itemId: 2, code: 'qui', name: 'Stealth Quilted Armor' },
weaponItem: { itemId: 3, code: 'sbw', name: 'Zephyr Short Bow' },
shieldItem: null,
}
wp.activePanel = 'merc'
const mercCtx = createMockCanvasContext()
wp.renderActiveLeftPanel(mercCtx)
expect(mercCtx.fillRectCount).toBeGreaterThan(0)
expect(mercCtx.drawnTexts.some((t) => t.includes('amplisa'))).toBe(true)
expect(mercCtx.drawnTexts.some((t) => t.includes('Tarnhelm'))).toBe(true)
// 2. Mercenary hire list panel ('hire')
wp.mercHireList = [
{
mercId: 1,
nameId: 10,
name: 'Aliza',
hcIdx: 271,
level: 8,
cost: 1050,
life: 140,
strength: 44,
dexterity: 55,
damageMin: 8,
damageMax: 18,
defense: 60,
fireResist: 20,
coldResist: 20,
lightResist: 20,
poisonResist: 20,
},
]
wp.activePanel = 'hire'
const hireCtx = createMockCanvasContext()
wp.renderActiveLeftPanel(hireCtx)
expect(hireCtx.fillRectCount).toBeGreaterThan(0)
expect(hireCtx.drawnTexts.some((t) => t.includes('Aliza'))).toBe(true)
expect(hireCtx.drawnTexts.some((t) => t.includes('1050 Gold'))).toBe(true)
// 3. Party screen panel ('party')
wp.partyMembers = [
{
unitId: 2,
name: 'SorceressTwo',
charClass: 1,
level: 24,
partyId: 1,
areaId: 1,
lifePct: 100,
x: 5100,
y: 5050,
relationship: 'in_party',
hostile: false,
lootPermitted: true,
},
]
wp.activePanel = 'party'
const partyCtx = createMockCanvasContext()
wp.renderActiveLeftPanel(partyCtx)
expect(partyCtx.fillRectCount).toBeGreaterThan(0)
expect(partyCtx.drawnTexts.some((t) => t.includes('SorceressTwo'))).toBe(true)
// 4. Player trade panel ('trade')
wp.trade = {
isOpen: true,
partnerUnitId: 2,
partnerName: 'PaladinTrader',
myGold: 2500,
partnerGold: 5000,
myAccepted: true,
partnerAccepted: false,
acceptDisabledTicks: 0,
myItems: [{ itemId: 11, code: 'rin', name: 'Stone of Jordan', gridX: 0, gridY: 0, width: 1, height: 1 }],
partnerItems: [{ itemId: 12, code: 'amu', name: 'Mara Kaleidoscope', gridX: 1, gridY: 0, width: 1, height: 1 }],
}
wp.activePanel = 'trade'
const tradeCtx = createMockCanvasContext()
wp.renderActiveLeftPanel(tradeCtx)
expect(tradeCtx.fillRectCount).toBeGreaterThan(0)
expect(tradeCtx.drawnTexts.some((t) => t.includes('PaladinTrader'))).toBe(true)
expect(tradeCtx.drawnTexts.some((t) => t.includes('Stone of Jordan'))).toBe(true)
expect(tradeCtx.drawnTexts.some((t) => t.includes('Mara Kaleidoscope'))).toBe(true)
// 5. Quest reward panels ('imbue', 'add_sockets', 'personalize') + unified render & snapshot
wp.questRewardItem = { id: '88', code: 'ci3', name: 'Diadem' } as any
for (const rewardPanel of ['imbue', 'add_sockets', 'personalize'] as const) {
wp.activePanel = rewardPanel
const qCtx = createMockCanvasContext()
wp.render(qCtx)
expect(qCtx.fillRectCount).toBeGreaterThan(0)
expect(qCtx.drawnTexts.some((t) => t.includes('Diadem'))).toBe(true)
expect(wp.snapshot.activePanel).toBe(rewardPanel)
}
})
})