663 lines
26 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
})
|
|
|
|
|