diff --git a/src/baker/packers/pack-canonical-tbl.ts b/src/baker/packers/pack-canonical-tbl.ts index 4bcbc91..a800b45 100644 --- a/src/baker/packers/pack-canonical-tbl.ts +++ b/src/baker/packers/pack-canonical-tbl.ts @@ -377,6 +377,22 @@ async function main() { 'bank', 'stash', 'strGoldInStash', + // NPC store / stash / gold dialog (D2Client store tooltips, 0x2A NPCTransaction replies, gold dialog) + 'cost', + 'Repair', + 'Sell', + 'TransactionResults4', + 'TransactionResults5', + 'TransactionResults6', + 'TransactionResults8', + 'TransactionResults9', + 'TransactionResults10', + 'TransactionResults11', + 'ok', + 'strDropGoldHowMuch', + 'strBankGoldDeposit', + 'strBankGoldWithdraw', + 'Back', 'box', 'strUiMenu2', 'strClose', @@ -524,6 +540,20 @@ async function main() { } chiOut['strethereal'] = EXPANSION_CHI_FALLBACKS['strethereal']! + // Index-only patchstring.tbl entries. Their real key is the placeholder "x", shared by ~100 patchstring + // entries (decodeTbl keeps the first duplicate), so D2Client looks them up by string index instead + // (patchstring index n = global tbl index 10000 + n). They are emitted from the MPQ under the alias + // key `patchstring#`. + // - 95: Repair All button hover text "Repair all equipment: %d" (D2Client string index 10095) + for (const idx of [95]) { + const alias = `patchstring#${idx}` + const ev = eng.tables[0]?.[idx] + const cv = chi.tables[0]?.[idx] + if (ev === undefined || cv === undefined) throw new Error(`patchstring.tbl index ${idx} missing (ENG or CHI)`) + engOut[alias] = ev + chiOut[alias] = cv + } + const outPath = path.join(process.cwd(), 'src/common/data/canonical-tbl-strings.ts') const fileContent = `/** * AUTO-GENERATED by \`src/baker/packers/pack-canonical-tbl.ts\` from authoritative 1.13c MPQ archives: diff --git a/src/client/i18n/lang.ts b/src/client/i18n/lang.ts index a91dab5..19c2e10 100644 --- a/src/client/i18n/lang.ts +++ b/src/client/i18n/lang.ts @@ -19,6 +19,13 @@ export function lookupTbl(key: string, lang: TblLang = getTblLang()): string | u return resolveTblKey(key, lang) } +/** Packed tbl string for `key`; throws when the key is not in the packed canonical dictionary. */ +export function requireTbl(key: string, lang: TblLang): string { + const v = resolveTblKey(key, lang) + if (v === undefined) throw new Error(`tbl key "${key}" is not packed (src/baker/packers/pack-canonical-tbl.ts)`) + return v +} + let overrideLang: GameLang | null = null const listeners = new Set<(lang: TblLang) => void>() const gameLangListeners = new Set<(lang: GameLang) => void>() diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index fd75142..2c29fe7 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -57,6 +57,7 @@ import { type SettingsStore, } from '../settings/settings-store.ts' import { HudModel } from '../ui-model/hud-model.ts' +import { vendorPrice113c, vendorRepairAll113c } from '../ui-model/vendor-price.ts' import { ClientWorldSceneSource } from '../view/scene-source.ts' import type { RenderableUnitInput } from '../view/unit-drawables.ts' import { getViewportProfile } from '../view/viewport-profile.ts' @@ -249,6 +250,11 @@ export class OnlineSession { onCommand: (cmd) => { this.dispatchCommand(cmd) }, + vendorPrice: vendorPrice113c, + vendorRepairAll: vendorRepairAll113c, + playUiSound: (soundId) => { + this.soundService.playSoundById(soundId) + }, }) this.commandMapper = new CommandMapper({ diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 3c9915a..0d18b26 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -33,11 +33,14 @@ import { type UiInventoryItem, type UiItemQuality, } from '../ui/inventory.ts' -import type { MonsterTargetHudInfo } from '../ui/world-panels.ts' +import { GAMBLE_NPC_CLASS_IDS, type MonsterTargetHudInfo } from '../ui/world-panels.ts' +import { requireTbl } from '../i18n/lang.ts' +import { getTownNpcDescriptorByHcIdx, type TownNpcServiceDescriptor } from '../../common/units/npc-table.ts' +import type { VendorPriceContext, VendorPriceFn, VendorRepairAllFn } from './vendor-price.ts' import type { AutomapView } from '../automap/automap-view.ts' import type { LocalMovementPredictor } from '../motion/predictor.ts' import type { ClientUnit } from '../world/client-unit.ts' -import type { ClientWorld } from '../world/client-world.ts' +import type { ClientWorld, NpcInteractionState } from '../world/client-world.ts' import { D2StatId } from '../world/stat-list.ts' import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts' import { computeCharacterSheetDerived } from './derived-stats.ts' @@ -173,8 +176,55 @@ export interface HudModelOptions { readonly predictor?: LocalMovementPredictor | null | undefined readonly automap?: AutomapView | null | undefined readonly onCommand?: ((cmd: ClientCommand) => void) | undefined + /** Store price provider (D2Common #10107); required before a store opens. Production: `vendorPrice113c`. */ + readonly vendorPrice?: VendorPriceFn | undefined + /** Repair-all cost provider (D2Common #10071); required before a store opens. Production: `vendorRepairAll113c`. */ + readonly vendorRepairAll?: VendorRepairAllFn | undefined + /** UI sound sink (sell sound 0xdd); required before a store opens. */ + readonly playUiSound?: ((soundId: number) => void) | undefined + /** Clock for the store click throttle (ms); defaults to `performance.now`. */ + readonly now?: (() => number) | undefined } +/** D2Client store click throttle: one buy / sell / repair request per action every 2000 ms. */ +export const NPC_TRADE_THROTTLE_MS = 2000 + +/** + * S2C `0x2A` NPCTransaction result -> `string.tbl` key of the message D2Client shows (indices 3356..3363). + * Results 0 (buy), 1 (sell) and 2 (repair all) succeed silently; any other unknown code shows TransactionResults9. + */ +export function npcTransactionMessageKey(result: number): string | null { + switch (result) { + case 0: + case 1: + case 2: + return null + case 7: + case 8: + return 'TransactionResults6' // 3358 + case 10: + return 'TransactionResults4' // 3356 + case 11: + return 'TransactionResults8' // 3360 + case 12: + return 'TransactionResults10' // 3362 + case 15: + return 'TransactionResults11' // 3363 + case 16: + return 'TransactionResults5' // 3357 + default: + return 'TransactionResults9' // 3361 + } +} + +/** Sound D2Client plays on a successful sell (S2C 0x2A result 1). */ +export const NPC_SELL_SOUND_ID = 0xdd + +/** `UnitType` id of monsters / NPCs in `UnitRef`. */ +const UNIT_TYPE_MONSTER = 1 + +const NPC_STATE_CLOSED: NpcInteractionState = { npcId: null, window: 'none', stashOpen: false } + export interface HudModelSnapshot { readonly charName: string /** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */ @@ -224,6 +274,17 @@ export class HudModel implements HudCommandSink { private unsubWorld: (() => void) | null = null private hoveredMonsterId: number | null = null private escapeMenuOpen = false + private readonly priceProvider: VendorPriceFn | null + private readonly repairAllProvider: VendorRepairAllFn | null + private readonly uiSound: ((soundId: number) => void) | null + private readonly now: () => number + /** NPC/stash state seen by the last sync; panels react to transitions only. */ + private prevNpc: NpcInteractionState = NPC_STATE_CLOSED + /** `lastNpcTransaction.seq` already reacted to (each S2C 0x2A is shown once). */ + private handledNpcTransactionSeq: number + private readonly lastNpcActionAt = new Map() + /** Message shown for the last non-success S2C 0x2A (string.tbl index + localized text). */ + lastNpcTransactionMessage: { readonly tblKey: string; readonly text: string } | null = null constructor(options: HudModelOptions) { this.world = options.world @@ -231,6 +292,11 @@ export class HudModel implements HudCommandSink { this.predictor = options.predictor ?? null this.automap = options.automap ?? null this.onCommand = options.onCommand + this.priceProvider = options.vendorPrice ?? null + this.repairAllProvider = options.vendorRepairAll ?? null + this.uiSound = options.playUiSound ?? null + this.now = options.now ?? (() => performance.now()) + this.handledNpcTransactionSeq = this.world.lastNpcTransaction?.seq ?? 0 if (this.hudManager) this.hudManager.commandSink = this this.unsubWorld = this.world.onChange(() => { @@ -551,9 +617,143 @@ export class HudModel implements HudCommandSink { // 7. Target monster plaque this.syncHoveredMonsterTarget() + // 8. NPC menu / store / stash panels and S2C 0x2A replies. + this.syncNpcInteraction(hud) + hud.syncPublishedState() } + private syncNpcInteraction(hud: HudManager): void { + const npc = this.world.npcInteraction + const prev = this.prevNpc + this.prevNpc = npc + const wp = hud.worldPanels + + // a. NpcInit noted without a window: open the NPC menu for service NPCs (npc-table descriptors). + if (npc.npcId !== null && npc.window === 'none' && npc.npcId !== prev.npcId) { + const desc = this.npcDescriptor(npc.npcId) + if (desc) wp.openNpcMenu(desc, 400, 220, npc.npcId) + } + if (npc.npcId === null && wp.npcMenu && wp.npcMenu.npcId !== null) wp.npcMenu = null + + // b. Trade / gamble window: store items come from 0x9C AddToShop (`containers.shop`). + if (npc.window !== 'none' && npc.npcId !== null) { + const npcId = npc.npcId + const desc = this.npcDescriptor(npcId) + if (!desc) throw new Error(`HudModel: store opened for NPC #${npcId} without a service descriptor`) + const ctx = this.priceContext(npcId) + const price = this.requireVendorPrice() + const gamble = npc.window === 'gamble' + const items = [...this.world.containers.shop.values()].map((item) => ({ + storePage: item.storePage, + placement: { + item: { ...itemDataToUiInventoryItem(item), buyCost: price(item, gamble ? 2 : 0, ctx) }, + col: item.gridX, + row: item.gridY, + }, + })) + wp.setVendorShop({ npcId, descriptor: desc, mode: npc.window, items }) + const canRepair = desc.canRepair && !gamble + wp.vendorRepairAllCost = canRepair ? this.requireVendorRepairAll()(ctx) : null + this.decoratePlayerItemPrices(hud, price, ctx, canRepair) + if (prev.window !== npc.window || prev.npcId !== npcId) hud.showVendorPanel() + } else if (prev.window !== 'none') { + wp.clearVendorShop() + hud.hideLeftPanelFromWorld('vendor') + } + + // c. Stash: opened by S2C 0x77 action 0x10, closed when 0x4F 0x12 is sent. + if (npc.stashOpen && !prev.stashOpen) hud.showStashPanel() + else if (!npc.stashOpen && prev.stashOpen) hud.hideLeftPanelFromWorld('stash') + + // d. S2C 0x2A NPCTransaction: react once per reply. + const txn = this.world.lastNpcTransaction + if (txn && txn.seq !== this.handledNpcTransactionSeq) { + this.handledNpcTransactionSeq = txn.seq + const msgKey = npcTransactionMessageKey(txn.result) + if (msgKey !== null) { + const text = requireTbl(msgKey, hud.lang) + this.lastNpcTransactionMessage = { tblKey: msgKey, text } + wp.showVendorStatus(text, 'white') + } else if (txn.result === 1) { + this.requirePlayUiSound()(NPC_SELL_SOUND_ID) + } + } + } + + /** Sell value (and repair cost at smiths) on the player's bag and equipped items while a store is open. */ + private decoratePlayerItemPrices(hud: HudManager, price: VendorPriceFn, ctx: VendorPriceContext, canRepair: boolean): void { + const decorate = (ui: UiInventoryItem, raw: ItemData): UiInventoryItem => ({ + ...ui, + sellCost: price(raw, 1, ctx), + ...(canRepair && raw.maxDurability !== undefined && raw.maxDurability > 0 ? { repairCost: price(raw, 3, ctx) } : {}), + }) + hud.inventory.gridItems = hud.inventory.gridItems.map((pl) => { + const raw = this.world.containers.inventory.get(Number(pl.item.id)) + if (!raw) throw new Error(`HudModel: inventory item ${pl.item.id} missing from ClientWorld`) + return { ...pl, item: decorate(pl.item, raw) } + }) + for (const raw of this.world.containers.equipment.values()) { + const slot = BODY_LOC_TO_EQUIP_SLOT[raw.bodyLoc || raw.equippedSlot] + const ui = slot ? hud.inventory.equipped[slot] : undefined + if (slot && ui) hud.inventory.equipped[slot] = decorate(ui, raw) + } + } + + private npcDescriptor(npcId: number): TownNpcServiceDescriptor | undefined { + return getTownNpcDescriptorByHcIdx(this.npcClassOf(npcId)) + } + + /** MonStats hcIdx of NPC unit `npcId`; throws when the unit is not in the world. */ + private npcClassOf(npcId: number): number { + const unit = this.world.getUnit('monster', npcId) + if (!unit) throw new Error(`HudModel: NPC unit #${npcId} is not in the world`) + return unit.classId + } + + private priceContext(npcId: number): VendorPriceContext { + return { world: this.world, npcClass: this.npcClassOf(npcId) } + } + + private requireVendorPrice(): VendorPriceFn { + if (!this.priceProvider) throw new Error('HudModel: no vendorPrice provider (HudModelOptions.vendorPrice)') + return this.priceProvider + } + + private requireVendorRepairAll(): VendorRepairAllFn { + if (!this.repairAllProvider) throw new Error('HudModel: no vendorRepairAll provider (HudModelOptions.vendorRepairAll)') + return this.repairAllProvider + } + + private requirePlayUiSound(): (soundId: number) => void { + if (!this.uiSound) throw new Error('HudModel: no playUiSound sink (HudModelOptions.playUiSound)') + return this.uiSound + } + + /** The open store (`npcInteraction.window` trade / gamble); throws when none is open. */ + private requireOpenStore(action: string): { npcId: number; gamble: boolean; ctx: VendorPriceContext } { + const npc = this.world.npcInteraction + if (npc.npcId === null || npc.window === 'none') throw new Error(`HudModel.${action}: no store window is open`) + return { npcId: npc.npcId, gamble: npc.window === 'gamble', ctx: this.priceContext(npc.npcId) } + } + + /** `true` when `action` ran less than 2000 ms ago (the click is dropped, like D2Client). */ + private isThrottled(action: string): boolean { + const t = this.now() + const last = this.lastNpcActionAt.get(action) + if (last !== undefined && t - last < NPC_TRADE_THROTTLE_MS) return true + this.lastNpcActionAt.set(action, t) + return false + } + + /** Player item by id from the bag, the paperdoll or the cursor. */ + private requirePlayerItem(itemId: number, action: string): ItemData { + const c = this.world.containers + const item = c.inventory.get(itemId) ?? c.equipment.get(itemId) ?? (c.cursor?.id === itemId ? c.cursor : undefined) + if (!item) throw new Error(`HudModel.${action}: item #${itemId} is not carried by the player`) + return item + } + /** * Update the hovered/targeted monster unit ID and refresh `hudManager.targetMonster`. */ @@ -775,6 +975,95 @@ export class HudModel implements HudCommandSink { identifyAllAtNpc(npcId: number): void { this.emitCommand({ type: 'NpcIdentifyAll', npcId }) } + + /** NPC menu "trade" / "trade/repair" (`0x38` action 1). Throws when no price provider is configured. */ + openNpcTrade(npcId: number): void { + this.requireVendorPrice() + this.requireVendorRepairAll() + this.requirePlayUiSound() + this.emitCommand({ type: 'NpcOpenTrade', npcId }) + this.syncFromWorld() + } + + /** NPC menu "gamble" (`0x38` action 2); only the six 1.13c gamble NPC classes. */ + openNpcGamble(npcId: number): void { + const npcClass = this.npcClassOf(npcId) + if (!GAMBLE_NPC_CLASS_IDS.has(npcClass)) throw new Error(`HudModel.openNpcGamble: NPC class ${npcClass} does not gamble`) + this.requireVendorPrice() + this.requireVendorRepairAll() + this.requirePlayUiSound() + this.emitCommand({ type: 'NpcOpenGamble', npcId }) + this.syncFromWorld() + } + + /** Close the NPC menu / store (`0x30 NpcCancel`); the server sends nothing back. */ + closeNpc(npcId: number): void { + this.emitCommand({ type: 'NpcCancel', target: { unitType: UNIT_TYPE_MONSTER, id: npcId } }) + this.syncFromWorld() + } + + /** Store grid click on shop item `itemId`: `0x32 NpcBuy` with the #10107 buy / gamble price. */ + vendorBuy(itemId: number, shiftFill: boolean): boolean { + const store = this.requireOpenStore('vendorBuy') + const item = this.world.containers.shop.get(itemId) + if (!item) throw new Error(`HudModel.vendorBuy: shop item #${itemId} not in the store`) + const cost = this.requireVendorPrice()(item, store.gamble ? 2 : 0, store.ctx) + if (this.isThrottled('buy')) return false + this.buyFromNpc({ + npcId: store.npcId, + itemId, + itemMode: 0, + gamble: store.gamble, + shiftFill: store.gamble ? false : shiftFill, + cost, + }) + return true + } + + /** Sell player item `itemId`: `0x33 NpcSell` with the #10107 sell price. */ + vendorSell(itemId: number): boolean { + const store = this.requireOpenStore('vendorSell') + const item = this.requirePlayerItem(itemId, 'vendorSell') + const cost = this.requireVendorPrice()(item, 1, store.ctx) + if (this.isThrottled('sell')) return false + this.sellToNpc({ npcId: store.npcId, itemId, itemMode: 0, cost }) + return true + } + + /** Repair player item `itemId` (`0x35`); durability is the item's current value (absent stat = 0). */ + vendorRepairItem(itemId: number): boolean { + const store = this.requireOpenStore('vendorRepairItem') + const item = this.requirePlayerItem(itemId, 'vendorRepairItem') + if (this.isThrottled('repair')) return false + this.repairItemAtNpc({ npcId: store.npcId, itemId, itemMode: 0, durability: item.curDurability ?? 0 }) + return true + } + + /** Repair All button (`0x35` field4 = 0x80000000). */ + vendorRepairAll(): boolean { + const store = this.requireOpenStore('vendorRepairAll') + if (this.isThrottled('repair')) return false + this.repairAllAtNpc(store.npcId) + return true + } + + /** Gold dialog mode 3: `0x4F ClickButton` 0x14 DepositGold. Non-positive amounts send nothing. */ + depositGold(amount: number): void { + if (!Number.isInteger(amount) || amount <= 0) return + this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.DepositGold, amount }) + } + + /** Gold dialog mode 4: `0x4F ClickButton` 0x13 WithdrawGold. Non-positive amounts send nothing. */ + withdrawGold(amount: number): void { + if (!Number.isInteger(amount) || amount <= 0) return + this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.WithdrawGold, amount }) + } + + /** Stash panel closed: `0x4F ClickButton` 0x12 CloseStash (no reply). */ + closeStash(): void { + this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 }) + this.syncFromWorld() + } } export function clientUnitToMonsterTargetInfo(unit: ClientUnit): MonsterTargetHudInfo { diff --git a/src/client/ui-model/vendor-price.ts b/src/client/ui-model/vendor-price.ts new file mode 100644 index 0000000..90e4c20 --- /dev/null +++ b/src/client/ui-model/vendor-price.ts @@ -0,0 +1,32 @@ +/** + * Vendor price provider contract (`src/client/ui-model/vendor-price.ts`). + * + * D2Client prices store items locally with D2Common #10107 (repair-all: #10071) for the tooltip and the + * `0x32 NpcBuy` / `0x33 NpcSell` cost field; D2Game ignores that field and prices server-side. The HUD never + * derives a price itself: `HudModel` receives these functions through its options, and opening a store + * without them throws (no fallback price). Production wiring injects {@link vendorPrice113c} / + * {@link vendorRepairAll113c}; tests inject stubs. + */ + +import type { ItemData } from '../../netproto/index.ts' +import type { ClientWorld } from '../world/client-world.ts' +import { priceItem113c, repairAllCost113c } from './transaction-cost-input.ts' + +/** Pricing inputs: the server-mirrored world (clvl, difficulty, quests, reduced-price stats) and the NPC class. */ +export interface VendorPriceContext { + readonly world: ClientWorld + /** MonStats hcIdx of the store NPC (e.g. 147 = Gheed). */ + readonly npcClass: number +} + +/** Transaction type `0` buy, `1` sell, `2` gamble, `3` repair; returns the displayed / sent gold amount. */ +export type VendorPriceFn = (item: ItemData, type: 0 | 1 | 2 | 3, ctx: VendorPriceContext) => number + +/** "Repair all equipment: %d" total (patchstring 10095) for the player's equipped items. */ +export type VendorRepairAllFn = (ctx: VendorPriceContext) => number + +/** D2Common 1.13c #10107 port. */ +export const vendorPrice113c: VendorPriceFn = (item, type, ctx) => priceItem113c(ctx.world, item, type, ctx.npcClass) + +/** D2Common 1.13c #10071 port. */ +export const vendorRepairAll113c: VendorRepairAllFn = (ctx) => repairAllCost113c(ctx.world, ctx.npcClass) diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index 97c0d2b..f877834 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -45,7 +45,6 @@ import { type CursorState, } from './cursor.ts' import type { CharacterClassCode } from '../../common/units/classes.ts' -import type { TownNpcServiceDescriptor, VendorNpcId } from '../../common/units/npc-table.ts' import { detectInitialLang, setCurrentLang, @@ -54,6 +53,7 @@ import { type TblLang, } from '../i18n/lang.ts' import { setTooltipLocale } from '../../common/items/item-tooltip.ts' +import { requireTbl } from '../i18n/lang.ts' import { computeViewportAnchors, getViewportProfile, @@ -283,6 +283,15 @@ export function logicalToClient( * Server-authoritative HUD command sink (online mode: `HudModel`). Every method only emits a `ClientCommand`; * the HUD never mutates gameplay state locally while a sink is attached. */ +export type GoldDialogMode = 0 | 3 | 4 + +export interface GoldDialogRect { + readonly x: number + readonly y: number + readonly w: number + readonly h: number +} + export interface HudCommandSink { allocateStat(key: BaseStatKey): boolean allocateSkill(skillId: number): boolean @@ -299,6 +308,28 @@ export interface HudCommandSink { useItem(itemId: number): void transmuteCube(): void teleportToLevel(destAreaId: number): void + /** NPC menu "trade" / "trade/repair": `0x38` action 1. */ + openNpcTrade(npcId: number): void + /** NPC menu "gamble": `0x38` action 2 (Gheed, Elzix, Alkor, Jamella, Anya, Nihlathak). */ + openNpcGamble(npcId: number): void + /** NPC menu "Identify Items" (Deckard Cain): `0x34`. */ + identifyAllAtNpc(npcId: number): void + /** Close the NPC menu / store: `0x30 NpcCancel`. The server sends nothing back. */ + closeNpc(npcId: number): void + /** Store grid click on a shop item: `0x32 NpcBuy` (gamble flag from the open window). */ + vendorBuy(itemId: number, shiftFill: boolean): void + /** Sell a player item: `0x33 NpcSell`. */ + vendorSell(itemId: number): void + /** Repair one item: `0x35 NpcRepair`. */ + vendorRepairItem(itemId: number): void + /** Repair all equipped items: `0x35` with field4 = 0x80000000. */ + vendorRepairAll(): void + /** Gold dialog mode 3: `0x4F` button 0x14. */ + depositGold(amount: number): void + /** Gold dialog mode 4: `0x4F` button 0x13. */ + withdrawGold(amount: number): void + /** Stash panel closed: `0x4F` button 0x12 (no reply). */ + closeStash(): void } /** Parse the numeric server item id carried by a `UiInventoryItem` synced from `ClientWorld`; throws otherwise. */ @@ -392,6 +423,13 @@ export class HudManager { currentClass: CharacterClassCode | null = null private _commandSink: HudCommandSink | null = null + /** + * D2Client gold dialog: mode `0` drop (string 4033), `3` deposit (4049), `4` withdraw (4050). + * The amount is clamped only to the source balance (inventory gold for 0/3, stash gold for 4); + * the server applies the inventory (clvl * 10,000) and stash (2,500,000) caps. + */ + goldDialog: { mode: GoldDialogMode; amount: number; fresh: boolean } | null = null + /** * Server-authoritative command sink (online: `HudModel`). When attached, HUD interactions only emit * `ClientCommand`s; all displayed state changes arrive back from the server through `HudModel.syncFromWorld()`. @@ -693,41 +731,164 @@ export class HudManager { return ok } - /** - * Opens a Town NPC Vendor or Gamble session on the Left Dock and ensures Player Inventory is open on the Right Dock. - */ - openVendorSession( - descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string, - mode: 'trade' | 'gamble' = 'trade', - difficulty: 0 | 1 | 2 = 0, - ): boolean { - if (this.currentClass === null) { - throw new Error('HudManager.openVendorSession: character class not assigned by the server yet') + private requireSink(action: string): HudCommandSink { + if (!this._commandSink) throw new Error(`HudManager.${action}: no server command sink attached`) + return this._commandSink + } + + /** Server opened a store (`ClientWorld.npcInteraction.window`): show the vendor panel with the inventory. */ + showVendorPanel(): void { + if (!this.worldPanels.activeVendorDescriptor) { + throw new Error('HudManager.showVendorPanel: no store set (WorldPanelsHud.setVendorShop)') } - const playerCtx = { - level: this.charSheet.attrs.level, - str: this.charSheet.attrs.str, - dex: this.charSheet.attrs.dex, - classCode: this.currentClass, - } - this.worldPanels.activePlayerInventory = this.inventory - this.inventory.setPlayerContext(playerCtx) - this.worldPanels.setPlayerContext(playerCtx) - const ok = this.worldPanels.openVendorForNpc( - descriptorOrName, - mode, - this.charSheet.attrs.level, - difficulty, - ) - if (!ok) return false this.worldPanels.npcMenu = null this.leftPanel = 'vendor' this.charSheet.visible = false - if (this.rightPanel !== 'inv') { - this.toggleRightPanel('inv') + if (this.rightPanel !== 'inv') this.toggleRightPanel('inv') + this.syncPublishedState() + } + + /** Server opened the stash (S2C 0x77 action 0x10): show the stash panel with the inventory. */ + showStashPanel(): void { + this.leftPanel = 'stash' + this.charSheet.visible = false + if (this.rightPanel !== 'inv') this.toggleRightPanel('inv') + this.syncPublishedState() + } + + /** World state closed `kind` (already noted by `ClientWorld`): hide it without sending anything. */ + hideLeftPanelFromWorld(kind: 'vendor' | 'stash'): void { + if (this.leftPanel !== kind) return + this.leftPanel = 'none' + if (kind === 'vendor') this.worldPanels.releaseMomentaryVendorButton() + if (this.goldDialog?.mode === 4 && kind === 'stash') this.goldDialog = null + this.syncPublishedState() + } + + /** Dismiss the NPC menu and end the interaction (`0x30 NpcCancel`). */ + dismissNpcMenu(): void { + const menu = this.worldPanels.npcMenu + if (!menu) return + this.worldPanels.npcMenu = null + if (menu.npcId !== null) this.requireSink('dismissNpcMenu').closeNpc(menu.npcId) + this.syncPublishedState() + } + + /** Leaving the vendor / stash panel tells the server (`0x30` / `0x4F 0x12`); other panels are local. */ + private leaveLeftPanel(prev: LeftDockPanelKind): void { + if (prev === 'vendor') { + const npcId = this.worldPanels.vendorNpcId + this.worldPanels.clearVendorShop() + if (npcId !== null) this.requireSink('closeVendor').closeNpc(npcId) + } else if (prev === 'stash') { + if (this.goldDialog && this.goldDialog.mode !== 0) this.goldDialog = null + this._commandSink?.closeStash() + } + } + + /** Source balance the gold dialog clamps to: stash gold for withdraw, inventory gold otherwise. */ + goldDialogBalance(mode: GoldDialogMode): number { + return mode === 4 ? this.worldPanels.stashGold : this.inventory.gold + } + + /** Open the gold dialog pre-filled with the whole source balance. */ + openGoldDialog(mode: GoldDialogMode): void { + if (mode === 3 && this.leftPanel !== 'stash') throw new Error('HudManager.openGoldDialog: deposit needs the stash open') + if (mode === 4 && this.leftPanel !== 'stash') throw new Error('HudManager.openGoldDialog: withdraw needs the stash open') + this.goldDialog = { mode, amount: this.goldDialogBalance(mode), fresh: true } + this.syncPublishedState() + } + + /** Set the typed amount, clamped to `0 .. source balance`. */ + setGoldDialogAmount(amount: number): void { + const dlg = this.goldDialog + if (!dlg) throw new Error('HudManager.setGoldDialogAmount: gold dialog is not open') + if (!Number.isFinite(amount)) throw new Error(`HudManager.setGoldDialogAmount: invalid amount ${amount}`) + dlg.amount = Math.max(0, Math.min(this.goldDialogBalance(dlg.mode), Math.floor(amount))) + dlg.fresh = false + } + + /** Digit / Backspace editing; the first digit after opening replaces the pre-filled amount. */ + typeGoldDialogKey(key: string): void { + const dlg = this.goldDialog + if (!dlg) throw new Error('HudManager.typeGoldDialogKey: gold dialog is not open') + if (key === 'Backspace') { + this.setGoldDialogAmount(dlg.fresh ? 0 : Math.floor(dlg.amount / 10)) + } else if (/^[0-9]$/.test(key)) { + this.setGoldDialogAmount((dlg.fresh ? 0 : dlg.amount) * 10 + Number(key)) + } + } + + /** "ok": send the request for the clamped amount (nothing for 0) and close the dialog. */ + confirmGoldDialog(): void { + const dlg = this.goldDialog + if (!dlg) throw new Error('HudManager.confirmGoldDialog: gold dialog is not open') + const amount = Math.max(0, Math.min(this.goldDialogBalance(dlg.mode), dlg.amount)) + this.goldDialog = null + if (amount > 0) { + const sink = this.requireSink('confirmGoldDialog') + if (dlg.mode === 0) sink.dropGold(amount) + else if (dlg.mode === 3) sink.depositGold(amount) + else sink.withdrawGold(amount) } this.syncPublishedState() - return true + } + + cancelGoldDialog(): void { + this.goldDialog = null + this.syncPublishedState() + } + + /** Gold dialog box (centered stone box: title, amount, ok / cancel). */ + getGoldDialogLayout(): { x: number; y: number; w: number; h: number; ok: GoldDialogRect; cancel: GoldDialogRect } { + const w = 260 + const h = 110 + const x = Math.round(this.screenWidth / 2 - w / 2) + const y = Math.round((this.screenHeight - 47) / 2 - h / 2) + return { + x, + y, + w, + h, + ok: { x: x + 30, y: y + h - 34, w: 80, h: 22 }, + cancel: { x: x + w - 110, y: y + h - 34, w: 80, h: 22 }, + } + } + + private handleGoldDialogClick(x: number, y: number): void { + const l = this.getGoldDialogLayout() + const inRect = (r: GoldDialogRect) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h + if (inRect(l.ok)) this.confirmGoldDialog() + else if (inRect(l.cancel)) this.cancelGoldDialog() + } + + private drawGoldDialog(ctx: CanvasRenderingContext2D): void { + const dlg = this.goldDialog + if (!dlg) return + const l = this.getGoldDialogLayout() + const titleKey = dlg.mode === 0 ? 'strDropGoldHowMuch' : dlg.mode === 3 ? 'strBankGoldDeposit' : 'strBankGoldWithdraw' + ctx.fillStyle = 'rgba(0, 0, 0, 0.9)' + ctx.fillRect(l.x, l.y, l.w, l.h) + ctx.strokeStyle = '#6c5c40' + ctx.strokeRect(l.x + 0.5, l.y + 0.5, l.w - 1, l.h - 1) + this.font.drawText(ctx, requireTbl(titleKey, this.lang), l.x + l.w / 2, l.y + 22, { + font: 'font8', + color: 'white', + align: 'center', + }) + this.font.drawText(ctx, String(dlg.amount), l.x + l.w / 2, l.y + 50, { + font: 'font16', + color: 'gold', + align: 'center', + }) + for (const [rect, key] of [[l.ok, 'ok'], [l.cancel, 'Back']] as const) { + ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1) + this.font.drawText(ctx, requireTbl(key, this.lang), rect.x + rect.w / 2, rect.y + 16, { + font: 'font16', + color: 'white', + align: 'center', + }) + } } /** @@ -735,16 +896,19 @@ export class HudManager { * Strictly follows D2Client.dll mutual exclusion on the Left Dock (`0..400`). */ toggleLeftPanel(kind: Exclude): void { - if (this.leftPanel === kind) { + const prev = this.leftPanel + if (prev === kind) { this.leftPanel = 'none' this.charSheet.visible = false this.worldPanels.vendorTradeState = 'idle' this.worldPanels.releaseMomentaryVendorButton() + this.leaveLeftPanel(prev) } else { if (kind === 'vendor' && !this.worldPanels.activeVendorDescriptor) { throw new Error('Cannot open vendor panel without an active vendor session') } this.leftPanel = kind + if (prev !== 'none') this.leaveLeftPanel(prev) this.charSheet.visible = kind === 'char' if ((kind === 'stash' || kind === 'vendor') && this.rightPanel === 'none') { this.toggleRightPanel('inv') @@ -777,8 +941,13 @@ export class HudManager { this.belt.lockedOpen || this.hotkeys.openPopup !== null || this.worldPanels.npcMenu !== null || + this.goldDialog !== null || this.inventory.isIdentifyMode() + const prevLeft = this.leftPanel + this.goldDialog = null + this.dismissNpcMenu() this.leftPanel = 'none' + if (prevLeft !== 'none') this.leaveLeftPanel(prevLeft) this.rightPanel = 'none' this.charSheet.visible = false this.inventory.visible = false @@ -849,6 +1018,7 @@ export class HudManager { * Central vision corridor (e.g. `400 < logicalX < 624` in `1024x768`) remains open and returns false. */ isPointInterceptedByHud(logicalX: number, logicalY: number): boolean { + if (this.goldDialog) return true const profile = this.viewportProfile const docking = this.getDockingLayout() const { marginW } = docking @@ -898,6 +1068,14 @@ export class HudManager { private bindEvents(): void { window.addEventListener('keydown', (e) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) return + if (this.goldDialog) { + e.preventDefault() + if (e.key === 'Enter') this.confirmGoldDialog() + else if (e.key === 'Escape') this.cancelGoldDialog() + else this.typeGoldDialogKey(e.key) + this.syncPublishedState() + return + } const key = e.key.toUpperCase() if (key === 'I' || key === 'B') { @@ -1067,9 +1245,14 @@ export class HudManager { handleHudMouseDown(e: MouseEvent): void { if (e.button !== 0 && e.button !== 2) return const pt = this.clientToLogical(e.clientX, e.clientY) + if (this.goldDialog) { + e.preventDefault() + e.stopPropagation() + if (e.button === 0) this.handleGoldDialogClick(pt.x, pt.y) + return + } if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) { - this.worldPanels.npcMenu = null - this.syncPublishedState() + this.dismissNpcMenu() } if (!this.isPointInterceptedByHud(pt.x, pt.y)) { if (this.inventory.isIdentifyMode()) { @@ -1107,19 +1290,25 @@ export class HudManager { // Town NPC Stone Menu click if (this.worldPanels.npcMenu) { + const npcId = this.worldPanels.npcMenu.npcId this.worldPanels.handleNpcMenuClick( pt.x, pt.y, { - onOpenVendor: (desc, mode) => { - this.openVendorSession(desc, mode) + onOpenVendor: (_desc, mode) => { + const sink = this.requireSink('npcMenu') + if (npcId === null) throw new Error('HudManager: NPC menu has no server NPC id') + if (mode === 'trade') sink.openNpcTrade(npcId) + else sink.openNpcGamble(npcId) }, onIdentifyAll: (_desc) => { - const count = this.worldPanels.identifyAllUiItems(this.inventory) - this.worldPanels.showAreaBanner( - count > 0 ? `已鉴定 ${count} 件物品` : '背包中没有未鉴定物品', - count > 0 ? `Identified ${count} items` : 'No unidentified items in inventory', - ) + const sink = this.requireSink('npcMenu') + if (npcId === null) throw new Error('HudManager: NPC menu has no server NPC id') + sink.identifyAllAtNpc(npcId) + sink.closeNpc(npcId) + }, + onCancel: () => { + if (npcId !== null) this.requireSink('npcMenu').closeNpc(npcId) }, }, this.font, @@ -1180,10 +1369,16 @@ export class HudManager { this.syncPublishedState() return } else if (this.leftPanel !== 'none') { - this.worldPanels.handleLeftDockClick(this.leftPanel, leftX, pt.y, { + const dockKind = this.leftPanel + this.worldPanels.handleLeftDockClick(dockKind, leftX, pt.y, { onClose: () => { this.leftPanel = 'none' + this.leaveLeftPanel(dockKind) }, + onStashGoldButton: () => this.openGoldDialog(4), + onVendorBuy: (item, shiftFill) => this.requireSink('vendorBuy').vendorBuy(serverItemIdOf(item), shiftFill), + onVendorSell: (item) => this.requireSink('vendorSell').vendorSell(serverItemIdOf(item)), + onVendorRepairAll: () => this.requireSink('vendorRepairAll').vendorRepairAll(), onWaypointTeleport: (act, slug, levelId) => { if (this._commandSink) { this._commandSink.teleportToLevel(levelId) @@ -1260,51 +1455,11 @@ export class HudManager { } } } + } - // Vendor Repair or Sell/Quick-Sell from Player Inventory - if (this.leftPanel === 'vendor') { - // D2Client.dll 0x6fb472d0: Right-clicking while in Buy/Sell/Repair cursor mode cancels back to idle - if (e.button === 2 && this.worldPanels.vendorTradeState !== 'idle') { - this.worldPanels.vendorTradeState = 'idle' - this.syncPublishedState() - return - } - if ( - rightX >= INV_GRID_ORIGIN.x && - rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && - pt.y >= INV_GRID_ORIGIN.y && - pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx - ) { - const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) - const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) - const hitIdx = this.inventory.gridItems.findIndex( - p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, - ) - if (hitIdx !== -1) { - const hit = this.inventory.gridItems[hitIdx]! - if (this.worldPanels.vendorTradeState === 'repair') { - this.worldPanels.repairSingleUiItem(hit.item, this.inventory) - this.syncPublishedState() - return - } - if (this.worldPanels.vendorTradeState === 'sell' || e.shiftKey || e.button === 2) { - const res = this.worldPanels.sellToActiveVendor(hit.item, this.inventory) - if (res.ok) { - if (this.inventory.gridItems[hitIdx] === hit) { - this.inventory.gridItems.splice(hitIdx, 1) - } - this.inventory.hoveredItem = null - } - this.syncPublishedState() - return - } - } - } else if (this.worldPanels.vendorTradeState === 'repair' && this.inventory.hoveredItem) { - this.worldPanels.repairSingleUiItem(this.inventory.hoveredItem.item, this.inventory) - this.syncPublishedState() - return - } - } + if (this.leftPanel === 'vendor' && !this.inventory.isIdentifyMode() && this.handleVendorInventoryClick(rightX, pt.y, e.button === 2)) { + this.syncPublishedState() + return } if (this._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) { @@ -1319,9 +1474,7 @@ export class HudManager { { onOpenCube: () => this.toggleLeftPanel('cube'), onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item), - onDropGold: (amt) => { - this.dropInventoryGold(amt, pt) - }, + onGoldButton: () => this.openGoldDialog(this.leftPanel === 'stash' ? 3 : 0), }, e.button === 2 ? 2 : 0, { @@ -1341,6 +1494,46 @@ export class HudManager { } } + /** + * Inventory click while a store is open and the sell / repair cursor is armed (D2Client 0x6fb472d0): + * right-click disarms; sell sends `0x33` for the bag item; repair sends `0x35` for the bag or equipped item. + * Returns `false` in the idle / buy state so the click picks up / places items as usual. + */ + private handleVendorInventoryClick(x: number, y: number, isRightClick: boolean): boolean { + const state = this.worldPanels.vendorTradeState + if (state !== 'sell' && state !== 'repair') return false + if (isRightClick) { + this.worldPanels.vendorTradeState = 'idle' + return true + } + let target: UiInventoryItem | null = null + if ( + x >= INV_GRID_ORIGIN.x && + x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && + y >= INV_GRID_ORIGIN.y && + y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) + const row = Math.floor((y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) + target = + this.inventory.gridItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + )?.item ?? null + } else if (state === 'repair') { + for (const [slot, rect] of Object.entries(EQUIP_SLOTS_LAYOUT) as [EquipSlotId, (typeof EQUIP_SLOTS_LAYOUT)[EquipSlotId]][]) { + if (x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h) { + target = this.inventory.equipped[slot] ?? null + break + } + } + } + if (!target) return true + const sink = this.requireSink(state === 'sell' ? 'vendorSell' : 'vendorRepairItem') + if (state === 'sell') sink.vendorSell(serverItemIdOf(target)) + else sink.vendorRepairItem(serverItemIdOf(target)) + return true + } + /** * Forward an inventory-panel click (grid / paperdoll) to the server sink. Returns `false` when the point is not * on an item area so the panel's own chrome (close / gold buttons / swap tabs) still handles it. @@ -1745,6 +1938,9 @@ export class HudManager { this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font, this.leftPanel === 'vendor') } + // 10b. Gold dialog (drop / deposit / withdraw) + this.drawGoldDialog(ctx) + // 11. Authentic Hand / Trade Cursor & Held Item at Hotspot this.renderCursor(ctx, nowMs) diff --git a/src/client/ui/inventory.ts b/src/client/ui/inventory.ts index fc09362..069b13f 100644 --- a/src/client/ui/inventory.ts +++ b/src/client/ui/inventory.ts @@ -64,7 +64,7 @@ export { import { INV_COLS, INV_ROWS, - PLAYER_GOLD_CAP, + STASH_GOLD_CAP, getInventoryGoldLimit, findFreeGridSlot, type EquipSlotId, @@ -76,7 +76,7 @@ import { export { INV_COLS, INV_ROWS, - PLAYER_GOLD_CAP, + STASH_GOLD_CAP, getInventoryGoldLimit, findFreeGridSlot, type EquipSlotId, @@ -127,6 +127,9 @@ export const INV_GOLD_BTN_BOUNDS = { h: 18, } as const +/** Panel-relative click rect of the inventory gold coin button (inclusive bounds, D2Client inventory panel). */ +export const INV_GOLD_BTN_HIT = { relX0: 83, relX1: 103, relY0: 392, relY1: 410 } as const + export const INV_GOLD_BOX_RECESS = { x: 400 + 107, y: 60 + 392, @@ -1583,34 +1586,6 @@ export function drawItemSocketOverlay( } } -declare global { - interface Boolean { - readonly cursorItem: UiInventoryItem | null - readonly action: 'equip' | 'unequip' | 'swap' | 'none' - } -} - -let _lastInvClickCursorItem: UiInventoryItem | null = null -let _lastInvClickAction: 'equip' | 'unequip' | 'swap' | 'none' = 'none' - -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'cursorItem')) { - Object.defineProperty(Boolean.prototype, 'cursorItem', { - get() { - return _lastInvClickCursorItem - }, - configurable: true, - }) -} - -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'action')) { - Object.defineProperty(Boolean.prototype, 'action', { - get() { - return _lastInvClickAction - }, - configurable: true, - }) -} - function normalizeEquipSlotId(slotId: string): EquipSlotId { if (slotId === 'head') return 'helm' if (slotId === 'mainHand') return 'weapon1' @@ -1854,18 +1829,6 @@ export class InventoryPanel { return false } - /** - * Deduct gold from inventory to drop. Returns actual amount deducted. - */ - dropGold(amount: number): number { - if (!Number.isFinite(amount) || amount <= 0) return 0 - const current = this.gold - const toDrop = Math.max(0, Math.min(Math.floor(amount), current)) - if (toDrop <= 0) return 0 - this.gold = current - toDrop - return toDrop - } - /** * Handle left-click on a grid cell `(col, row)`: * - If `cursorItem` is null: pick up the item at `(col, row)` into `cursorItem`. @@ -1877,7 +1840,6 @@ export class InventoryPanel { */ clickGridCell(col: number, row: number): boolean { if (col < 0 || row < 0 || col >= INV_GRID_ORIGIN.cols || row >= INV_GRID_ORIGIN.rows) { - _lastInvClickCursorItem = this.cursorItem return false } if (!this.cursorItem) { @@ -1885,12 +1847,10 @@ export class InventoryPanel { p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) if (!hit) { - _lastInvClickCursorItem = null return false } this.gridItems = this.gridItems.filter(p => p !== hit) this.cursorItem = hit.item - _lastInvClickCursorItem = this.cursorItem return true } @@ -1910,7 +1870,6 @@ export class InventoryPanel { } else { this.cursorItem = null } - _lastInvClickCursorItem = this.cursorItem return true } } @@ -1922,12 +1881,10 @@ export class InventoryPanel { } else { this.cursorItem = null } - _lastInvClickCursorItem = this.cursorItem return true } if (!directHit && (col + this.cursorItem.invWidth > INV_GRID_ORIGIN.cols || row + this.cursorItem.invHeight > INV_GRID_ORIGIN.rows)) { - _lastInvClickCursorItem = this.cursorItem return false } @@ -1937,7 +1894,6 @@ export class InventoryPanel { if (overlaps.length === 0) { this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.cursorItem = null - _lastInvClickCursorItem = this.cursorItem return true } if (overlaps.length === 1) { @@ -1954,7 +1910,6 @@ export class InventoryPanel { } else { this.cursorItem = null } - _lastInvClickCursorItem = this.cursorItem return true } } @@ -1966,16 +1921,13 @@ export class InventoryPanel { } else { this.cursorItem = null } - _lastInvClickCursorItem = this.cursorItem return true } this.gridItems = this.gridItems.filter(p => p !== single) this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.cursorItem = single.item - _lastInvClickCursorItem = this.cursorItem return true } - _lastInvClickCursorItem = this.cursorItem return false } @@ -2057,14 +2009,10 @@ export class InventoryPanel { const existing = this.equipped[slotId] ?? null if (!this.cursorItem) { if (!existing) { - _lastInvClickAction = 'none' - _lastInvClickCursorItem = null return false } delete this.equipped[slotId] this.cursorItem = existing - _lastInvClickAction = 'unequip' - _lastInvClickCursorItem = this.cursorItem return true } if (existing && canSocketUiItem(this.cursorItem, existing)) { @@ -2075,19 +2023,13 @@ export class InventoryPanel { } else { this.cursorItem = null } - _lastInvClickAction = 'equip' - _lastInvClickCursorItem = this.cursorItem return true } if (!this.canEquipItemInSlot(this.cursorItem, slotId)) { - _lastInvClickAction = 'none' - _lastInvClickCursorItem = this.cursorItem return false } this.equipped[slotId] = this.cursorItem this.cursorItem = existing - _lastInvClickAction = existing ? 'swap' : 'equip' - _lastInvClickCursorItem = this.cursorItem return true } @@ -2194,7 +2136,8 @@ export class InventoryPanel { callbacks?: { onOpenCube?: () => void onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void - onDropGold?: (amount: number) => void + /** Inventory gold coin button pressed: open the gold dialog (drop, or deposit while the stash is open). */ + onGoldButton?: () => void }, button: 0 | 2 = 0, externalContainers?: { @@ -2310,12 +2253,15 @@ export class InventoryPanel { return true } - // 5. Gold Coin Button (`goldcoinbtn.dc6` at x=484..504) & Readout Area (x: 484..625, y: 450..485) - if (logicalX >= INV_GOLD_BTN_BOUNDS.x && logicalX <= 625 && logicalY >= 450 && logicalY <= 485) { - if (this.gold > 0) { - const dropAmt = Math.min(10_000, this.gold) - callbacks?.onDropGold?.(dropAmt) - } + // 5. Gold coin button (D2Client inventory gold button hit rect, panel-relative x 83..103, y 392..410). + // D2 never drops/deposits locally: the button only opens the gold dialog; the amount is chosen there. + if ( + logicalX >= INV_PANEL_ORIGIN.x + INV_GOLD_BTN_HIT.relX0 && + logicalX <= INV_PANEL_ORIGIN.x + INV_GOLD_BTN_HIT.relX1 && + logicalY >= INV_PANEL_ORIGIN.y + INV_GOLD_BTN_HIT.relY0 && + logicalY <= INV_PANEL_ORIGIN.y + INV_GOLD_BTN_HIT.relY1 + ) { + if (this.gold > 0) callbacks?.onGoldButton?.() return true } diff --git a/src/client/ui/world-panels.ts b/src/client/ui/world-panels.ts index 371a092..4f479a2 100644 --- a/src/client/ui/world-panels.ts +++ b/src/client/ui/world-panels.ts @@ -40,74 +40,10 @@ import { import type { Item } from '../../common/items/items.ts' import type { CharacterClassCode } from '../../common/units/classes.ts' import { D2Rng } from '../../common/rng/d2-rng.ts' -import { getTblLang, type TblLang } from '../i18n/lang.ts' +import { getTblLang, requireTbl, type TblLang } from '../i18n/lang.ts' +import { STASH_GOLD_CAP } from '../../common/items/inventory-model.ts' import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' -let _lastVendorReason: string | undefined = undefined -let _lastVendorBoughtCount = 0 -let _lastVendorTotalCost = 0 -let _lastVendorGoldReceived = 0 - -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'ok')) { - Object.defineProperty(Boolean.prototype, 'ok', { - get() { - return this.valueOf() - }, - configurable: true, - }) -} -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'reason')) { - Object.defineProperty(Boolean.prototype, 'reason', { - get() { - return _lastVendorReason - }, - configurable: true, - }) -} -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'boughtCount')) { - Object.defineProperty(Boolean.prototype, 'boughtCount', { - get() { - return _lastVendorBoughtCount - }, - configurable: true, - }) -} -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'totalCost')) { - Object.defineProperty(Boolean.prototype, 'totalCost', { - get() { - return _lastVendorTotalCost - }, - configurable: true, - }) -} -if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'goldReceived')) { - Object.defineProperty(Boolean.prototype, 'goldReceived', { - get() { - return _lastVendorGoldReceived - }, - configurable: true, - }) -} - -function makeVendorBuyResult( - ok: boolean, - boughtCount: number, - totalCost: number, - reason?: string, -): { ok: boolean; reason?: string; boughtCount: number; totalCost: number } & boolean { - _lastVendorReason = reason - _lastVendorBoughtCount = boughtCount - _lastVendorTotalCost = totalCost - return ok as any -} - -function makeVendorSellResult( - ok: boolean, - goldReceived: number, -): { ok: boolean; goldReceived: number } & boolean { - _lastVendorGoldReceived = goldReceived - return ok as any -} import { type TownNpcServiceDescriptor, type VendorNpcId, @@ -117,9 +53,6 @@ import { getLocalizedNpcGreetings, } from '../../common/units/npc-table.ts' import type { VendorTabId } from '../../common/items/vendor-generator.ts' -import { VendorSessionManager } from '../../common/items/vendor-service.ts' -import { getEmbeddedDropTables } from '../../common/items/embedded-drop-tables.ts' -import { isItemRepairable } from '../../common/items/item-cost.ts' import { CANONICAL_CHI_TBL_DICT, CANONICAL_ENG_TBL_DICT } from '../../common/data/canonical-tbl-strings.ts' import { CANONICAL_ELITE_MODIFIERS, monsterDisplayName } from '../../common/units/monsters-data.ts' import { getSocketableInfo, isJewelCode } from '../../common/items/gems-data.ts' @@ -129,6 +62,7 @@ import { } from '../../common/items/runeword-socket.ts' export { + STASH_GOLD_CAP, GRID_ITEM_BG_BLUE, GRID_ITEM_BG_GREEN, GRID_ITEM_BG_RED, @@ -141,8 +75,10 @@ export type LeftDockPanelKind = 'none' | 'char' | 'quest' | 'waypoint' | 'stash' export const STASH_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const export const STASH_GRID_ORIGIN = { x: 80 + 74, y: 60 + 82, cols: 6, rows: 8, cellPx: 29 } as const -export const STASH_GOLD_CAP = 2_500_000 -export const PLAYER_GOLD_CAP = 2_500_000 +/** Panel-relative click rect of the stash gold coin button (inclusive, D2Client stash panel). */ +export const STASH_GOLD_BTN_HIT = { relX0: 73, relX1: 150, relY0: 24, relY1: 41 } as const + +/** Packed canonical tbl lookup that fails fast when the key was not packed. */ export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } as const /** @@ -376,6 +312,12 @@ export const VENDOR_TABS: readonly { { id: 'misc', index: 3, xOffset: 240, width: 79, height: 31, centerX: 281, textY: 20, strIdx: 4039, labelZh: '其他', labelEn: 'Misc' }, ] as const +/** + * MonStats classes whose NPC menu offers "gamble" in 1.13c: Gheed (147), Elzix (199), Alkor (254), + * Jamella (405), Anya (512), Nihlathak (514); matches `TownNpcServiceDescriptor.canGamble`. + */ +export const GAMBLE_NPC_CLASS_IDS: ReadonlySet = new Set([147, 199, 254, 405, 512, 514]) + export type NpcMenuActionId = 'talk' | 'trade' | 'gamble' | 'identify' | 'cancel' export interface NpcMenuOption { @@ -710,23 +652,15 @@ export class WorldPanelsHud { lang: TblLang = getTblLang() selectedActTab = 1 selectedQuestIdx = 0 - /** Server stat 15 (`goldbank`); 0 until received. */ - private _stashGold = 0 - private _stashGoldExplicitlySet = false - get stashGold(): number { - return this._stashGold - } - set stashGold(val: number) { - this._stashGold = Math.max(0, Math.floor(Number.isFinite(val) ? val : 0)) - this._stashGoldExplicitlySet = true - } + /** Server stat 15 (`goldbank`), written by `HudModel` only; 0 until received (absent stat = 0). */ + stashGold = 0 private _starterStashCleared = false - private readonly vendorRng = new D2Rng(0x56454e44) - private vendorBuySeq = 0 cubeTransmuteCount = 0 escapeMenuOpen = false lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null npcMenu: { + /** Server unit id of the NPC (from `NpcInit`); `null` for menus opened without a server unit. */ + npcId: number | null npcName: string x: number y: number @@ -737,14 +671,6 @@ export class WorldPanelsHud { hoveredOptionIdx?: number | null | undefined } | null = null - private _vendorSession: VendorSessionManager | null = null - get vendorSession(): VendorSessionManager { - if (!this._vendorSession) { - this._vendorSession = new VendorSessionManager(getEmbeddedDropTables()) - } - return this._vendorSession - } - private _clearStarterStashIfRawItem(item: UiInventoryItem | null | undefined): void { if (!this._starterStashCleared && item && !Array.isArray((item as any).allowedSlots)) { this._starterStashCleared = true @@ -755,7 +681,13 @@ export class WorldPanelsHud { activeVendorDescriptor: TownNpcServiceDescriptor | null = null vendorMode: 'trade' | 'gamble' = 'trade' activeVendorTab: VendorTabId = 'armor' - vendorDifficulty: 0 | 1 | 2 = 0 + /** Server unit id of the NPC whose store is open (`ClientWorld.npcInteraction.npcId`); `null` when closed. */ + vendorNpcId: number | null = null + /** + * "Repair all equipment: %d" total (patchstring 10095), computed by `HudModel` from its injected + * `VendorRepairAllFn`; `null` when no repair-capable store is open. + */ + vendorRepairAllCost: number | null = null /** Synced from the server character level by `HudManager.syncPlayerContext()`. */ vendorCharLevel = 0 vendorPlayerStats: PlayerItemRequirementContext = { @@ -980,11 +912,13 @@ export class WorldPanelsHud { descriptor: TownNpcServiceDescriptor, screenX = 400, screenY = 220, + npcId: number | null = null, ): void { const options = this.buildNpcMenuOptions(descriptor) // D2Client.dll 0x6fb53829 -> 0x6fb53370: first selectable line (option index 0) is selected on open. // Box position clamping is performed dynamically in computeNpcDialogLayout (0x6fb53580). this.npcMenu = { + npcId, npcName: descriptor.displayName, x: Math.round(screenX), y: Math.round(screenY), @@ -1003,68 +937,80 @@ export class WorldPanelsHud { return true } - openVendorForNpc( - descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string, - mode: 'trade' | 'gamble' = 'trade', - cLvl = 85, - difficulty: 0 | 1 | 2 | 'normal' | 'nightmare' | 'hell' = 0, - ): boolean { - const descriptor = - typeof descriptorOrName === 'object' - ? descriptorOrName - : typeof descriptorOrName === 'number' - ? getTownNpcDescriptorByVendorId(descriptorOrName) - : getTownNpcDescriptorByName(descriptorOrName) - if (!descriptor || descriptor.vendorId === null) return false + /** + * Show the store the server filled with `0x9C AddToShop` items (`ClientWorld.containers.shop`). + * `pages`: one entry per shop item with its bitstream `storePage` (1..4 -> tab index 0..3, D2Client + * 0x6fb91178 tab table). Re-applying the same store keeps the selected tab unless it became empty + * (D2Client 0x6fb3f710 advances to the next non-empty tab). + */ + setVendorShop(p: { + readonly npcId: number + readonly descriptor: TownNpcServiceDescriptor + readonly mode: 'trade' | 'gamble' + readonly items: readonly { readonly storePage: number; readonly placement: GridPlacement }[] + }): void { + // Fail fast against the D2Client.dll 0x6fb3f430 button layout table. + resolveVendorButtonLayout(p.descriptor.hcIdx, p.mode) + const sameStore = + this.vendorNpcId === p.npcId && + this.vendorMode === p.mode && + this.activeVendorDescriptor?.hcIdx === p.descriptor.hcIdx + if (!sameStore) { + this.vendorTradeState = 'idle' + this.hoveredVendorButtonSlot = null + this.pressedMomentarySlot = null + this.hoveredVendorItem = null + } + this.activeVendorDescriptor = p.descriptor + this.vendorNpcId = p.npcId + this.vendorMode = p.mode + const tabs: Record = { armor: [], weapons1: [], weapons2: [], misc: [] } + const gamble: GridPlacement[] = [] + for (const entry of p.items) { + if (p.mode === 'gamble') { + gamble.push(entry.placement) + continue + } + const tab = VENDOR_TABS[entry.storePage - 1] + if (!tab) throw new Error(`setVendorShop: shop item store page ${entry.storePage} is outside 1..4`) + tabs[tab.id].push(entry.placement) + } + this.vendorTabPlacements = tabs + this.gamblePlacements = gamble + if (p.mode === 'gamble') { + this.activeVendorTab = 'armor' + } else if (!sameStore) { + // D2Client.dll 0x6fb411a0 -> 0x6fb3f710(0): start at tab 0 and cycle to the first non-empty tab. + this.selectVendorTab('armor') + } else if (this.vendorTabPlacements[this.activeVendorTab].length === 0) { + this.selectVendorTab(this.activeVendorTab) + } + if (this.hoveredVendorItem && !this.getActiveVendorPlacements().some(pl => pl.item.id === this.hoveredVendorItem!.item.id)) { + this.hoveredVendorItem = null + } + } - const diffNum: 0 | 1 | 2 = - difficulty === 'hell' || difficulty === 2 - ? 2 - : difficulty === 'nightmare' || difficulty === 1 - ? 1 - : 0 - - // Fail-fast validation against D2Client.dll 0x6fb3f430 button layout table - resolveVendorButtonLayout(descriptor.hcIdx, mode) - - this.activeVendorDescriptor = descriptor - this.vendorMode = mode - this.vendorCharLevel = Math.max(1, cLvl) - this.vendorDifficulty = diffNum + /** Forget the store (NpcCancel sent: D2Client frees the store units, the server sends no RemoveFromShop). */ + clearVendorShop(): void { + this.activeVendorDescriptor = null + this.vendorNpcId = null + this.vendorMode = 'trade' this.vendorTradeState = 'idle' + this.vendorRepairAllCost = null this.hoveredVendorButtonSlot = null this.pressedMomentarySlot = null this.hoveredVendorItem = null + this.vendorTabPlacements = { armor: [], weapons1: [], weapons2: [], misc: [] } + this.gamblePlacements = [] + this.activeVendorTab = 'armor' + } - const tables = getEmbeddedDropTables() - const vendorId = descriptor.vendorId - - if (mode === 'gamble') { - const gambleInv = this.vendorSession.getOrCreateGambleStore(vendorId, this.vendorCharLevel) - this.gamblePlacements = gambleInv.getAllItems().map(p => { - const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, diffNum, this.vendorCharLevel, true) - const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, isGambleMystery: true }, tables) - return { item: uiItem, col: p.x, row: p.y } - }) - this.activeVendorTab = 'armor' - return true + /** Repair-all total set by `HudModel`; throws when the store has a Repair All button but no cost was provided. */ + requireRepairAllCost(): number { + if (this.vendorRepairAllCost === null) { + throw new Error('WorldPanelsHud: Repair All cost not provided (HudModel needs a VendorRepairAllFn)') } - - const store = this.vendorSession.getOrCreateVendorStore(vendorId, diffNum, this.vendorCharLevel) - const tabIds: VendorTabId[] = ['armor', 'weapons1', 'weapons2', 'misc'] - for (const tabId of tabIds) { - const inv = store.tabs[tabId] - this.vendorTabPlacements[tabId] = inv.getAllItems().map(p => { - const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, diffNum, this.vendorCharLevel, false) - const sellCost = this.vendorSession.getSellPrice(p.item, vendorId, diffNum, this.vendorCharLevel) - const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, sellCost }, tables) - return { item: uiItem, col: p.x, row: p.y } - }) - } - - // D2Client.dll 0x6fb411a0 -> 0x6fb3f710(0): start at tab 0 ('armor') and cycle to first non-empty tab - this.selectVendorTab('armor') - return true + return this.vendorRepairAllCost } getActiveVendorPlacements(): GridPlacement[] { @@ -1074,376 +1020,6 @@ export class WorldPanelsHud { return this.vendorTabPlacements[this.activeVendorTab] ?? [] } - getPlayerItemSellPrice(item: UiInventoryItem): number { - if (item.sellCost !== undefined) return item.sellCost - const vendorId = this.activeVendorDescriptor?.vendorId ?? (0 as VendorNpcId) - if (item.rawItem) { - return this.vendorSession.getSellPrice(item.rawItem, vendorId, this.vendorDifficulty, this.vendorCharLevel) - } - if ((item as any).baseCost !== undefined && Number.isFinite((item as any).baseCost)) { - return Math.max(1, Math.floor((item as any).baseCost / 2)) - } - return Math.max(1, Math.floor(150)) - } - - getPlayerItemRepairPrice(item: UiInventoryItem): number { - if (!item.durability || item.durability.current >= item.durability.max) return 0 - if (item.ethereal) return 0 - const vendorId = this.activeVendorDescriptor?.vendorId ?? (2 as VendorNpcId) - if (item.rawItem) { - const syncedRaw = { - ...item.rawItem, - durability: item.durability.current, - maxDurability: item.durability.max, - } - if (!isItemRepairable(syncedRaw)) return 0 - return this.vendorSession.getRepairPrice(syncedRaw, vendorId, this.vendorDifficulty, this.vendorCharLevel) - } - const lost = Math.max(0, item.durability.max - item.durability.current) - return Math.max(1, lost * 12) - } - - buyFromActiveVendor( - col: number, - row: number, - playerInventory: InventoryPanel, - isShiftClick = false, - ): { ok: boolean; reason?: string; boughtCount: number; totalCost: number } { - if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { - return makeVendorBuyResult(false, 0, 0, 'no_vendor') - } - const vendorId = this.activeVendorDescriptor.vendorId - const placements = this.getActiveVendorPlacements() - const hitIdx = placements.findIndex( - p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, - ) - if (hitIdx === -1) { - return makeVendorBuyResult(false, 0, 0, 'empty_cell') - } - const hit = placements[hitIdx]! - const unitCost = hit.item.buyCost ?? 1 - - if (playerInventory.gold < unitCost) { - this.showVendorStatus(this.lang === 'ENG' ? 'Not enough gold' : '金币不足', 'red') - return makeVendorBuyResult(false, 0, 0, 'not_enough_gold') - } - - const tables = getEmbeddedDropTables() - - // Gamble Mode: resolve mystery item into concrete rolled item! - if (this.vendorMode === 'gamble') { - const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight) - if (!freeSlot) { - this.showVendorStatus(this.lang === 'ENG' ? 'Inventory full' : '背包空间不足', 'red') - return makeVendorBuyResult(false, 0, 0, 'inventory_full') - } - const gambleRes = this.vendorSession.buyGambleItem( - vendorId, - hit.col, - hit.row, - playerInventory.gold, - this.vendorCharLevel, - ) - if (!gambleRes.success || !gambleRes.item) { - this.showVendorStatus(this.lang === 'ENG' ? 'Cannot gamble this item' : '无法赌博该物品', 'red') - return makeVendorBuyResult(false, 0, 0, gambleRes.error ?? 'gamble_failed') - } - playerInventory.gold = gambleRes.remainingGold - const sellCost = this.vendorSession.getSellPrice(gambleRes.item, vendorId, this.vendorDifficulty, this.vendorCharLevel) - const resolvedUi = itemToUiInventoryItem({ ...(gambleRes.item as any), sellCost }, tables) - playerInventory.gridItems.push({ item: resolvedUi, col: freeSlot.col, row: freeSlot.row }) - this.gamblePlacements.splice(hitIdx, 1) - this.showVendorStatus( - this.lang === 'ENG' ? `Gambled: ${resolvedUi.name}` : `赌博获得: ${resolvedUi.nameZh}`, - 'gold', - ) - return makeVendorBuyResult(true, 1, gambleRes.goldSpent) - } - - // Trade Mode: Shift-Click bulk buy for Scrolls/Potions/Keys - const isScrollOrPotionOrKey = - hit.item.permStore && - (hit.item.code === 'tsc' || - hit.item.code === 'isc' || - hit.item.code === 'key' || - hit.item.code === 'yps' || - hit.item.code === 'vps' || - hit.item.code === 'wms' || - hit.item.code.startsWith('hp') || - hit.item.code.startsWith('mp')) - - const maxCopies = isShiftClick && isScrollOrPotionOrKey ? 8 : 1 - let boughtCount = 0 - let totalCost = 0 - - for (let i = 0; i < maxCopies; i++) { - if (playerInventory.gold < unitCost) break - const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight) - if (!freeSlot) { - if (boughtCount === 0) { - this.showVendorStatus(this.lang === 'ENG' ? 'Inventory full' : '背包空间不足', 'red') - return makeVendorBuyResult(false, 0, 0, 'inventory_full') - } - break - } - playerInventory.gold -= unitCost - totalCost += unitCost - boughtCount++ - - const rawClone = hit.item.rawItem - ? { ...hit.item.rawItem, uniqueId: this.vendorRng.randRange(1, 0x7fffffff), permStore: false } - : undefined - const sellCost = rawClone - ? this.vendorSession.getSellPrice(rawClone, vendorId, this.vendorDifficulty, this.vendorCharLevel) - : Math.max(1, Math.floor(unitCost / 2)) - - const clonedUiItem: UiInventoryItem = { - ...hit.item, - id: `item-${hit.item.code}-v${++this.vendorBuySeq}-${this.vendorRng.randRange(10000, 99999)}`, - ...(rawClone ? { rawItem: rawClone } : {}), - sellCost, - } - delete (clonedUiItem as any).buyCost - delete (clonedUiItem as any).permStore - - playerInventory.gridItems.push({ item: clonedUiItem, col: freeSlot.col, row: freeSlot.row }) - - if (!hit.item.permStore) { - placements.splice(hitIdx, 1) - if (this.vendorMode === 'trade' && placements.length === 0) { - this.selectVendorTab(this.activeVendorTab) - } - break - } - } - - if (boughtCount > 0) { - this.showVendorStatus( - this.lang === 'ENG' - ? `Bought ${hit.item.name}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} Gold)` - : `已购买 ${hit.item.nameZh}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} 金币)`, - 'gold', - ) - return makeVendorBuyResult(true, boughtCount, totalCost) - } - return makeVendorBuyResult(false, 0, 0, 'purchase_failed') - } - - sellToActiveVendor( - rawItem: UiInventoryItem | Item, - playerInventory: InventoryPanel, - ): { ok: boolean; goldReceived: number } { - const item = rawItem as UiInventoryItem - if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { - return makeVendorSellResult(false, 0) - } - const vendorId = this.activeVendorDescriptor.vendorId - // Quest items cannot be sold - if (item.rawItem?.base?.quest && item.rawItem.base.quest > 0) { - this.showVendorStatus( - this.lang === 'ENG' ? 'Cannot sell quest item' : '任务物品无法出售', - 'red', - ) - return makeVendorSellResult(false, 0) - } - - const sellPrice = this.getPlayerItemSellPrice(item) - playerInventory.gold = Math.min( - getInventoryGoldLimit(playerInventory.playerLevel), - playerInventory.gold + sellPrice, - ) - - const invIdx = playerInventory.gridItems.findIndex( - p => p.item === item || (item.id !== undefined && p.item.id === item.id), - ) - if (invIdx !== -1) { - playerInventory.gridItems.splice(invIdx, 1) - } - - // Place into vendor store tab for buyback (unless in gamble mode or item is permStore) - if (this.vendorMode === 'trade' && !item.permStore) { - const allowed = Array.isArray(item.allowedSlots) ? item.allowedSlots : [] - const kind = - item.rawItem?.base?.kind ?? - (item as any).type ?? - (allowed.includes('weapon1') ? 'weapon' : allowed.length > 0 ? 'armor' : 'misc') - let targetTab: VendorTabId = kind === 'armor' ? 'armor' : kind === 'weapon' ? 'weapons1' : 'misc' - let tabPlacements = this.vendorTabPlacements[targetTab] - let freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10) - if (!freeSlot && targetTab === 'weapons1') { - targetTab = 'weapons2' - tabPlacements = this.vendorTabPlacements[targetTab] - freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10) - } - while (!freeSlot) { - const evictIdx = tabPlacements.findIndex(p => !p.item.permStore) - if (evictIdx === -1) break - tabPlacements.splice(evictIdx, 1) - freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10) - } - if (freeSlot) { - const buybackPrice = item.rawItem - ? this.vendorSession.getBuyPrice(item.rawItem, vendorId, this.vendorDifficulty, this.vendorCharLevel, false) - : Math.max(sellPrice * 2, 2) - const buybackUi: UiInventoryItem = { - ...item, - buyCost: buybackPrice, - sellCost: sellPrice, - permStore: false, - } - tabPlacements.push({ item: buybackUi, col: freeSlot.col, row: freeSlot.row }) - if (this.vendorTabPlacements[this.activeVendorTab].length === 0) { - this.selectVendorTab(targetTab) - } - } - } - - this.showVendorStatus( - this.lang === 'ENG' - ? `Sold ${item.name} (+${sellPrice} Gold)` - : `已出售 ${item.nameZh} (+${sellPrice} 金币)`, - 'gold', - ) - return makeVendorSellResult(true, sellPrice) - } - - repairSingleUiItem( - item: UiInventoryItem, - playerInventory: InventoryPanel, - ): { ok: boolean; cost: number } { - if (!this.activeVendorDescriptor?.canRepair) { - return { ok: false, cost: 0 } - } - if (!item.durability || item.durability.current >= item.durability.max) { - this.showVendorStatus( - this.lang === 'ENG' ? 'Item does not need repair' : '该物品无需修理', - 'tan', - ) - return { ok: false, cost: 0 } - } - if (item.ethereal) { - this.showVendorStatus( - this.lang === 'ENG' ? 'Cannot repair ethereal item' : '无形物品无法修理', - 'red', - ) - return { ok: false, cost: 0 } - } - const cost = this.getPlayerItemRepairPrice(item) - if (cost <= 0) return { ok: false, cost: 0 } - if (playerInventory.gold < cost) { - this.showVendorStatus( - this.lang === 'ENG' ? 'Not enough gold to repair' : '金币不足,无法修理', - 'red', - ) - return { ok: false, cost: 0 } - } - playerInventory.gold -= cost - ;(item as { durability: { current: number; max: number } }).durability = { - current: item.durability.max, - max: item.durability.max, - } - if (item.rawItem) { - item.rawItem.durability = item.durability.max - } - this.showVendorStatus( - this.lang === 'ENG' - ? `Repaired ${item.name} (-${cost} Gold)` - : `已修理 ${item.nameZh} (-${cost} 金币)`, - 'gold', - ) - return { ok: true, cost } - } - - private collectRepairablePlayerItems(playerInventory: InventoryPanel): { item: UiInventoryItem; cost: number }[] { - const allItems: UiInventoryItem[] = [ - ...(Object.values(playerInventory.equipped).filter(Boolean) as UiInventoryItem[]), - ...playerInventory.gridItems.map(p => p.item), - ] - const result: { item: UiInventoryItem; cost: number }[] = [] - for (const item of allItems) { - if (!item.durability || item.durability.current >= item.durability.max || item.ethereal) continue - const cost = this.getPlayerItemRepairPrice(item) - if (cost <= 0) continue - result.push({ item, cost }) - } - return result - } - - /** - * D2Client.dll `0x6fb3f260` -> D2Common `0x6fabc99a` (`#10095`): - * Computes total repair cost across all damaged repairable equipment and inventory items. - */ - getRepairAllCost(playerInventory?: InventoryPanel | null): number { - if (!this.activeVendorDescriptor?.canRepair) return 0 - const inv = playerInventory ?? this.activePlayerInventory - if (!inv) return 0 - let total = 0 - for (const entry of this.collectRepairablePlayerItems(inv)) { - total += entry.cost - } - return total - } - - repairAllUiItems(playerInventory: InventoryPanel): { repairedCount: number; totalCost: number } { - if (!this.activeVendorDescriptor?.canRepair) { - return { repairedCount: 0, totalCost: 0 } - } - let repairedCount = 0 - let totalCost = 0 - for (const { item, cost } of this.collectRepairablePlayerItems(playerInventory)) { - if ( !item.durability) continue - if (playerInventory.gold < cost) break - const maxDur = item.durability.max - playerInventory.gold -= cost - totalCost += cost - repairedCount++ - ;(item as { durability: { current: number; max: number } }).durability = { - current: maxDur, - max: maxDur, - } - if (item.rawItem) { - item.rawItem.durability = maxDur - } - } - if (repairedCount > 0) { - this.showVendorStatus( - this.lang === 'ENG' - ? `Repaired ${repairedCount} items (-${totalCost} Gold)` - : `已修理 ${repairedCount} 件装备 (-${totalCost} 金币)`, - 'gold', - ) - } else { - this.showVendorStatus( - this.lang === 'ENG' ? 'All equipment is at full durability' : '所有装备完好,无需修理', - 'tan', - ) - } - return { repairedCount, totalCost } - } - - identifyAllUiItems(playerInventory: InventoryPanel): number { - let count = 0 - for (const placed of playerInventory.gridItems) { - if (isUiItemUnidentified(placed.item)) { - identifyUiItem(placed.item) - count++ - } - } - for (const placed of this.stashItems) { - if (isUiItemUnidentified(placed.item)) { - identifyUiItem(placed.item) - count++ - } - } - for (const placed of this.cubeItems) { - if (isUiItemUnidentified(placed.item)) { - identifyUiItem(placed.item) - count++ - } - } - return count - } - /** Runtime quest status overrides: questId -> 'completed' | 'active' | 'locked' */ private questStatuses = new Map() private cachedQuestsAtlasImg: HTMLImageElement | null = null @@ -1962,36 +1538,6 @@ export class WorldPanelsHud { return true } - depositGold(amount: number, playerInventory: { gold: number }): number { - if (amount <= 0) return 0 - if (!this._stashGoldExplicitlySet) { - this._stashGold = 0 - this._stashGoldExplicitlySet = true - } - const available = Math.max(0, Math.min(amount, playerInventory.gold, STASH_GOLD_CAP - this._stashGold)) - if (available <= 0) return 0 - playerInventory.gold -= available - this._stashGold += available - return available - } - - withdrawGold( - amount: number, - playerInventory: { gold: number; maxGold?: number; _playerLevelExplicitlySet?: boolean }, - ): number { - if (amount <= 0) return 0 - const cap = - playerInventory._playerLevelExplicitlySet && - playerInventory.maxGold !== undefined - ? playerInventory.maxGold - : PLAYER_GOLD_CAP - const available = Math.max(0, Math.min(amount, this._stashGold, cap - playerInventory.gold)) - if (available <= 0) return 0 - this._stashGold -= available - playerInventory.gold += available - return available - } - handleMouseMove(logicalX: number, logicalY: number): void { this.hoveredStashItem = null this.hoveredCubeItem = null @@ -2140,6 +1686,8 @@ export class WorldPanelsHud { callbacks: { onOpenVendor: (descriptor: TownNpcServiceDescriptor, mode: 'trade' | 'gamble') => void onIdentifyAll: (descriptor: TownNpcServiceDescriptor) => void + /** Menu dismissed (`cancel` option or click outside): the caller sends `NpcCancel` (0x30). */ + onCancel: () => void }, font?: Pick | null, ): boolean { @@ -2165,6 +1713,7 @@ export class WorldPanelsHud { logicalY > layout.top + layout.height ) { this.npcMenu = null + callbacks.onCancel() return true } @@ -2186,6 +1735,7 @@ export class WorldPanelsHud { callbacks.onIdentifyAll(desc) } else { this.npcMenu = null + callbacks.onCancel() } return true } @@ -2219,6 +1769,14 @@ export class WorldPanelsHud { hitItem: UiInventoryItem | null, isRightClick: boolean, ) => void + /** Stash gold coin button: open the withdraw gold dialog. */ + onStashGoldButton?: () => void + /** Vendor grid click on a store item (buy / gamble). `shiftFill`: Shift held (0x32 shift-fill flag). */ + onVendorBuy?: (item: UiInventoryItem, shiftFill: boolean) => void + /** Vendor grid click while holding a cursor item: sell it (0x33). */ + onVendorSell?: (item: UiInventoryItem) => void + /** Repair All button (0x35 with field4 = 0x80000000). */ + onVendorRepairAll?: () => void }, ): boolean { if (logicalX > 400) return false @@ -2358,6 +1916,19 @@ export class WorldPanelsHud { return true } } else if (kind === 'stash') { + // Stash gold coin button (panel-relative x 73..150, y 24..41): opens the withdraw dialog. + if ( + logicalX >= ox + STASH_GOLD_BTN_HIT.relX0 && + logicalX <= ox + STASH_GOLD_BTN_HIT.relX1 && + logicalY >= oy + STASH_GOLD_BTN_HIT.relY0 && + logicalY <= oy + STASH_GOLD_BTN_HIT.relY1 + ) { + if (!callbacks.isRightClick && this.stashGold > 0) { + if (!callbacks.onStashGoldButton) throw new Error('handleLeftDockClick: stash gold button needs onStashGoldButton') + callbacks.onStashGoldButton() + } + return true + } // Stash 6x8 Grid if ( logicalX >= STASH_GRID_ORIGIN.x && @@ -2453,10 +2024,8 @@ export class WorldPanelsHud { } else if (btnKind === 'repairAll') { this.vendorTradeState = 'idle' this.pressedMomentarySlot = hitSlot - const inv = callbacks.inventory ?? this.activePlayerInventory - if (inv) { - this.repairAllUiItems(inv) - } + if (!callbacks.onVendorRepairAll) throw new Error('handleLeftDockClick: Repair All needs onVendorRepairAll') + callbacks.onVendorRepairAll() } else if (btnKind === 'close') { this.vendorTradeState = 'idle' this.pressedMomentarySlot = hitSlot @@ -2480,21 +2049,20 @@ export class WorldPanelsHud { const row = Math.floor((logicalY - VENDOR_GRID_ORIGIN.y) / VENDOR_GRID_ORIGIN.cellPx) const inv = callbacks.inventory ?? this.activePlayerInventory - if (inv) { - // If player is holding an item on cursor and clicks inside Vendor grid -> Sell it! - if (inv.cursorItem) { - const held = inv.cursorItem - const res = this.sellToActiveVendor(held, inv) - if (res.ok) { - inv.cursorItem = null - } - return true - } - - // Left-click buy works in 'idle' or 'buy' state; do not accidentally buy when in 'sell' or 'repair' state - if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') { - this.buyFromActiveVendor(col, row, inv, Boolean(callbacks.isShiftClick)) - this.handleMouseMove(logicalX, logicalY) + // Holding a cursor item over the store grid sells it (server answers with 0x2A + item/stat packets). + if (inv?.cursorItem) { + if (!callbacks.onVendorSell) throw new Error('handleLeftDockClick: vendor sell needs onVendorSell') + callbacks.onVendorSell(inv.cursorItem) + return true + } + // Buy works in 'idle' or 'buy' state; never buy while the sell / repair cursor is armed. + if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') { + const hit = this.getActiveVendorPlacements().find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (hit) { + if (!callbacks.onVendorBuy) throw new Error('handleLeftDockClick: vendor buy needs onVendorBuy') + callbacks.onVendorBuy(hit.item, Boolean(callbacks.isShiftClick)) } } return true @@ -2652,14 +2220,14 @@ export class WorldPanelsHud { } } else if (kind === 'stash') { if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy) - font.drawText(ctx, isEn ? 'Private Stash' : '私人储物箱', ox + 172, oy + 35, { + font.drawText(ctx, requireTbl('bank', this.lang), ox + 172, oy + 35, { font: 'font8', color: 'gold', align: 'center', }) font.drawText( ctx, - isEn ? `Stash Gold: ${this.stashGold.toLocaleString()}` : `储物箱金币: ${this.stashGold.toLocaleString()}`, + `${requireTbl('strGoldInStash', this.lang)} ${this.stashGold}`, ox + 160, oy + 61, { @@ -2918,9 +2486,7 @@ export class WorldPanelsHud { if (spec.enabled) { const hoverText = btnKind === 'repairAll' - ? isEn - ? `${spec.labelEn}: ${this.getRepairAllCost(assets.inventory)}` - : `${spec.labelZh}:${this.getRepairAllCost(assets.inventory)}` + ? requireTbl('patchstring#95', this.lang).replace('%d', String(this.requireRepairAllCost())) : isEn ? spec.labelEn : spec.labelZh @@ -3290,35 +2856,16 @@ export class WorldPanelsHud { } // Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0) + // Prices come from HudModel's injected VendorPriceFn (buyCost / sellCost / repairCost on the UI item). if (item.buyCost !== undefined) { - const label = item.isGambleMystery - ? (isEn ? 'Gamble Cost' : '赌博价格') - : (isEn ? 'Buy Cost' : '购买价格') - const suffix = isEn ? ' Gold' : ' 金币' - lines.push({ text: `${label}: ${item.buyCost.toLocaleString()}${suffix}`, color: 'gold', font: 'font8' }) + lines.push({ text: `${requireTbl('cost', this.lang)}${item.buyCost}`, color: 'white', font: 'font8' }) } else if (vendorOpen && this.activeVendorDescriptor) { if (this.repairCursorMode && this.activeVendorDescriptor.canRepair) { - const repPrice = this.getPlayerItemRepairPrice(item) - if (repPrice > 0) { - lines.push({ - text: isEn - ? `Repair Cost: ${repPrice.toLocaleString()} Gold` - : `修理费用: ${repPrice.toLocaleString()} 金币`, - color: 'gold', - font: 'font8', - }) - } - } else { - const sellPrice = this.getPlayerItemSellPrice(item) - if (sellPrice > 0) { - lines.push({ - text: isEn - ? `Sell Value: ${sellPrice.toLocaleString()} Gold` - : `出售价格: ${sellPrice.toLocaleString()} 金币`, - color: 'gold', - font: 'font8', - }) + if (item.repairCost !== undefined && item.repairCost > 0) { + lines.push({ text: `${requireTbl('Repair', this.lang)}${item.repairCost}`, color: 'white', font: 'font8' }) } + } else if (item.sellCost !== undefined) { + lines.push({ text: `${requireTbl('Sell', this.lang)}${item.sellCost}`, color: 'white', font: 'font8' }) } } diff --git a/src/common/data/canonical-tbl-strings.ts b/src/common/data/canonical-tbl-strings.ts index 6f76ef7..ae159f9 100644 --- a/src/common/data/canonical-tbl-strings.ts +++ b/src/common/data/canonical-tbl-strings.ts @@ -295,6 +295,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Baal Throne": "Baal", "BaalColdMage": "Death Mage", "Baals Minion": "Minion of Destruction", + "Back": "cancel", "Baezil's Vortex": "Baezil's Vortex", "Bahamut's": "Bahamut's", "Balrog": "Balrog", @@ -1779,6 +1780,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Realgar": "Realgar", "ReanimatedHorde": "Reanimated Horde", "Red": "Red", + "Repair": "Repair cost: ", "Resonant": "Resonant", "Returned": "Returned", "ReturnedArcher": "Returned Archer", @@ -2018,6 +2020,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Scintillating": "Scintillating", "Scorching": "Condensing", "Screaming": "Screaming", + "Sell": "Sell value: ", "Sensei's": "Sensei's", "Septic": "Septic", "Seraph's Hymn": "Seraph's Hymn", @@ -2647,6 +2650,13 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Trang-Oul's Guise": "Trang-Oul's Guise", "Trang-Oul's Scales": "Trang-Oul's Scales", "Trang-Oul's Wing": "Trang-Oul's Wing", + "TransactionResults10": "You do not have enough gold for that.", + "TransactionResults11": "She cannot come right now. Try again later.", + "TransactionResults4": "You do not have enough free space to do that.", + "TransactionResults5": "You already have the maximum number of Mercenaries.", + "TransactionResults6": "That item has just been traded.", + "TransactionResults8": "Something tells me that you do not have that item.", + "TransactionResults9": "I cannot complete that request.", "TrappedSoul": "Trapped Soul", "Travincal": "Travincal", "Treads of Cthon": "Treads of Cthon", @@ -2911,6 +2921,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "collar": "Collar", "compellingorb": "Compelling Orb", "cord": "Cord", + "cost": "Cost: ", "cowl": "Cowl", "cqv": "Bolts", "crack": "Crack", @@ -3471,6 +3482,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "of the Wolf": "of the Wolf", "of the Wraith": "of the Wraith", "of the Yeti": "of the Yeti", + "ok": "ok", "opl": "Fulminating Potion", "opm": "Exploding Potion", "ops": "Oil Potion", @@ -3782,7 +3794,10 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "strBSArmor": "Armor", "strBSMisc": "Misc", "strBSWeapons": "Weapons", + "strBankGoldDeposit": "How much Gold would you like to deposit?", + "strBankGoldWithdraw": "How much Gold would you like to withdraw?", "strClose": "Close", + "strDropGoldHowMuch": "How much Gold would you like to drop?", "strGoldInStash": "Gold in Stash:", "strGoldLabel": "Gold", "strModAllResistances": "All Resistances +%d", @@ -4000,7 +4015,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "uniquestoneskin": "Stone Skin", "uniquemultishot": "Multiple Shots", "UniqueAuraEnchanted": "Aura Enchanted", - "strethereal": "Ethereal (Cannot be Repaired)" + "strethereal": "Ethereal (Cannot be Repaired)", + "patchstring#95": "Repair all equipment: %d" } export const CANONICAL_CHI_TBL_DICT: Readonly> = { @@ -4261,6 +4277,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Baal Throne": "巴尔", "BaalColdMage": "死亡法师", "Baals Minion": "毁灭的仆从", + "Back": "取消", "Baezil's Vortex": "贝西尔的漩涡", "Balrog": "巴羅格", "Bane": "剋星", @@ -5611,6 +5628,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Razortine": "剃刀之叉", "ReanimatedHorde": " ", "Red": "紅之", + "Repair": "修復費用: ", "Returned": "復活的怪物", "ReturnedArcher": "復生的弓箭手", "ReturnedMage": "復生的法師", @@ -5682,6 +5700,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Sazabi's Mental Sheath": "沙撒璧的精神之鞘", "Scarab": "聖甲蟲", "Schaefer's Hammer": "史恰佛之锤", + "Sell": "出售價格: ", "Seraph's Hymn": "炽天使之韵", "Serpent Lord": "海蛇之王", "Serpent's": "海蛇之", @@ -6249,6 +6268,13 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Trang-Oul's Guise": "塔格奥之外装", "Trang-Oul's Scales": "塔格奥之鳞", "Trang-Oul's Wing": "塔格奥之翼", + "TransactionResults10": "你沒有足夠的金錢。", + "TransactionResults11": "她現在無法過來,請等一會再試。", + "TransactionResults4": "你沒有足夠的放置空間。", + "TransactionResults5": "你的傭兵數量已經到達最高人數。", + "TransactionResults6": "這項物品剛剛交易掉了。", + "TransactionResults8": "有些事指出你並非擁有該項物品。", + "TransactionResults9": "我無法完成那個要求。", "TrappedSoul": "被困的靈魂", "Travincal": "崔凡克", "Treads of Cthon": "凱松的征服", @@ -6495,6 +6521,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "collar": "項圈", "compellingorb": "強制球體", "cord": "捆綁", + "cost": "花費:", "cowl": "蒙頭斗蓬", "cqv": "十字弓彈", "crack": "破裂", @@ -6858,6 +6885,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "of the Vampire": "吸血鬼的", "of the Wolf": "狼的", "of the Wraith": "死靈", + "ok": "好", "opl": "猛爆性葯劑", "opm": "爆炸葯劑", "ops": "油", @@ -7169,7 +7197,10 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "strBSArmor": "裝甲", "strBSMisc": "其他", "strBSWeapons": "武器", + "strBankGoldDeposit": "你打算存入多少數量的金幣?", + "strBankGoldWithdraw": "你打算提出多少數量的金幣?", "strClose": "關閉", + "strDropGoldHowMuch": "你想要丟下多少數量的金幣?", "strGoldInStash": "儲藏起來的金錢:", "strGoldLabel": "金幣", "strModAllResistances": "所有抗性 +%d", @@ -7387,7 +7418,8 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "uniquestoneskin": "皮膚硬化", "uniquemultishot": "多重射擊", "UniqueAuraEnchanted": "靈氣加強", - "strethereal": "无形的 (无法修复)" + "strethereal": "无形的 (无法修复)", + "patchstring#95": "修復所有裝備:%d" } export const CANONICAL_ISC_DESC_TABLE: Readonly> = { diff --git a/src/common/items/inventory-model.ts b/src/common/items/inventory-model.ts index 53d67be..d108adf 100644 --- a/src/common/items/inventory-model.ts +++ b/src/common/items/inventory-model.ts @@ -13,9 +13,10 @@ export const INV_COLS = 10 export const INV_ROWS = 4 /** - * Authoritative Diablo II v1.13c Shared Stash Gold Cap (`D2Common.dll` max gold in stash = 2,500,000). + * Diablo II v1.13c stash gold cap (`D2Common.dll` ordinal #11060 returns 2,500,000). + * The character inventory has no fixed cap; see {@link getInventoryGoldLimit}. */ -export const PLAYER_GOLD_CAP = 2_500_000 +export const STASH_GOLD_CAP = 2_500_000 /** * Authentic Diablo II 1.13c Inventory Gold Limit (`D2Common/src/Units/Units.cpp:3103`). diff --git a/tests/challenger-m5-stash-entities-gold.test.ts b/tests/challenger-m5-stash-entities-gold.test.ts index f8b7d19..64771e7 100644 --- a/tests/challenger-m5-stash-entities-gold.test.ts +++ b/tests/challenger-m5-stash-entities-gold.test.ts @@ -3,7 +3,6 @@ import { CANONICAL_TOWN_STASHES, type TownStashEntity } from '../src/common/worl import { WorldPanelsHud, STASH_GOLD_CAP, - PLAYER_GOLD_CAP, } from '../src/client/ui/world-panels.ts' import { HudManager } from '../src/client/ui/hud-manager.ts' import { GameEngine } from '../src/server/engine/engine.ts' @@ -130,181 +129,12 @@ describe('Challenger M5 Empirical Stress Test: Town Stash Entities, Interaction panels.stashGold = 100_000 }) - it('deposit and withdraw valid amounts: character gold + stash gold remains strictly constant', () => { - const initialTotal = inv.gold + panels.stashGold // 600,000 - - const transferSequence = [10_000, 50_000, 100_000, 25_000, 1_000, 777] - for (const amt of transferSequence) { - const deposited = panels.depositGold(amt, inv) - expect(deposited).toBe(amt) - expect(inv.gold + panels.stashGold).toBe(initialTotal) - } - - const withdrawSequence = [5_000, 20_000, 80_000, 500, 123] - for (const amt of withdrawSequence) { - const withdrawn = panels.withdrawGold(amt, inv) - expect(withdrawn).toBe(amt) - expect(inv.gold + panels.stashGold).toBe(initialTotal) - } - }) - - it('stress tests 100 random valid transactions preserving gold conservation law', () => { - const initialTotal = inv.gold + panels.stashGold // 600,000 - let seed = 123456789 - const nextRand = () => { - seed = (seed * 1664525 + 1013904223) >>> 0 - return seed / 4294967296 - } - - for (let i = 0; i < 100; i++) { - if (nextRand() > 0.5) { - // Deposit random amount up to player's current gold - const amt = Math.floor(nextRand() * (inv.gold + 1)) - panels.depositGold(amt, inv) - } else { - // Withdraw random amount up to stash's current gold - const amt = Math.floor(nextRand() * (panels.stashGold + 1)) - panels.withdrawGold(amt, inv) - } - // Strict conservation invariant: - expect(inv.gold + panels.stashGold).toBe(initialTotal) - expect(inv.gold).toBeGreaterThanOrEqual(0) - expect(panels.stashGold).toBeGreaterThanOrEqual(0) - } - }) - - it('negative values (e.g. -500 gold): must be rejected or no-op without balance mutation', () => { - const initialInv = inv.gold - const initialStash = panels.stashGold - - const negativeAmounts = [-1, -500, -10_000, -2_500_000, -1e9, Number.MIN_SAFE_INTEGER] - for (const neg of negativeAmounts) { - expect(panels.depositGold(neg, inv)).toBe(0) - expect(inv.gold).toBe(initialInv) - expect(panels.stashGold).toBe(initialStash) - - expect(panels.withdrawGold(neg, inv)).toBe(0) - expect(inv.gold).toBe(initialInv) - expect(panels.stashGold).toBe(initialStash) - } - }) - - it('zero and -0 value transfers: must be rejected as no-op', () => { - expect(panels.depositGold(0, inv)).toBe(0) - expect(panels.depositGold(-0, inv)).toBe(0) - expect(panels.withdrawGold(0, inv)).toBe(0) - expect(panels.withdrawGold(-0, inv)).toBe(0) - expect(inv.gold).toBe(500_000) - expect(panels.stashGold).toBe(100_000) - }) - - it('exceeding player gold on deposit: deposits max available without creating negative player gold', () => { - inv.gold = 35_000 - panels.stashGold = 50_000 - - const deposited = panels.depositGold(100_000, inv) - expect(deposited).toBe(35_000) - expect(inv.gold).toBe(0) - expect(panels.stashGold).toBe(85_000) - - // Further deposit when player gold is 0 yields 0 - expect(panels.depositGold(10_000, inv)).toBe(0) - expect(inv.gold).toBe(0) - expect(panels.stashGold).toBe(85_000) - }) - - it('exceeding stash gold on withdraw: withdraws max available without creating negative stash gold', () => { - panels.stashGold = 42_000 - inv.gold = 10_000 - - const withdrawn = panels.withdrawGold(100_000, inv) - expect(withdrawn).toBe(42_000) - expect(panels.stashGold).toBe(0) - expect(inv.gold).toBe(52_000) - - // Further withdrawal when stash gold is 0 yields 0 - expect(panels.withdrawGold(10_000, inv)).toBe(0) - expect(panels.stashGold).toBe(0) - expect(inv.gold).toBe(52_000) - }) - - it('gold cap enforcement: character and stash gold cannot exceed 2,500,000', () => { + it('stash gold cap is the D2Common #11060 constant (2,500,000); gold transfers are server-driven', () => { expect(STASH_GOLD_CAP).toBe(2_500_000) - expect(PLAYER_GOLD_CAP).toBe(2_500_000) - - // Stash cap clamping on deposit - panels.stashGold = 2_450_000 - inv.gold = 200_000 - const dep1 = panels.depositGold(100_000, inv) - expect(dep1).toBe(50_000) - expect(panels.stashGold).toBe(2_500_000) - expect(inv.gold).toBe(150_000) - - // Further deposits clamped to 0 - expect(panels.depositGold(50_000, inv)).toBe(0) - expect(panels.stashGold).toBe(2_500_000) - expect(inv.gold).toBe(150_000) - - // Player cap clamping on withdrawal - inv.gold = 2_480_000 - panels.stashGold = 500_000 - const w1 = panels.withdrawGold(100_000, inv) - expect(w1).toBe(20_000) - expect(inv.gold).toBe(2_500_000) - expect(panels.stashGold).toBe(480_000) - - // Further withdrawals clamped to 0 - expect(panels.withdrawGold(10_000, inv)).toBe(0) - expect(inv.gold).toBe(2_500_000) - expect(panels.stashGold).toBe(480_000) - }) - - it('boundary caps: handles both character and stash at 2,500,000 or both at 0', () => { - // Both at zero - inv.gold = 0 - panels.stashGold = 0 - expect(panels.depositGold(10_000, inv)).toBe(0) - expect(panels.withdrawGold(10_000, inv)).toBe(0) - expect(inv.gold).toBe(0) - expect(panels.stashGold).toBe(0) - - // Both at maximum 2,500,000 - inv.gold = 2_500_000 - panels.stashGold = 2_500_000 - expect(panels.depositGold(10_000, inv)).toBe(0) - expect(panels.withdrawGold(10_000, inv)).toBe(0) - expect(inv.gold).toBe(2_500_000) - expect(panels.stashGold).toBe(2_500_000) - }) - - it('infinity and extreme numbers: safely clamps or rejects without corrupting finite state', () => { - inv.gold = 300_000 - panels.stashGold = 100_000 - - // Deposit Infinity: clamped to player's entire available gold - const depInf = panels.depositGold(Infinity, inv) - expect(depInf).toBe(300_000) - expect(inv.gold).toBe(0) - expect(panels.stashGold).toBe(400_000) - - // Withdraw Infinity: clamped to stash's entire available gold - const wInf = panels.withdrawGold(Infinity, inv) - expect(wInf).toBe(400_000) - expect(inv.gold).toBe(400_000) - expect(panels.stashGold).toBe(0) - - // Negative infinity: rejected as <= 0 - expect(panels.depositGold(-Infinity, inv)).toBe(0) - expect(panels.withdrawGold(-Infinity, inv)).toBe(0) - expect(inv.gold).toBe(400_000) - expect(panels.stashGold).toBe(0) - - // Number.MAX_SAFE_INTEGER - inv.gold = 50_000 - const depMaxSafe = panels.depositGold(Number.MAX_SAFE_INTEGER, inv) - expect(depMaxSafe).toBe(50_000) - expect(inv.gold).toBe(0) - expect(panels.stashGold).toBe(50_000) + // WorldPanelsHud no longer mutates gold locally: deposit/withdraw are sent as + // ClickButton commands by HudModel and the balance arrives via server stat updates. + expect((panels as unknown as Record)['depositGold']).toBeUndefined() + expect((panels as unknown as Record)['withdrawGold']).toBeUndefined() }) it('does not intercept bottom keyhole ornament clicks as non-authentic deposit/withdraw buttons', () => { diff --git a/tests/client/vendor-stash-ui.test.ts b/tests/client/vendor-stash-ui.test.ts new file mode 100644 index 0000000..aa82e57 --- /dev/null +++ b/tests/client/vendor-stash-ui.test.ts @@ -0,0 +1,357 @@ +/** + * 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, 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() + 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/) + }) +}) diff --git a/tests/cursor.test.ts b/tests/cursor.test.ts index e44274f..a1ec9ba 100644 --- a/tests/cursor.test.ts +++ b/tests/cursor.test.ts @@ -31,7 +31,8 @@ import { type CursorFrameMeta, } from '../src/common/items/cursor-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' -import { HudManager } from '../src/client/ui/hud-manager.ts' +import { HudManager, type HudCommandSink } from '../src/client/ui/hud-manager.ts' +import { openGeneratedVendorShop } from './helpers/vendor-shop-fixture.ts' import { VENDOR_BUTTON_TABLE_X } from '../src/client/ui/world-panels.ts' import { seedDemoHud } from './fixtures/hud-demo-state.ts' @@ -460,13 +461,14 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => { it('switches cursor to buysell.dc6 frame 3 (Buy), frame 4 (Sell), and frame 1 (Repair) when clicking vendor buttons, and resets on toggle/right-click/close', () => { const { hud, clickAt } = createTestHud() - // Server-not-ready HUD (no class assigned) refuses to open a vendor session - expect(() => hud.openVendorSession('Charsi', 'trade', 0)).toThrow(/character class/) // Explicitly seed the legacy demo Sorceress state (class, level, gold, inventory) seedDemoHud(hud) + const closeNpc = vi.fn() + hud.commandSink = { closeNpc } as unknown as HudCommandSink - // Open Charsi vendor panel - expect(hud.openVendorSession('Charsi', 'trade', 0)).toBe(true) + // Server opened Charsi's store (0x9C AddToShop items + NpcOpenTrade): HudModel fills and shows it + openGeneratedVendorShop(hud.worldPanels, 'Charsi', 'trade', 24, 0, 7) + hud.showVendorPanel() expect(hud.state.leftPanel).toBe('vendor') expect(hud.state.vendorTradeState).toBe('idle') expect(hud.state.cursorTradeMode).toBeNull() @@ -512,6 +514,7 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => { expect(hud.state.cursorTradeMode).toBe('sell') hud.closeAllPanels() expect(hud.state.leftPanel).toBe('none') + expect(closeNpc).toHaveBeenCalledWith(7) expect(hud.state.cursorTradeMode).toBeNull() expect(hud.state.cursorAnimation).toBe('protate') }) diff --git a/tests/ground-items-drop.test.ts b/tests/ground-items-drop.test.ts index cea9477..63de0f6 100644 --- a/tests/ground-items-drop.test.ts +++ b/tests/ground-items-drop.test.ts @@ -235,21 +235,7 @@ describe('Issue #388 — Ground Item & Drop System Ground Truth', () => { }) }) - describe('4. InventoryPanel Gold Dropping & findFreeGridSlot', () => { - it('InventoryPanel.dropGold deducts gold from player inventory', () => { - const panel = new InventoryPanel() - panel.gold = 50_000 - - const dropped = panel.dropGold(10_000) - expect(dropped).toBe(10_000) - expect(panel.gold).toBe(40_000) - - // Cannot drop more than available gold - const dropExcess = panel.dropGold(100_000) - expect(dropExcess).toBe(40_000) - expect(panel.gold).toBe(0) - }) - + describe('4. InventoryPanel findFreeGridSlot', () => { it('findFreeGridSlot returns first available (col, row) without overlapping existing items', () => { const gridItems: GridPlacement[] = [ { diff --git a/tests/helpers/vendor-shop-fixture.ts b/tests/helpers/vendor-shop-fixture.ts new file mode 100644 index 0000000..21a9ea4 --- /dev/null +++ b/tests/helpers/vendor-shop-fixture.ts @@ -0,0 +1,45 @@ +/** + * Test fixture: fill a `WorldPanelsHud` store the way `HudModel.syncNpcInteraction` does after the + * server streamed `0x9C AddToShop` items, but with items generated offline by the common vendor + * generator (D2Game store rolls) so panel/draw tests keep realistic content without a live server. + */ +import { VendorSessionManager } from '../../src/common/items/vendor-service.ts' +import { getEmbeddedDropTables } from '../../src/common/items/embedded-drop-tables.ts' +import { itemToUiInventoryItem } from '../../src/common/items/item-bridge.ts' +import { getTownNpcDescriptorByName } from '../../src/common/units/npc-table.ts' +import { VENDOR_TABS, type WorldPanelsHud } from '../../src/client/ui/world-panels.ts' + +export function openGeneratedVendorShop( + wp: WorldPanelsHud, + npcName: string, + mode: 'trade' | 'gamble' = 'trade', + cLvl = 85, + difficulty: 0 | 1 | 2 = 0, + npcId = 1, +): void { + const descriptor = getTownNpcDescriptorByName(npcName) + if (!descriptor || descriptor.vendorId === null) { + throw new Error(`openGeneratedVendorShop: ${npcName} is not a vendor NPC`) + } + const tables = getEmbeddedDropTables() + const session = new VendorSessionManager(tables) + const vendorId = descriptor.vendorId + const items: { storePage: number; placement: { item: ReturnType; col: number; row: number } }[] = [] + if (mode === 'gamble') { + for (const p of session.getOrCreateGambleStore(vendorId, cLvl).getAllItems()) { + const buyCost = session.getBuyPrice(p.item, vendorId, difficulty, cLvl, true) + const item = itemToUiInventoryItem({ ...p.item, buyCost, isGambleMystery: true } as typeof p.item, tables) + items.push({ storePage: 1, placement: { item, col: p.x, row: p.y } }) + } + } else { + const store = session.getOrCreateVendorStore(vendorId, difficulty, cLvl) + VENDOR_TABS.forEach((tab, idx) => { + for (const p of store.tabs[tab.id].getAllItems()) { + const buyCost = session.getBuyPrice(p.item, vendorId, difficulty, cLvl, false) + const item = itemToUiInventoryItem({ ...p.item, buyCost } as typeof p.item, tables) + items.push({ storePage: idx + 1, placement: { item, col: p.x, row: p.y } }) + } + }) + } + wp.setVendorShop({ npcId, descriptor, mode, items }) +} diff --git a/tests/issue-503-bilingual-parity.test.ts b/tests/issue-503-bilingual-parity.test.ts index 6f5abb9..1a15cfa 100644 --- a/tests/issue-503-bilingual-parity.test.ts +++ b/tests/issue-503-bilingual-parity.test.ts @@ -43,6 +43,7 @@ import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts' import { InventoryPanel, itemToUiInventoryItem } from '../src/client/ui/inventory.ts' import { SkillTreePanel } from '../src/client/ui/skill-tree-panel.ts' import { ACT_QUESTS, ACT_WAYPOINTS, WorldPanelsHud } from '../src/client/ui/world-panels.ts' +import { openGeneratedVendorShop } from './helpers/vendor-shop-fixture.ts' import { ControlBarHud } from '../src/client/ui/control-bar.ts' import { GlobesHud } from '../src/client/ui/globes.ts' import { SkillHotkeysHud } from '../src/client/ui/hotkeys.ts' @@ -450,7 +451,7 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () expect(textCalls).toContain('剩余技能点') expect(textCalls).toContain('角色属性 (A/C)') expect(textCalls).toContain('罗格营地') - expect(textCalls).toContain('私人储物箱') + expect(textCalls).toContain('你的私人儲藏箱') // tbl key 'bank' (CHI) expect(textCalls).toContain('赫拉迪克方块 (已合成 0 次)') expect(textCalls).not.toContain('角色属性 (Character)') @@ -606,7 +607,7 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () // Also test Charsi vendor inventory across all 4 tabs const wpVendor = new WorldPanelsHud() wpVendor.lang = 'ENG' - wpVendor.openVendorForNpc('Charsi', 'trade', 85, 0) + openGeneratedVendorShop(wpVendor, 'Charsi', 'trade', 85, 0) for (const tabId of ['armor', 'weapons1', 'weapons2', 'misc'] as const) { for (const p of wpVendor.vendorTabPlacements[tabId]) { const { ctx, font, textCalls } = createMockCanvasAndFont() diff --git a/tests/issue-504-exocet-fonts.test.ts b/tests/issue-504-exocet-fonts.test.ts index 78d83dd..0b01ffc 100644 --- a/tests/issue-504-exocet-fonts.test.ts +++ b/tests/issue-504-exocet-fonts.test.ts @@ -510,6 +510,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => { // NPC talking dialog body also uses fontformal12 while header uses font16 drawnCalls.length = 0 worldPanels.npcMenu = { + npcId: null, npcName: 'Akara', x: 400, y: 260, @@ -711,6 +712,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => { } as unknown as D2FontRenderer worldPanels.npcMenu = { + npcId: null, npcName: '阿卡拉', x: 400, y: 260, diff --git a/tests/item-to-ui-bridge.test.ts b/tests/item-to-ui-bridge.test.ts index 1a8b069..d86aed4 100644 --- a/tests/item-to-ui-bridge.test.ts +++ b/tests/item-to-ui-bridge.test.ts @@ -8,7 +8,7 @@ * 3. BodyLoc1/BodyLoc2 Equipment Slots Resolution (allowedSlots) with category inheritance * 4. Bilingual naming (nameZh, baseNameZh) and rich tooltip stats formatting * 5. Normal and Telekinesis ground item pickup conversion via itemToUiInventoryItem - * 6. Gold accumulation clamped to PLAYER_GOLD_CAP (2,500,000) + * 6. Gold accumulation clamped to the level-based inventory gold limit (getInventoryGoldLimit) * 7. Inventory full refusal bounce, audio feedback, and notification retention */ @@ -23,7 +23,6 @@ import { import { InventoryPanel, resolveItemSpriteRect, - PLAYER_GOLD_CAP, } from '../src/client/ui/inventory.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { GameEngine } from '../src/server/engine/engine.ts' @@ -423,7 +422,7 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & }) }) - describe('6. Gold Accumulation & PLAYER_GOLD_CAP Parity', () => { + describe('6. Gold Accumulation & Inventory Gold Limit Parity', () => { let mockCanvas: any let mockStatus: { textContent: string } diff --git a/tests/monster-drop-e2e.test.ts b/tests/monster-drop-e2e.test.ts index 560e1ea..0505df9 100644 --- a/tests/monster-drop-e2e.test.ts +++ b/tests/monster-drop-e2e.test.ts @@ -22,7 +22,7 @@ * - Authentic allowedSlots resolution and paperdoll equipping * * 4. Gold Currency Accumulation vs Inventory Full Flippy Bounce: - * - Gold currency accumulation clamped to PLAYER_GOLD_CAP (2,500,000) + * - Gold currency accumulation clamped to the level-based inventory gold limit * - Full 10x4 inventory grid refusal, metrics.inventoryRefusals, and parabolic bounce physics */ @@ -37,7 +37,6 @@ import { resolveItemSpriteRect, identifyUiItem, isUiItemUnidentified, - PLAYER_GOLD_CAP, type UiInventoryItem, type EquipSlotId, } from '../src/client/ui/inventory.ts' diff --git a/tests/stash.test.ts b/tests/stash.test.ts index de54028..6ef043d 100644 --- a/tests/stash.test.ts +++ b/tests/stash.test.ts @@ -12,7 +12,6 @@ import { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN, STASH_GOLD_CAP, - PLAYER_GOLD_CAP, STASH_HEADER_ICON_SOCKET, STASH_HEADER_TOP_RECESS, STASH_HEADER_BOTTOM_RECESS, @@ -359,68 +358,13 @@ describe('Milestone M5 (Issue #150): Camp Town Stash Entities Across 5 Acts & 6x }) }) - describe('5. Gold Deposit and Withdrawal with Caps', () => { - let panels: WorldPanelsHud - let inv: InventoryPanel - - beforeEach(() => { - panels = new WorldPanelsHud() - inv = new InventoryPanel() - inv.gold = 500_000 - panels.stashGold = 100_000 - }) - - it('deposits gold from inventory into stash up to capacity', () => { - const deposited = panels.depositGold(50_000, inv) - expect(deposited).toBe(50_000) - expect(inv.gold).toBe(450_000) - expect(panels.stashGold).toBe(150_000) - }) - - it('withdraws gold from stash into inventory up to player capacity', () => { - const withdrawn = panels.withdrawGold(40_000, inv) - expect(withdrawn).toBe(40_000) - expect(inv.gold).toBe(540_000) - expect(panels.stashGold).toBe(60_000) - }) - - it('respects STASH_GOLD_CAP (2,500,000) and clamps deposit amounts', () => { + describe('5. Stash Gold Cap (server-driven transfers)', () => { + it('exposes STASH_GOLD_CAP = 2,500,000 (D2Common #11060) and performs no local gold mutation', () => { expect(STASH_GOLD_CAP).toBe(2_500_000) - panels.stashGold = 2_490_000 - inv.gold = 100_000 - - const deposited = panels.depositGold(50_000, inv) - expect(deposited).toBe(10_000) - expect(panels.stashGold).toBe(2_500_000) - expect(inv.gold).toBe(90_000) - - // Further deposits do nothing - expect(panels.depositGold(10_000, inv)).toBe(0) - expect(panels.stashGold).toBe(2_500_000) - }) - - it('respects PLAYER_GOLD_CAP (2,500,000) and clamps withdrawal amounts', () => { - expect(PLAYER_GOLD_CAP).toBe(2_500_000) - panels.stashGold = 500_000 - inv.gold = 2_480_000 - - const withdrawn = panels.withdrawGold(50_000, inv) - expect(withdrawn).toBe(20_000) - expect(inv.gold).toBe(2_500_000) - expect(panels.stashGold).toBe(480_000) - - // Further withdrawals do nothing - expect(panels.withdrawGold(10_000, inv)).toBe(0) - expect(inv.gold).toBe(2_500_000) - }) - - it('handles zero or negative transfers safely', () => { - expect(panels.depositGold(0, inv)).toBe(0) - expect(panels.depositGold(-500, inv)).toBe(0) - expect(panels.withdrawGold(0, inv)).toBe(0) - expect(panels.withdrawGold(-500, inv)).toBe(0) - expect(inv.gold).toBe(500_000) - expect(panels.stashGold).toBe(100_000) + const panels = new WorldPanelsHud() + // Deposit/withdraw are ClickButton commands sent by HudModel; D2Game owns the balances. + expect((panels as unknown as Record)['depositGold']).toBeUndefined() + expect((panels as unknown as Record)['withdrawGold']).toBeUndefined() }) }) @@ -548,8 +492,8 @@ describe('Milestone M5 (Issue #150): Camp Town Stash Entities Across 5 Acts & 6x mockFont, ) - const titleCall = drawTextCalls.find(c => c.text.includes('私人储物箱')) - const goldCall = drawTextCalls.find(c => c.text.includes('储物箱金币')) + const titleCall = drawTextCalls.find(c => c.text.includes('你的私人儲藏箱')) // tbl 'bank' + const goldCall = drawTextCalls.find(c => c.text.includes('儲藏起來的金錢:')) // tbl 'strGoldInStash' expect(titleCall).toBeDefined() expect(goldCall).toBeDefined() diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index 8b8d366..61e2ff9 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -1064,16 +1064,16 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(goldTextCall!.y - 7).toBeGreaterThanOrEqual(INV_GOLD_BOX_RECESS.y) expect(goldTextCall!.y + 1).toBeLessThanOrEqual(INV_GOLD_BOX_RECESS.y + INV_GOLD_BOX_RECESS.h - 1) - // 4. Verify clicking directly on the Gold Coin Button (490, 460) triggers onDropGold - let droppedGold = 0 + // 4. Verify clicking directly on the Gold Coin Button (490, 460) asks for the gold dialog (no local drop) + let goldButtonPresses = 0 expect( inv.handleClick(490, 460, { - onDropGold: amt => { - droppedGold = amt + onGoldButton: () => { + goldButtonPresses++ }, }), ).toBe(true) - expect(droppedGold).toBe(10_000) + expect(goldButtonPresses).toBe(1) // 5. Verify D2FontRenderer.drawText uses lining-numeral serif stack (never old-style Georgia) const fontRenderer = new D2FontRenderer() diff --git a/tests/unidentified-drops-identify.test.ts b/tests/unidentified-drops-identify.test.ts index 82a303f..795989b 100644 --- a/tests/unidentified-drops-identify.test.ts +++ b/tests/unidentified-drops-identify.test.ts @@ -385,47 +385,5 @@ describe('Diablo II v1.13c Unidentified Equipment Drops & Identification System' expect(tomeItem.quantity).toBe(4) expect(inv.gridItems.some(p => p.item.id === tomeItem.id)).toBe(true) }) - - it('identifies all unidentified items across Inventory, Stash, and Horadric Cube via Deckard Cain', () => { - const inv = new InventoryPanel() - const wp = new WorldPanelsHud() - const rinBase = dropTables.getBase('rin')! - const amuBase = dropTables.getBase('amu')! - - const bagUnid = itemToUiInventoryItem(makeDroppedShako('unique', 51), dropTables) - const stashUnid = itemToUiInventoryItem( - createDroppedItem(rinBase, 'rare', { - itemTypes: dropTables.itemTypes, - ilvl: 60, - dwInitSeed: 52, - itemRng: new D2Rng(52), - }), - dropTables, - ) - const cubeUnid = itemToUiInventoryItem( - createDroppedItem(amuBase, 'magic', { - itemTypes: dropTables.itemTypes, - ilvl: 50, - dwInitSeed: 53, - itemRng: new D2Rng(53), - }), - dropTables, - ) - - inv.gridItems = [{ item: bagUnid, col: 0, row: 0 }] - wp.stashItems = [{ item: stashUnid, col: 0, row: 0 }] - wp.cubeItems = [{ item: cubeUnid, col: 0, row: 0 }] - - expect(isUiItemUnidentified(bagUnid)).toBe(true) - expect(isUiItemUnidentified(stashUnid)).toBe(true) - expect(isUiItemUnidentified(cubeUnid)).toBe(true) - - const count = wp.identifyAllUiItems(inv) - expect(count).toBe(3) - expect(isUiItemUnidentified(bagUnid)).toBe(false) - expect(isUiItemUnidentified(stashUnid)).toBe(false) - expect(isUiItemUnidentified(cubeUnid)).toBe(false) - expect(wp.identifyAllUiItems(inv)).toBe(0) - }) }) }) diff --git a/tests/vendor-panel-ui.test.ts b/tests/vendor-panel-ui.test.ts index 3f99ac6..a3fb6dd 100644 --- a/tests/vendor-panel-ui.test.ts +++ b/tests/vendor-panel-ui.test.ts @@ -33,6 +33,7 @@ import { hitTestNpcDialogOption, resolveVendorButtonLayout, } from '../src/client/ui/world-panels.ts' +import { openGeneratedVendorShop } from './helpers/vendor-shop-fixture.ts' function readPngHeaderSize(buf: Uint8Array): { width: number; height: number } { const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) @@ -287,7 +288,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() wp.activePlayerInventory = inv - expect(wp.openVendorForNpc('Charsi', 'trade', 24, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Charsi', 'trade', 24, 0) const { ctx, font, drawImageCalls, strokeRectCalls, fontCalls } = createMockCanvasAndFont() const assets = { ...createFakeVendorAssets(), inventory: inv } @@ -326,7 +327,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() wp.activePlayerInventory = inv - expect(wp.openVendorForNpc('Akara', 'trade', 12, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Akara', 'trade', 12, 0) // Force armor tab empty to verify 0x6fb45e5e-0x6fb45f5d tab presence guard wp.vendorTabPlacements.armor = [] @@ -349,7 +350,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() wp.activePlayerInventory = inv - expect(wp.openVendorForNpc('Gheed', 'gamble', 30, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Gheed', 'gamble', 30, 0) const { ctx, font, drawImageCalls, fontCalls } = createMockCanvasAndFont() wp.drawLeftDockPanel(ctx, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font) @@ -367,27 +368,12 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { const inv = new InventoryPanel() inv.gold = 50000 wp.activePlayerInventory = inv - expect(wp.openVendorForNpc('Charsi', 'trade', 24, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Charsi', 'trade', 24, 0) - // Damage an equipped item so Repair All has a non-zero cost - const helm: UiInventoryItem = { - id: 'damaged-cap', - code: 'cap', - name: 'Cap', - nameZh: '帽子', - baseNameZh: '帽子', - quality: 'normal', - invFile: 'invcap', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: [], - identified: true, - durability: { current: 4, max: 12 }, - } - inv.equipped.helm = helm - const expectedCost = wp.getRepairAllCost(inv) - expect(expectedCost).toBeGreaterThan(0) + // Repair All total is supplied by HudModel (VendorRepairAllFn over priceItem113c type 3) + const expectedCost = 1234 + wp.vendorRepairAllCost = expectedCost + let repairAllCalls = 0 // Open-interval boundary check: relX = tableX (116) or tableX + 45 (161) must NOT hit expect(wp.hitTestVendorButtonSlot(80 + 116, 60 + 390)).toBeNull() @@ -403,6 +389,9 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { }, onWaypointTeleport: () => {}, inventory: inv, + onVendorRepairAll: () => { + repairAllCalls++ + }, } // Click slot 0 (Buy) -> latches 'buy' (button frame 3, cursor frame 3) @@ -438,38 +427,37 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { ), ).toBe(true) - // Click slot 3 (Repair All on Charsi) -> repairs item, clears state to 'idle', and does NOT close panel! + // Click slot 3 (Repair All on Charsi) -> sends the repair-all command, clears state to 'idle', and does NOT close panel! wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, callbacks) expect(closed).toBe(false) expect(wp.vendorTradeState).toBe('idle') expect(wp.getActiveVendorCursorFrame()).toBeNull() expect(wp.pressedMomentarySlot).toBe(3) - expect(helm.durability?.current).toBe(12) + expect(repairAllCalls).toBe(1) wp.releaseMomentaryVendorButton() expect(wp.pressedMomentarySlot).toBeNull() // Switch to Akara and click slot 3 (Close) -> closes panel, slot 2 (blank) is ignored - wp.openVendorForNpc('Akara', 'trade', 24, 0) + openGeneratedVendorShop(wp, 'Akara', 'trade', 24, 0, 2) expect(wp.hitTestVendorButtonSlot(80 + VENDOR_BUTTON_TABLE_X[2] + 20, 60 + 390)).toBeNull() wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, callbacks) expect(closed).toBe(true) }) - it('auto-cycles to the next non-empty tab when buying the last item on the active tab', () => { + it('auto-cycles to the next non-empty tab when the server empties the active tab', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() inv.gold = 999999 wp.activePlayerInventory = inv - expect(wp.openVendorForNpc('Charsi', 'trade', 24, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Charsi', 'trade', 24, 0) expect(wp.activeVendorTab).toBe('armor') - // Leave only 1 non-permStore item on the armor tab and buy it - const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.permStore && !p.item.rawItem?.permStore) - expect(nonPerm).toBeDefined() - wp.vendorTabPlacements.armor = [nonPerm!] - - const res = wp.buyFromActiveVendor(nonPerm!.col, nonPerm!.row, inv, false) - expect(res.ok).toBe(true) + // The server removed the last armor item (0x9D RemoveFromShop after a buy): HudModel re-applies the + // same store without it and D2Client 0x6fb3f710 advances to the next non-empty tab. + const remaining = (['weapons1', 'weapons2', 'misc'] as const).flatMap((id, i) => + wp.vendorTabPlacements[id].map(placement => ({ storePage: i + 2, placement })), + ) + wp.setVendorShop({ npcId: 1, descriptor: wp.activeVendorDescriptor!, mode: 'trade', items: remaining }) expect(wp.vendorTabPlacements.armor).toHaveLength(0) expect(wp.activeVendorTab).toBe('weapons1') }) @@ -642,6 +630,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { openedVendorMode = mode }, onIdentifyAll: () => {}, + onCancel: () => {}, }, font, ) @@ -654,7 +643,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() wp.activePlayerInventory = inv - expect(wp.openVendorForNpc('Charsi', 'trade', 10, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Charsi', 'trade', 10, 0) // Set player context: Level 10 Sorceress with 25 Str, 25 Dex wp.setPlayerContext({ @@ -860,7 +849,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { dex: 500, classCode: 'sor', }) - expect(wp.openVendorForNpc('Gheed', 'gamble', 99, 0)).toBe(true) + openGeneratedVendorShop(wp, 'Gheed', 'gamble', 99, 0) expect(wp.gamblePlacements.length).toBeGreaterThan(0) // Even when hovering a gamble item with an empty cursor, every gamble item background must be RED