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

358 lines
16 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 } from '../../src/client/ui/world-panels.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'
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)
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?.map((o) => o.id)).toContain('trade')
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/)
})
})