feat(client): server-driven vendor, repair, stash-gold and gamble UI

- NPC menu: Trade -> 0x38 action 1, Gamble -> action 2 (classes
  147/199/254/405/512/514 only), Repair via the trade window, close -> 0x30.
- Store shelves render ClientWorld.containers.shop by storePage tab;
  buy / shift-buy / sell / repair / repair-all go out as 0x32/0x33/0x35
  with 2000 ms throttling and the #10107/#10071 display price.
- 0x2A results show TransactionResults* tbl messages; sale plays 0xdd.
- Stash gold dialog modes 0/3/4 (strDropGoldHowMuch / strBankGoldDeposit /
  strBankGoldWithdraw) send 0x50 / 0x4F 0x14 / 0x4F 0x13; amounts are
  clamped to the source balance only, 0 sends nothing.
- Removed every local gold/item mutation, VendorSessionManager usage,
  fake stock and the Boolean.prototype hacks.
- PLAYER_GOLD_CAP (2,500,000) removed: inventory gold is clvl*10000;
  STASH_GOLD_CAP 2,500,000 (D2Common #11060) moved to inventory-model.
- NPC strings packed from the MPQ tbls (16 new whitelist keys incl.
  index alias patchstring#95); canonical-tbl-strings gains only those.
This commit is contained in:
troytt 2026-10-01 11:09:53 +00:00
parent 4b509c0ebd
commit 812eb55d36
23 changed files with 1309 additions and 1110 deletions

View File

@ -377,6 +377,22 @@ async function main() {
'bank', 'bank',
'stash', 'stash',
'strGoldInStash', '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', 'box',
'strUiMenu2', 'strUiMenu2',
'strClose', 'strClose',
@ -524,6 +540,20 @@ async function main() {
} }
chiOut['strethereal'] = EXPANSION_CHI_FALLBACKS['strethereal']! 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#<n>`.
// - 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 outPath = path.join(process.cwd(), 'src/common/data/canonical-tbl-strings.ts')
const fileContent = `/** const fileContent = `/**
* AUTO-GENERATED by \`src/baker/packers/pack-canonical-tbl.ts\` from authoritative 1.13c MPQ archives: * AUTO-GENERATED by \`src/baker/packers/pack-canonical-tbl.ts\` from authoritative 1.13c MPQ archives:

View File

@ -19,6 +19,13 @@ export function lookupTbl(key: string, lang: TblLang = getTblLang()): string | u
return resolveTblKey(key, lang) 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 let overrideLang: GameLang | null = null
const listeners = new Set<(lang: TblLang) => void>() const listeners = new Set<(lang: TblLang) => void>()
const gameLangListeners = new Set<(lang: GameLang) => void>() const gameLangListeners = new Set<(lang: GameLang) => void>()

View File

@ -57,6 +57,7 @@ import {
type SettingsStore, type SettingsStore,
} from '../settings/settings-store.ts' } from '../settings/settings-store.ts'
import { HudModel } from '../ui-model/hud-model.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 { ClientWorldSceneSource } from '../view/scene-source.ts'
import type { RenderableUnitInput } from '../view/unit-drawables.ts' import type { RenderableUnitInput } from '../view/unit-drawables.ts'
import { getViewportProfile } from '../view/viewport-profile.ts' import { getViewportProfile } from '../view/viewport-profile.ts'
@ -249,6 +250,11 @@ export class OnlineSession {
onCommand: (cmd) => { onCommand: (cmd) => {
this.dispatchCommand(cmd) this.dispatchCommand(cmd)
}, },
vendorPrice: vendorPrice113c,
vendorRepairAll: vendorRepairAll113c,
playUiSound: (soundId) => {
this.soundService.playSoundById(soundId)
},
}) })
this.commandMapper = new CommandMapper({ this.commandMapper = new CommandMapper({

View File

@ -33,11 +33,14 @@ import {
type UiInventoryItem, type UiInventoryItem,
type UiItemQuality, type UiItemQuality,
} from '../ui/inventory.ts' } 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 { AutomapView } from '../automap/automap-view.ts'
import type { LocalMovementPredictor } from '../motion/predictor.ts' import type { LocalMovementPredictor } from '../motion/predictor.ts'
import type { ClientUnit } from '../world/client-unit.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 { D2StatId } from '../world/stat-list.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts' import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
import { computeCharacterSheetDerived } from './derived-stats.ts' import { computeCharacterSheetDerived } from './derived-stats.ts'
@ -173,8 +176,55 @@ export interface HudModelOptions {
readonly predictor?: LocalMovementPredictor | null | undefined readonly predictor?: LocalMovementPredictor | null | undefined
readonly automap?: AutomapView | null | undefined readonly automap?: AutomapView | null | undefined
readonly onCommand?: ((cmd: ClientCommand) => void) | 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 { export interface HudModelSnapshot {
readonly charName: string readonly charName: string
/** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */ /** `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 unsubWorld: (() => void) | null = null
private hoveredMonsterId: number | null = null private hoveredMonsterId: number | null = null
private escapeMenuOpen = false 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<string, number>()
/** 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) { constructor(options: HudModelOptions) {
this.world = options.world this.world = options.world
@ -231,6 +292,11 @@ export class HudModel implements HudCommandSink {
this.predictor = options.predictor ?? null this.predictor = options.predictor ?? null
this.automap = options.automap ?? null this.automap = options.automap ?? null
this.onCommand = options.onCommand 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 if (this.hudManager) this.hudManager.commandSink = this
this.unsubWorld = this.world.onChange(() => { this.unsubWorld = this.world.onChange(() => {
@ -551,9 +617,143 @@ export class HudModel implements HudCommandSink {
// 7. Target monster plaque // 7. Target monster plaque
this.syncHoveredMonsterTarget() this.syncHoveredMonsterTarget()
// 8. NPC menu / store / stash panels and S2C 0x2A replies.
this.syncNpcInteraction(hud)
hud.syncPublishedState() 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`. * Update the hovered/targeted monster unit ID and refresh `hudManager.targetMonster`.
*/ */
@ -775,6 +975,95 @@ export class HudModel implements HudCommandSink {
identifyAllAtNpc(npcId: number): void { identifyAllAtNpc(npcId: number): void {
this.emitCommand({ type: 'NpcIdentifyAll', npcId }) 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 { export function clientUnitToMonsterTargetInfo(unit: ClientUnit): MonsterTargetHudInfo {

View File

@ -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)

View File

@ -45,7 +45,6 @@ import {
type CursorState, type CursorState,
} from './cursor.ts' } from './cursor.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts' import type { CharacterClassCode } from '../../common/units/classes.ts'
import type { TownNpcServiceDescriptor, VendorNpcId } from '../../common/units/npc-table.ts'
import { import {
detectInitialLang, detectInitialLang,
setCurrentLang, setCurrentLang,
@ -54,6 +53,7 @@ import {
type TblLang, type TblLang,
} from '../i18n/lang.ts' } from '../i18n/lang.ts'
import { setTooltipLocale } from '../../common/items/item-tooltip.ts' import { setTooltipLocale } from '../../common/items/item-tooltip.ts'
import { requireTbl } from '../i18n/lang.ts'
import { import {
computeViewportAnchors, computeViewportAnchors,
getViewportProfile, getViewportProfile,
@ -283,6 +283,15 @@ export function logicalToClient(
* Server-authoritative HUD command sink (online mode: `HudModel`). Every method only emits a `ClientCommand`; * 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. * 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 { export interface HudCommandSink {
allocateStat(key: BaseStatKey): boolean allocateStat(key: BaseStatKey): boolean
allocateSkill(skillId: number): boolean allocateSkill(skillId: number): boolean
@ -299,6 +308,28 @@ export interface HudCommandSink {
useItem(itemId: number): void useItem(itemId: number): void
transmuteCube(): void transmuteCube(): void
teleportToLevel(destAreaId: number): 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. */ /** 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 currentClass: CharacterClassCode | null = null
private _commandSink: HudCommandSink | 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 * 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()`. * `ClientCommand`s; all displayed state changes arrive back from the server through `HudModel.syncFromWorld()`.
@ -693,41 +731,164 @@ export class HudManager {
return ok return ok
} }
/** private requireSink(action: string): HudCommandSink {
* Opens a Town NPC Vendor or Gamble session on the Left Dock and ensures Player Inventory is open on the Right Dock. if (!this._commandSink) throw new Error(`HudManager.${action}: no server command sink attached`)
*/ return this._commandSink
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')
} }
const playerCtx = {
level: this.charSheet.attrs.level, /** Server opened a store (`ClientWorld.npcInteraction.window`): show the vendor panel with the inventory. */
str: this.charSheet.attrs.str, showVendorPanel(): void {
dex: this.charSheet.attrs.dex, if (!this.worldPanels.activeVendorDescriptor) {
classCode: this.currentClass, throw new Error('HudManager.showVendorPanel: no store set (WorldPanelsHud.setVendorShop)')
} }
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.worldPanels.npcMenu = null
this.leftPanel = 'vendor' this.leftPanel = 'vendor'
this.charSheet.visible = false this.charSheet.visible = false
if (this.rightPanel !== 'inv') { if (this.rightPanel !== 'inv') this.toggleRightPanel('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() 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`). * Strictly follows D2Client.dll mutual exclusion on the Left Dock (`0..400`).
*/ */
toggleLeftPanel(kind: Exclude<LeftDockPanelKind, 'none'>): void { toggleLeftPanel(kind: Exclude<LeftDockPanelKind, 'none'>): void {
if (this.leftPanel === kind) { const prev = this.leftPanel
if (prev === kind) {
this.leftPanel = 'none' this.leftPanel = 'none'
this.charSheet.visible = false this.charSheet.visible = false
this.worldPanels.vendorTradeState = 'idle' this.worldPanels.vendorTradeState = 'idle'
this.worldPanels.releaseMomentaryVendorButton() this.worldPanels.releaseMomentaryVendorButton()
this.leaveLeftPanel(prev)
} else { } else {
if (kind === 'vendor' && !this.worldPanels.activeVendorDescriptor) { if (kind === 'vendor' && !this.worldPanels.activeVendorDescriptor) {
throw new Error('Cannot open vendor panel without an active vendor session') throw new Error('Cannot open vendor panel without an active vendor session')
} }
this.leftPanel = kind this.leftPanel = kind
if (prev !== 'none') this.leaveLeftPanel(prev)
this.charSheet.visible = kind === 'char' this.charSheet.visible = kind === 'char'
if ((kind === 'stash' || kind === 'vendor') && this.rightPanel === 'none') { if ((kind === 'stash' || kind === 'vendor') && this.rightPanel === 'none') {
this.toggleRightPanel('inv') this.toggleRightPanel('inv')
@ -777,8 +941,13 @@ export class HudManager {
this.belt.lockedOpen || this.belt.lockedOpen ||
this.hotkeys.openPopup !== null || this.hotkeys.openPopup !== null ||
this.worldPanels.npcMenu !== null || this.worldPanels.npcMenu !== null ||
this.goldDialog !== null ||
this.inventory.isIdentifyMode() this.inventory.isIdentifyMode()
const prevLeft = this.leftPanel
this.goldDialog = null
this.dismissNpcMenu()
this.leftPanel = 'none' this.leftPanel = 'none'
if (prevLeft !== 'none') this.leaveLeftPanel(prevLeft)
this.rightPanel = 'none' this.rightPanel = 'none'
this.charSheet.visible = false this.charSheet.visible = false
this.inventory.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. * Central vision corridor (e.g. `400 < logicalX < 624` in `1024x768`) remains open and returns false.
*/ */
isPointInterceptedByHud(logicalX: number, logicalY: number): boolean { isPointInterceptedByHud(logicalX: number, logicalY: number): boolean {
if (this.goldDialog) return true
const profile = this.viewportProfile const profile = this.viewportProfile
const docking = this.getDockingLayout() const docking = this.getDockingLayout()
const { marginW } = docking const { marginW } = docking
@ -898,6 +1068,14 @@ export class HudManager {
private bindEvents(): void { private bindEvents(): void {
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) return 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() const key = e.key.toUpperCase()
if (key === 'I' || key === 'B') { if (key === 'I' || key === 'B') {
@ -1067,9 +1245,14 @@ export class HudManager {
handleHudMouseDown(e: MouseEvent): void { handleHudMouseDown(e: MouseEvent): void {
if (e.button !== 0 && e.button !== 2) return if (e.button !== 0 && e.button !== 2) return
const pt = this.clientToLogical(e.clientX, e.clientY) 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)) { if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
this.worldPanels.npcMenu = null this.dismissNpcMenu()
this.syncPublishedState()
} }
if (!this.isPointInterceptedByHud(pt.x, pt.y)) { if (!this.isPointInterceptedByHud(pt.x, pt.y)) {
if (this.inventory.isIdentifyMode()) { if (this.inventory.isIdentifyMode()) {
@ -1107,19 +1290,25 @@ export class HudManager {
// Town NPC Stone Menu click // Town NPC Stone Menu click
if (this.worldPanels.npcMenu) { if (this.worldPanels.npcMenu) {
const npcId = this.worldPanels.npcMenu.npcId
this.worldPanels.handleNpcMenuClick( this.worldPanels.handleNpcMenuClick(
pt.x, pt.x,
pt.y, pt.y,
{ {
onOpenVendor: (desc, mode) => { onOpenVendor: (_desc, mode) => {
this.openVendorSession(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) => { onIdentifyAll: (_desc) => {
const count = this.worldPanels.identifyAllUiItems(this.inventory) const sink = this.requireSink('npcMenu')
this.worldPanels.showAreaBanner( if (npcId === null) throw new Error('HudManager: NPC menu has no server NPC id')
count > 0 ? `已鉴定 ${count} 件物品` : '背包中没有未鉴定物品', sink.identifyAllAtNpc(npcId)
count > 0 ? `Identified ${count} items` : 'No unidentified items in inventory', sink.closeNpc(npcId)
) },
onCancel: () => {
if (npcId !== null) this.requireSink('npcMenu').closeNpc(npcId)
}, },
}, },
this.font, this.font,
@ -1180,10 +1369,16 @@ export class HudManager {
this.syncPublishedState() this.syncPublishedState()
return return
} else if (this.leftPanel !== 'none') { } 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: () => { onClose: () => {
this.leftPanel = 'none' 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) => { onWaypointTeleport: (act, slug, levelId) => {
if (this._commandSink) { if (this._commandSink) {
this._commandSink.teleportToLevel(levelId) this._commandSink.teleportToLevel(levelId)
@ -1260,52 +1455,12 @@ export class HudManager {
} }
} }
} }
}
// Vendor Repair or Sell/Quick-Sell from Player Inventory if (this.leftPanel === 'vendor' && !this.inventory.isIdentifyMode() && this.handleVendorInventoryClick(rightX, pt.y, e.button === 2)) {
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() this.syncPublishedState()
return 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._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) { if (this._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) {
this.syncPublishedState() this.syncPublishedState()
@ -1319,9 +1474,7 @@ export class HudManager {
{ {
onOpenCube: () => this.toggleLeftPanel('cube'), onOpenCube: () => this.toggleLeftPanel('cube'),
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item), onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
onDropGold: (amt) => { onGoldButton: () => this.openGoldDialog(this.leftPanel === 'stash' ? 3 : 0),
this.dropInventoryGold(amt, pt)
},
}, },
e.button === 2 ? 2 : 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 * 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. * 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') 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 // 11. Authentic Hand / Trade Cursor & Held Item at Hotspot
this.renderCursor(ctx, nowMs) this.renderCursor(ctx, nowMs)

View File

@ -64,7 +64,7 @@ export {
import { import {
INV_COLS, INV_COLS,
INV_ROWS, INV_ROWS,
PLAYER_GOLD_CAP, STASH_GOLD_CAP,
getInventoryGoldLimit, getInventoryGoldLimit,
findFreeGridSlot, findFreeGridSlot,
type EquipSlotId, type EquipSlotId,
@ -76,7 +76,7 @@ import {
export { export {
INV_COLS, INV_COLS,
INV_ROWS, INV_ROWS,
PLAYER_GOLD_CAP, STASH_GOLD_CAP,
getInventoryGoldLimit, getInventoryGoldLimit,
findFreeGridSlot, findFreeGridSlot,
type EquipSlotId, type EquipSlotId,
@ -127,6 +127,9 @@ export const INV_GOLD_BTN_BOUNDS = {
h: 18, h: 18,
} as const } 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 = { export const INV_GOLD_BOX_RECESS = {
x: 400 + 107, x: 400 + 107,
y: 60 + 392, 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 { function normalizeEquipSlotId(slotId: string): EquipSlotId {
if (slotId === 'head') return 'helm' if (slotId === 'head') return 'helm'
if (slotId === 'mainHand') return 'weapon1' if (slotId === 'mainHand') return 'weapon1'
@ -1854,18 +1829,6 @@ export class InventoryPanel {
return false 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)`: * Handle left-click on a grid cell `(col, row)`:
* - If `cursorItem` is null: pick up the item at `(col, row)` into `cursorItem`. * - 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 { clickGridCell(col: number, row: number): boolean {
if (col < 0 || row < 0 || col >= INV_GRID_ORIGIN.cols || row >= INV_GRID_ORIGIN.rows) { if (col < 0 || row < 0 || col >= INV_GRID_ORIGIN.cols || row >= INV_GRID_ORIGIN.rows) {
_lastInvClickCursorItem = this.cursorItem
return false return false
} }
if (!this.cursorItem) { 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, p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
) )
if (!hit) { if (!hit) {
_lastInvClickCursorItem = null
return false return false
} }
this.gridItems = this.gridItems.filter(p => p !== hit) this.gridItems = this.gridItems.filter(p => p !== hit)
this.cursorItem = hit.item this.cursorItem = hit.item
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
@ -1910,7 +1870,6 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
} }
@ -1922,12 +1881,10 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
if (!directHit && (col + this.cursorItem.invWidth > INV_GRID_ORIGIN.cols || row + this.cursorItem.invHeight > INV_GRID_ORIGIN.rows)) { if (!directHit && (col + this.cursorItem.invWidth > INV_GRID_ORIGIN.cols || row + this.cursorItem.invHeight > INV_GRID_ORIGIN.rows)) {
_lastInvClickCursorItem = this.cursorItem
return false return false
} }
@ -1937,7 +1894,6 @@ export class InventoryPanel {
if (overlaps.length === 0) { if (overlaps.length === 0) {
this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow })
this.cursorItem = null this.cursorItem = null
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
if (overlaps.length === 1) { if (overlaps.length === 1) {
@ -1954,7 +1910,6 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
} }
@ -1966,16 +1921,13 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
this.gridItems = this.gridItems.filter(p => p !== single) this.gridItems = this.gridItems.filter(p => p !== single)
this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow })
this.cursorItem = single.item this.cursorItem = single.item
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
_lastInvClickCursorItem = this.cursorItem
return false return false
} }
@ -2057,14 +2009,10 @@ export class InventoryPanel {
const existing = this.equipped[slotId] ?? null const existing = this.equipped[slotId] ?? null
if (!this.cursorItem) { if (!this.cursorItem) {
if (!existing) { if (!existing) {
_lastInvClickAction = 'none'
_lastInvClickCursorItem = null
return false return false
} }
delete this.equipped[slotId] delete this.equipped[slotId]
this.cursorItem = existing this.cursorItem = existing
_lastInvClickAction = 'unequip'
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
if (existing && canSocketUiItem(this.cursorItem, existing)) { if (existing && canSocketUiItem(this.cursorItem, existing)) {
@ -2075,19 +2023,13 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickAction = 'equip'
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
if (!this.canEquipItemInSlot(this.cursorItem, slotId)) { if (!this.canEquipItemInSlot(this.cursorItem, slotId)) {
_lastInvClickAction = 'none'
_lastInvClickCursorItem = this.cursorItem
return false return false
} }
this.equipped[slotId] = this.cursorItem this.equipped[slotId] = this.cursorItem
this.cursorItem = existing this.cursorItem = existing
_lastInvClickAction = existing ? 'swap' : 'equip'
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
@ -2194,7 +2136,8 @@ export class InventoryPanel {
callbacks?: { callbacks?: {
onOpenCube?: () => void onOpenCube?: () => void
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | 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, button: 0 | 2 = 0,
externalContainers?: { externalContainers?: {
@ -2310,12 +2253,15 @@ export class InventoryPanel {
return true return true
} }
// 5. Gold Coin Button (`goldcoinbtn.dc6` at x=484..504) & Readout Area (x: 484..625, y: 450..485) // 5. Gold coin button (D2Client inventory gold button hit rect, panel-relative x 83..103, y 392..410).
if (logicalX >= INV_GOLD_BTN_BOUNDS.x && logicalX <= 625 && logicalY >= 450 && logicalY <= 485) { // D2 never drops/deposits locally: the button only opens the gold dialog; the amount is chosen there.
if (this.gold > 0) { if (
const dropAmt = Math.min(10_000, this.gold) logicalX >= INV_PANEL_ORIGIN.x + INV_GOLD_BTN_HIT.relX0 &&
callbacks?.onDropGold?.(dropAmt) 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 return true
} }

View File

@ -40,74 +40,10 @@ import {
import type { Item } from '../../common/items/items.ts' import type { Item } from '../../common/items/items.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts' import type { CharacterClassCode } from '../../common/units/classes.ts'
import { D2Rng } from '../../common/rng/d2-rng.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' 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 { import {
type TownNpcServiceDescriptor, type TownNpcServiceDescriptor,
type VendorNpcId, type VendorNpcId,
@ -117,9 +53,6 @@ import {
getLocalizedNpcGreetings, getLocalizedNpcGreetings,
} from '../../common/units/npc-table.ts' } from '../../common/units/npc-table.ts'
import type { VendorTabId } from '../../common/items/vendor-generator.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_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 { CANONICAL_ELITE_MODIFIERS, monsterDisplayName } from '../../common/units/monsters-data.ts'
import { getSocketableInfo, isJewelCode } from '../../common/items/gems-data.ts' import { getSocketableInfo, isJewelCode } from '../../common/items/gems-data.ts'
@ -129,6 +62,7 @@ import {
} from '../../common/items/runeword-socket.ts' } from '../../common/items/runeword-socket.ts'
export { export {
STASH_GOLD_CAP,
GRID_ITEM_BG_BLUE, GRID_ITEM_BG_BLUE,
GRID_ITEM_BG_GREEN, GRID_ITEM_BG_GREEN,
GRID_ITEM_BG_RED, 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_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_GRID_ORIGIN = { x: 80 + 74, y: 60 + 82, cols: 6, rows: 8, cellPx: 29 } as const
export const STASH_GOLD_CAP = 2_500_000 /** Panel-relative click rect of the stash gold coin button (inclusive, D2Client stash panel). */
export const PLAYER_GOLD_CAP = 2_500_000 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 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' }, { id: 'misc', index: 3, xOffset: 240, width: 79, height: 31, centerX: 281, textY: 20, strIdx: 4039, labelZh: '其他', labelEn: 'Misc' },
] as const ] 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<number> = new Set([147, 199, 254, 405, 512, 514])
export type NpcMenuActionId = 'talk' | 'trade' | 'gamble' | 'identify' | 'cancel' export type NpcMenuActionId = 'talk' | 'trade' | 'gamble' | 'identify' | 'cancel'
export interface NpcMenuOption { export interface NpcMenuOption {
@ -710,23 +652,15 @@ export class WorldPanelsHud {
lang: TblLang = getTblLang() lang: TblLang = getTblLang()
selectedActTab = 1 selectedActTab = 1
selectedQuestIdx = 0 selectedQuestIdx = 0
/** Server stat 15 (`goldbank`); 0 until received. */ /** Server stat 15 (`goldbank`), written by `HudModel` only; 0 until received (absent stat = 0). */
private _stashGold = 0 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
}
private _starterStashCleared = false private _starterStashCleared = false
private readonly vendorRng = new D2Rng(0x56454e44)
private vendorBuySeq = 0
cubeTransmuteCount = 0 cubeTransmuteCount = 0
escapeMenuOpen = false escapeMenuOpen = false
lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null
npcMenu: { npcMenu: {
/** Server unit id of the NPC (from `NpcInit`); `null` for menus opened without a server unit. */
npcId: number | null
npcName: string npcName: string
x: number x: number
y: number y: number
@ -737,14 +671,6 @@ export class WorldPanelsHud {
hoveredOptionIdx?: number | null | undefined hoveredOptionIdx?: number | null | undefined
} | null = null } | 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 { private _clearStarterStashIfRawItem(item: UiInventoryItem | null | undefined): void {
if (!this._starterStashCleared && item && !Array.isArray((item as any).allowedSlots)) { if (!this._starterStashCleared && item && !Array.isArray((item as any).allowedSlots)) {
this._starterStashCleared = true this._starterStashCleared = true
@ -755,7 +681,13 @@ export class WorldPanelsHud {
activeVendorDescriptor: TownNpcServiceDescriptor | null = null activeVendorDescriptor: TownNpcServiceDescriptor | null = null
vendorMode: 'trade' | 'gamble' = 'trade' vendorMode: 'trade' | 'gamble' = 'trade'
activeVendorTab: VendorTabId = 'armor' 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()`. */ /** Synced from the server character level by `HudManager.syncPlayerContext()`. */
vendorCharLevel = 0 vendorCharLevel = 0
vendorPlayerStats: PlayerItemRequirementContext = { vendorPlayerStats: PlayerItemRequirementContext = {
@ -980,11 +912,13 @@ export class WorldPanelsHud {
descriptor: TownNpcServiceDescriptor, descriptor: TownNpcServiceDescriptor,
screenX = 400, screenX = 400,
screenY = 220, screenY = 220,
npcId: number | null = null,
): void { ): void {
const options = this.buildNpcMenuOptions(descriptor) const options = this.buildNpcMenuOptions(descriptor)
// D2Client.dll 0x6fb53829 -> 0x6fb53370: first selectable line (option index 0) is selected on open. // D2Client.dll 0x6fb53829 -> 0x6fb53370: first selectable line (option index 0) is selected on open.
// Box position clamping is performed dynamically in computeNpcDialogLayout (0x6fb53580). // Box position clamping is performed dynamically in computeNpcDialogLayout (0x6fb53580).
this.npcMenu = { this.npcMenu = {
npcId,
npcName: descriptor.displayName, npcName: descriptor.displayName,
x: Math.round(screenX), x: Math.round(screenX),
y: Math.round(screenY), y: Math.round(screenY),
@ -1003,68 +937,80 @@ export class WorldPanelsHud {
return true return true
} }
openVendorForNpc( /**
descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string, * Show the store the server filled with `0x9C AddToShop` items (`ClientWorld.containers.shop`).
mode: 'trade' | 'gamble' = 'trade', * `pages`: one entry per shop item with its bitstream `storePage` (1..4 -> tab index 0..3, D2Client
cLvl = 85, * 0x6fb91178 tab table). Re-applying the same store keeps the selected tab unless it became empty
difficulty: 0 | 1 | 2 | 'normal' | 'nightmare' | 'hell' = 0, * (D2Client 0x6fb3f710 advances to the next non-empty tab).
): boolean { */
const descriptor = setVendorShop(p: {
typeof descriptorOrName === 'object' readonly npcId: number
? descriptorOrName readonly descriptor: TownNpcServiceDescriptor
: typeof descriptorOrName === 'number' readonly mode: 'trade' | 'gamble'
? getTownNpcDescriptorByVendorId(descriptorOrName) readonly items: readonly { readonly storePage: number; readonly placement: GridPlacement }[]
: getTownNpcDescriptorByName(descriptorOrName) }): void {
if (!descriptor || descriptor.vendorId === null) return false // Fail fast against the D2Client.dll 0x6fb3f430 button layout table.
resolveVendorButtonLayout(p.descriptor.hcIdx, p.mode)
const diffNum: 0 | 1 | 2 = const sameStore =
difficulty === 'hell' || difficulty === 2 this.vendorNpcId === p.npcId &&
? 2 this.vendorMode === p.mode &&
: difficulty === 'nightmare' || difficulty === 1 this.activeVendorDescriptor?.hcIdx === p.descriptor.hcIdx
? 1 if (!sameStore) {
: 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
this.vendorTradeState = 'idle' this.vendorTradeState = 'idle'
this.hoveredVendorButtonSlot = null this.hoveredVendorButtonSlot = null
this.pressedMomentarySlot = null this.pressedMomentarySlot = null
this.hoveredVendorItem = null this.hoveredVendorItem = null
}
const tables = getEmbeddedDropTables() this.activeVendorDescriptor = p.descriptor
const vendorId = descriptor.vendorId this.vendorNpcId = p.npcId
this.vendorMode = p.mode
if (mode === 'gamble') { const tabs: Record<VendorTabId, GridPlacement[]> = { armor: [], weapons1: [], weapons2: [], misc: [] }
const gambleInv = this.vendorSession.getOrCreateGambleStore(vendorId, this.vendorCharLevel) const gamble: GridPlacement[] = []
this.gamblePlacements = gambleInv.getAllItems().map(p => { for (const entry of p.items) {
const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, diffNum, this.vendorCharLevel, true) if (p.mode === 'gamble') {
const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, isGambleMystery: true }, tables) gamble.push(entry.placement)
return { item: uiItem, col: p.x, row: p.y } 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' this.activeVendorTab = 'armor'
return true } else if (!sameStore) {
} // D2Client.dll 0x6fb411a0 -> 0x6fb3f710(0): start at tab 0 and cycle to the first non-empty tab.
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') this.selectVendorTab('armor')
return true } 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
}
}
/** 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'
}
/** 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)')
}
return this.vendorRepairAllCost
} }
getActiveVendorPlacements(): GridPlacement[] { getActiveVendorPlacements(): GridPlacement[] {
@ -1074,376 +1020,6 @@ export class WorldPanelsHud {
return this.vendorTabPlacements[this.activeVendorTab] ?? [] 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' */ /** Runtime quest status overrides: questId -> 'completed' | 'active' | 'locked' */
private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>() private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>()
private cachedQuestsAtlasImg: HTMLImageElement | null = null private cachedQuestsAtlasImg: HTMLImageElement | null = null
@ -1962,36 +1538,6 @@ export class WorldPanelsHud {
return true 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 { handleMouseMove(logicalX: number, logicalY: number): void {
this.hoveredStashItem = null this.hoveredStashItem = null
this.hoveredCubeItem = null this.hoveredCubeItem = null
@ -2140,6 +1686,8 @@ export class WorldPanelsHud {
callbacks: { callbacks: {
onOpenVendor: (descriptor: TownNpcServiceDescriptor, mode: 'trade' | 'gamble') => void onOpenVendor: (descriptor: TownNpcServiceDescriptor, mode: 'trade' | 'gamble') => void
onIdentifyAll: (descriptor: TownNpcServiceDescriptor) => void onIdentifyAll: (descriptor: TownNpcServiceDescriptor) => void
/** Menu dismissed (`cancel` option or click outside): the caller sends `NpcCancel` (0x30). */
onCancel: () => void
}, },
font?: Pick<D2FontRenderer, 'measureText'> | null, font?: Pick<D2FontRenderer, 'measureText'> | null,
): boolean { ): boolean {
@ -2165,6 +1713,7 @@ export class WorldPanelsHud {
logicalY > layout.top + layout.height logicalY > layout.top + layout.height
) { ) {
this.npcMenu = null this.npcMenu = null
callbacks.onCancel()
return true return true
} }
@ -2186,6 +1735,7 @@ export class WorldPanelsHud {
callbacks.onIdentifyAll(desc) callbacks.onIdentifyAll(desc)
} else { } else {
this.npcMenu = null this.npcMenu = null
callbacks.onCancel()
} }
return true return true
} }
@ -2219,6 +1769,14 @@ export class WorldPanelsHud {
hitItem: UiInventoryItem | null, hitItem: UiInventoryItem | null,
isRightClick: boolean, isRightClick: boolean,
) => void ) => 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 { ): boolean {
if (logicalX > 400) return false if (logicalX > 400) return false
@ -2358,6 +1916,19 @@ export class WorldPanelsHud {
return true return true
} }
} else if (kind === 'stash') { } 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 // Stash 6x8 Grid
if ( if (
logicalX >= STASH_GRID_ORIGIN.x && logicalX >= STASH_GRID_ORIGIN.x &&
@ -2453,10 +2024,8 @@ export class WorldPanelsHud {
} else if (btnKind === 'repairAll') { } else if (btnKind === 'repairAll') {
this.vendorTradeState = 'idle' this.vendorTradeState = 'idle'
this.pressedMomentarySlot = hitSlot this.pressedMomentarySlot = hitSlot
const inv = callbacks.inventory ?? this.activePlayerInventory if (!callbacks.onVendorRepairAll) throw new Error('handleLeftDockClick: Repair All needs onVendorRepairAll')
if (inv) { callbacks.onVendorRepairAll()
this.repairAllUiItems(inv)
}
} else if (btnKind === 'close') { } else if (btnKind === 'close') {
this.vendorTradeState = 'idle' this.vendorTradeState = 'idle'
this.pressedMomentarySlot = hitSlot this.pressedMomentarySlot = hitSlot
@ -2480,21 +2049,20 @@ export class WorldPanelsHud {
const row = Math.floor((logicalY - VENDOR_GRID_ORIGIN.y) / VENDOR_GRID_ORIGIN.cellPx) const row = Math.floor((logicalY - VENDOR_GRID_ORIGIN.y) / VENDOR_GRID_ORIGIN.cellPx)
const inv = callbacks.inventory ?? this.activePlayerInventory const inv = callbacks.inventory ?? this.activePlayerInventory
if (inv) { // Holding a cursor item over the store grid sells it (server answers with 0x2A + item/stat packets).
// If player is holding an item on cursor and clicks inside Vendor grid -> Sell it! if (inv?.cursorItem) {
if (inv.cursorItem) { if (!callbacks.onVendorSell) throw new Error('handleLeftDockClick: vendor sell needs onVendorSell')
const held = inv.cursorItem callbacks.onVendorSell(inv.cursorItem)
const res = this.sellToActiveVendor(held, inv)
if (res.ok) {
inv.cursorItem = null
}
return true return true
} }
// Buy works in 'idle' or 'buy' state; never buy while the sell / repair cursor is armed.
// 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') { if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') {
this.buyFromActiveVendor(col, row, inv, Boolean(callbacks.isShiftClick)) const hit = this.getActiveVendorPlacements().find(
this.handleMouseMove(logicalX, logicalY) 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 return true
@ -2652,14 +2220,14 @@ export class WorldPanelsHud {
} }
} else if (kind === 'stash') { } else if (kind === 'stash') {
if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy) 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', font: 'font8',
color: 'gold', color: 'gold',
align: 'center', align: 'center',
}) })
font.drawText( font.drawText(
ctx, ctx,
isEn ? `Stash Gold: ${this.stashGold.toLocaleString()}` : `储物箱金币: ${this.stashGold.toLocaleString()}`, `${requireTbl('strGoldInStash', this.lang)} ${this.stashGold}`,
ox + 160, ox + 160,
oy + 61, oy + 61,
{ {
@ -2918,9 +2486,7 @@ export class WorldPanelsHud {
if (spec.enabled) { if (spec.enabled) {
const hoverText = const hoverText =
btnKind === 'repairAll' btnKind === 'repairAll'
? isEn ? requireTbl('patchstring#95', this.lang).replace('%d', String(this.requireRepairAllCost()))
? `${spec.labelEn}: ${this.getRepairAllCost(assets.inventory)}`
: `${spec.labelZh}:${this.getRepairAllCost(assets.inventory)}`
: isEn : isEn
? spec.labelEn ? spec.labelEn
: spec.labelZh : spec.labelZh
@ -3290,35 +2856,16 @@ export class WorldPanelsHud {
} }
// Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0) // 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) { if (item.buyCost !== undefined) {
const label = item.isGambleMystery lines.push({ text: `${requireTbl('cost', this.lang)}${item.buyCost}`, color: 'white', font: 'font8' })
? (isEn ? 'Gamble Cost' : '赌博价格')
: (isEn ? 'Buy Cost' : '购买价格')
const suffix = isEn ? ' Gold' : ' 金币'
lines.push({ text: `${label}: ${item.buyCost.toLocaleString()}${suffix}`, color: 'gold', font: 'font8' })
} else if (vendorOpen && this.activeVendorDescriptor) { } else if (vendorOpen && this.activeVendorDescriptor) {
if (this.repairCursorMode && this.activeVendorDescriptor.canRepair) { if (this.repairCursorMode && this.activeVendorDescriptor.canRepair) {
const repPrice = this.getPlayerItemRepairPrice(item) if (item.repairCost !== undefined && item.repairCost > 0) {
if (repPrice > 0) { lines.push({ text: `${requireTbl('Repair', this.lang)}${item.repairCost}`, color: 'white', font: 'font8' })
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',
})
} }
} else if (item.sellCost !== undefined) {
lines.push({ text: `${requireTbl('Sell', this.lang)}${item.sellCost}`, color: 'white', font: 'font8' })
} }
} }

View File

@ -295,6 +295,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Baal Throne": "Baal", "Baal Throne": "Baal",
"BaalColdMage": "Death Mage", "BaalColdMage": "Death Mage",
"Baals Minion": "Minion of Destruction", "Baals Minion": "Minion of Destruction",
"Back": "cancel",
"Baezil's Vortex": "Baezil's Vortex", "Baezil's Vortex": "Baezil's Vortex",
"Bahamut's": "Bahamut's", "Bahamut's": "Bahamut's",
"Balrog": "Balrog", "Balrog": "Balrog",
@ -1779,6 +1780,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Realgar": "Realgar", "Realgar": "Realgar",
"ReanimatedHorde": "Reanimated Horde", "ReanimatedHorde": "Reanimated Horde",
"Red": "Red", "Red": "Red",
"Repair": "Repair cost: ",
"Resonant": "Resonant", "Resonant": "Resonant",
"Returned": "Returned", "Returned": "Returned",
"ReturnedArcher": "Returned Archer", "ReturnedArcher": "Returned Archer",
@ -2018,6 +2020,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Scintillating": "Scintillating", "Scintillating": "Scintillating",
"Scorching": "Condensing", "Scorching": "Condensing",
"Screaming": "Screaming", "Screaming": "Screaming",
"Sell": "Sell value: ",
"Sensei's": "Sensei's", "Sensei's": "Sensei's",
"Septic": "Septic", "Septic": "Septic",
"Seraph's Hymn": "Seraph's Hymn", "Seraph's Hymn": "Seraph's Hymn",
@ -2647,6 +2650,13 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Trang-Oul's Guise": "Trang-Oul's Guise", "Trang-Oul's Guise": "Trang-Oul's Guise",
"Trang-Oul's Scales": "Trang-Oul's Scales", "Trang-Oul's Scales": "Trang-Oul's Scales",
"Trang-Oul's Wing": "Trang-Oul's Wing", "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", "TrappedSoul": "Trapped Soul",
"Travincal": "Travincal", "Travincal": "Travincal",
"Treads of Cthon": "Treads of Cthon", "Treads of Cthon": "Treads of Cthon",
@ -2911,6 +2921,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"collar": "Collar", "collar": "Collar",
"compellingorb": "Compelling Orb", "compellingorb": "Compelling Orb",
"cord": "Cord", "cord": "Cord",
"cost": "Cost: ",
"cowl": "Cowl", "cowl": "Cowl",
"cqv": "Bolts", "cqv": "Bolts",
"crack": "Crack", "crack": "Crack",
@ -3471,6 +3482,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"of the Wolf": "of the Wolf", "of the Wolf": "of the Wolf",
"of the Wraith": "of the Wraith", "of the Wraith": "of the Wraith",
"of the Yeti": "of the Yeti", "of the Yeti": "of the Yeti",
"ok": "ok",
"opl": "Fulminating Potion", "opl": "Fulminating Potion",
"opm": "Exploding Potion", "opm": "Exploding Potion",
"ops": "Oil Potion", "ops": "Oil Potion",
@ -3782,7 +3794,10 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"strBSArmor": "Armor", "strBSArmor": "Armor",
"strBSMisc": "Misc", "strBSMisc": "Misc",
"strBSWeapons": "Weapons", "strBSWeapons": "Weapons",
"strBankGoldDeposit": "How much Gold would you like to deposit?",
"strBankGoldWithdraw": "How much Gold would you like to withdraw?",
"strClose": "Close", "strClose": "Close",
"strDropGoldHowMuch": "How much Gold would you like to drop?",
"strGoldInStash": "Gold in Stash:", "strGoldInStash": "Gold in Stash:",
"strGoldLabel": "Gold", "strGoldLabel": "Gold",
"strModAllResistances": "All Resistances +%d", "strModAllResistances": "All Resistances +%d",
@ -4000,7 +4015,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"uniquestoneskin": "Stone Skin", "uniquestoneskin": "Stone Skin",
"uniquemultishot": "Multiple Shots", "uniquemultishot": "Multiple Shots",
"UniqueAuraEnchanted": "Aura Enchanted", "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<Record<string, string>> = { export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
@ -4261,6 +4277,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Baal Throne": "巴尔", "Baal Throne": "巴尔",
"BaalColdMage": "死亡法师", "BaalColdMage": "死亡法师",
"Baals Minion": "毁灭的仆从", "Baals Minion": "毁灭的仆从",
"Back": "取消",
"Baezil's Vortex": "贝西尔的漩涡", "Baezil's Vortex": "贝西尔的漩涡",
"Balrog": "巴羅格", "Balrog": "巴羅格",
"Bane": "剋星", "Bane": "剋星",
@ -5611,6 +5628,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Razortine": "剃刀之叉", "Razortine": "剃刀之叉",
"ReanimatedHorde": " ", "ReanimatedHorde": " ",
"Red": "紅之", "Red": "紅之",
"Repair": "修復費用: ",
"Returned": "復活的怪物", "Returned": "復活的怪物",
"ReturnedArcher": "復生的弓箭手", "ReturnedArcher": "復生的弓箭手",
"ReturnedMage": "復生的法師", "ReturnedMage": "復生的法師",
@ -5682,6 +5700,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Sazabi's Mental Sheath": "沙撒璧的精神之鞘", "Sazabi's Mental Sheath": "沙撒璧的精神之鞘",
"Scarab": "聖甲蟲", "Scarab": "聖甲蟲",
"Schaefer's Hammer": "史恰佛之锤", "Schaefer's Hammer": "史恰佛之锤",
"Sell": "出售價格: ",
"Seraph's Hymn": "炽天使之韵", "Seraph's Hymn": "炽天使之韵",
"Serpent Lord": "海蛇之王", "Serpent Lord": "海蛇之王",
"Serpent's": "海蛇之", "Serpent's": "海蛇之",
@ -6249,6 +6268,13 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"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": "你沒有足夠的金錢。",
"TransactionResults11": "她現在無法過來,請等一會再試。",
"TransactionResults4": "你沒有足夠的放置空間。",
"TransactionResults5": "你的傭兵數量已經到達最高人數。",
"TransactionResults6": "這項物品剛剛交易掉了。",
"TransactionResults8": "有些事指出你並非擁有該項物品。",
"TransactionResults9": "我無法完成那個要求。",
"TrappedSoul": "被困的靈魂", "TrappedSoul": "被困的靈魂",
"Travincal": "崔凡克", "Travincal": "崔凡克",
"Treads of Cthon": "凱松的征服", "Treads of Cthon": "凱松的征服",
@ -6495,6 +6521,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"collar": "項圈", "collar": "項圈",
"compellingorb": "強制球體", "compellingorb": "強制球體",
"cord": "捆綁", "cord": "捆綁",
"cost": "花費:",
"cowl": "蒙頭斗蓬", "cowl": "蒙頭斗蓬",
"cqv": "十字弓彈", "cqv": "十字弓彈",
"crack": "破裂", "crack": "破裂",
@ -6858,6 +6885,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"of the Vampire": "吸血鬼的", "of the Vampire": "吸血鬼的",
"of the Wolf": "狼的", "of the Wolf": "狼的",
"of the Wraith": "死靈", "of the Wraith": "死靈",
"ok": "好",
"opl": "猛爆性葯劑", "opl": "猛爆性葯劑",
"opm": "爆炸葯劑", "opm": "爆炸葯劑",
"ops": "油", "ops": "油",
@ -7169,7 +7197,10 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"strBSArmor": "裝甲", "strBSArmor": "裝甲",
"strBSMisc": "其他", "strBSMisc": "其他",
"strBSWeapons": "武器", "strBSWeapons": "武器",
"strBankGoldDeposit": "你打算存入多少數量的金幣?",
"strBankGoldWithdraw": "你打算提出多少數量的金幣?",
"strClose": "關閉", "strClose": "關閉",
"strDropGoldHowMuch": "你想要丟下多少數量的金幣?",
"strGoldInStash": "儲藏起來的金錢:", "strGoldInStash": "儲藏起來的金錢:",
"strGoldLabel": "金幣", "strGoldLabel": "金幣",
"strModAllResistances": "所有抗性 +%d", "strModAllResistances": "所有抗性 +%d",
@ -7387,7 +7418,8 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"uniquestoneskin": "皮膚硬化", "uniquestoneskin": "皮膚硬化",
"uniquemultishot": "多重射擊", "uniquemultishot": "多重射擊",
"UniqueAuraEnchanted": "靈氣加強", "UniqueAuraEnchanted": "靈氣加強",
"strethereal": "无形的 (无法修复)" "strethereal": "无形的 (无法修复)",
"patchstring#95": "修復所有裝備:%d"
} }
export const CANONICAL_ISC_DESC_TABLE: Readonly<Record<string, CanonicalIscDescEntry>> = { export const CANONICAL_ISC_DESC_TABLE: Readonly<Record<string, CanonicalIscDescEntry>> = {

View File

@ -13,9 +13,10 @@ export const INV_COLS = 10
export const INV_ROWS = 4 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`). * Authentic Diablo II 1.13c Inventory Gold Limit (`D2Common/src/Units/Units.cpp:3103`).

View File

@ -3,7 +3,6 @@ import { CANONICAL_TOWN_STASHES, type TownStashEntity } from '../src/common/worl
import { import {
WorldPanelsHud, WorldPanelsHud,
STASH_GOLD_CAP, STASH_GOLD_CAP,
PLAYER_GOLD_CAP,
} from '../src/client/ui/world-panels.ts' } from '../src/client/ui/world-panels.ts'
import { HudManager } from '../src/client/ui/hud-manager.ts' import { HudManager } from '../src/client/ui/hud-manager.ts'
import { GameEngine } from '../src/server/engine/engine.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 panels.stashGold = 100_000
}) })
it('deposit and withdraw valid amounts: character gold + stash gold remains strictly constant', () => { it('stash gold cap is the D2Common #11060 constant (2,500,000); gold transfers are server-driven', () => {
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', () => {
expect(STASH_GOLD_CAP).toBe(2_500_000) expect(STASH_GOLD_CAP).toBe(2_500_000)
expect(PLAYER_GOLD_CAP).toBe(2_500_000) // WorldPanelsHud no longer mutates gold locally: deposit/withdraw are sent as
// ClickButton commands by HudModel and the balance arrives via server stat updates.
// Stash cap clamping on deposit expect((panels as unknown as Record<string, unknown>)['depositGold']).toBeUndefined()
panels.stashGold = 2_450_000 expect((panels as unknown as Record<string, unknown>)['withdrawGold']).toBeUndefined()
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)
}) })
it('does not intercept bottom keyhole ornament clicks as non-authentic deposit/withdraw buttons', () => { it('does not intercept bottom keyhole ornament clicks as non-authentic deposit/withdraw buttons', () => {

View File

@ -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<typeof createSession>, events: readonly ServerEvent[], npcId: number): ServerEvent[] {
const firstShop = events.findIndex(isShopItem)
expect(firstShop).toBeGreaterThan(0)
for (const e of events.slice(0, firstShop)) s.world.apply(e)
s.model.syncFromWorld()
s.world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: MONSTER, id: npcId } })
s.model.syncFromWorld()
return events.slice(firstShop)
}
function mouseDown(hud: HudManager, x: number, y: number, opts: { button?: number; shiftKey?: boolean } = {}): void {
hud.handleHudMouseDown({
clientX: x,
clientY: y,
button: opts.button ?? 0,
shiftKey: opts.shiftKey ?? false,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as MouseEvent)
}
function clickNpcOption(hud: HudManager, optionId: string): void {
const menu = hud.worldPanels.npcMenu
if (!menu?.options) throw new Error('no NPC menu')
const idx = menu.options.findIndex((o) => o.id === optionId)
expect(idx).toBeGreaterThanOrEqual(0)
const layout = hud.worldPanels.getNpcMenuLayout(hud.font)!
mouseDown(hud, layout.left + Math.trunc(layout.width / 2), layout.optionYs[idx]! - 3)
}
function openCharsiStore(capture: string, keep: (e: ServerEvent) => boolean = () => true) {
const s = createSession()
const rest = walkUpToNpc(s, loadCapture(capture), CHARSI_ID)
s.model.openNpcTrade(CHARSI_ID)
for (const e of rest) if (keep(e)) s.world.apply(e)
s.model.syncFromWorld()
return s
}
describe('NPC menu -> server store', () => {
it('NpcInit opens the descriptor menu; "trade/repair" sends 0x38 and the 0x9C store fills the panel with display prices', () => {
const s = createSession()
const rest = walkUpToNpc(s, loadCapture('05-d2gs-vendor-open.d2cap'), CHARSI_ID)
const menu = s.hud.worldPanels.npcMenu
expect(menu?.npcId).toBe(CHARSI_ID)
expect(menu?.options?.map((o) => o.id)).toContain('trade')
clickNpcOption(s.hud, 'trade')
expect(s.commands).toEqual([{ type: 'NpcOpenTrade', npcId: CHARSI_ID }])
expect(s.hud.leftPanel).toBe('vendor')
expect(s.world.npcInteraction.window).toBe('trade')
for (const e of rest) s.world.apply(e)
s.model.syncFromWorld()
const wp = s.hud.worldPanels
const shown = Object.values(wp.vendorTabPlacements).flat()
expect(shown.length).toBe(s.world.containers.shop.size)
expect(shown.length).toBe(46)
for (const pl of shown) {
const raw = s.world.containers.shop.get(Number(pl.item.id))!
expect(pl.item.buyCost).toBe(stubPrice(raw, 0, { world: s.world, npcClass: 154 }))
expect(pl.col).toBe(raw.gridX)
expect(pl.row).toBe(raw.gridY)
}
expect(wp.vendorRepairAllCost).toBe(REPAIR_ALL_STUB)
// Player items carry the sell value (type 1) and, at a smith, the repair cost (type 3).
for (const pl of s.hud.inventory.gridItems) {
expect(pl.item.sellCost).toBe(1000 * 2 + Number(pl.item.id))
}
const axeRaw = [...s.world.containers.equipment.values()].find((i) => i.code === 'hax')!
const axe = s.hud.inventory.equipped.weapon1
expect(axe?.id).toBe(String(axeRaw.id))
expect(axe?.repairCost).toBe(1000 * 4 + axeRaw.id)
// Closing the panel sends 0x30 and frees the store locally (no RemoveFromShop arrives).
s.hud.closeAllPanels()
expect(s.commands.at(-1)).toEqual({ type: 'NpcCancel', target: { unitType: MONSTER, id: CHARSI_ID } })
expect(s.world.containers.shop.size).toBe(0)
expect(s.hud.leftPanel).toBe('none')
expect(wp.activeVendorDescriptor).toBeNull()
})
it('"cancel" and clicks outside the menu send NpcCancel', () => {
const s = createSession()
walkUpToNpc(s, loadCapture('05-d2gs-vendor-open.d2cap'), CHARSI_ID)
clickNpcOption(s.hud, 'cancel')
expect(s.commands).toEqual([{ type: 'NpcCancel', target: { unitType: MONSTER, id: CHARSI_ID } }])
expect(s.hud.worldPanels.npcMenu).toBeNull()
s.world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: MONSTER, id: CHARSI_ID } })
s.model.syncFromWorld()
expect(s.hud.worldPanels.npcMenu?.npcId).toBe(CHARSI_ID)
mouseDown(s.hud, 20, 20)
expect(s.commands.at(-1)).toEqual({ type: 'NpcCancel', target: { unitType: MONSTER, id: CHARSI_ID } })
expect(s.hud.worldPanels.npcMenu).toBeNull()
})
it('Charsi does not gamble: openNpcGamble fails fast', () => {
const s = createSession()
walkUpToNpc(s, loadCapture('05-d2gs-vendor-open.d2cap'), CHARSI_ID)
expect(() => s.model.openNpcGamble(CHARSI_ID)).toThrow(/does not gamble/)
expect(s.commands).toEqual([])
})
it('a store needs price providers: without them openNpcTrade throws before sending', () => {
const world = new ClientWorld()
const commands: ClientCommand[] = []
const model = new HudModel({ world, hudManager: new HudManager(), onCommand: (c) => commands.push(c) })
expect(() => model.openNpcTrade(CHARSI_ID)).toThrow(/vendorPrice/)
expect(commands).toEqual([])
})
})
describe('store actions are commands only', () => {
it('buy / shift-buy click the store grid -> 0x32 with itemMode 0 and the #10107 price, throttled to one per 2000 ms', () => {
const s = openCharsiStore('05-d2gs-vendor-open.d2cap')
const before = s.commands.length
const pl = s.hud.worldPanels.getActiveVendorPlacements()[0]!
const x = VENDOR_GRID_ORIGIN.x + pl.col * VENDOR_GRID_ORIGIN.cellPx + 5
const y = VENDOR_GRID_ORIGIN.y + pl.row * VENDOR_GRID_ORIGIN.cellPx + 5
const itemId = Number(pl.item.id)
const raw = s.world.containers.shop.get(itemId)!
const shopSize = s.world.containers.shop.size
const gold = s.world.self.gold
mouseDown(s.hud, x, y)
expect(s.commands.slice(before)).toEqual([
{ type: 'NpcBuy', npcId: CHARSI_ID, itemId, itemMode: 0, gamble: false, shiftFill: false, cost: stubPrice(raw, 0, { world: s.world, npcClass: 154 }) },
])
// Second click inside the throttle window is dropped.
mouseDown(s.hud, x, y, { shiftKey: true })
expect(s.commands.length).toBe(before + 1)
s.clock.t += NPC_TRADE_THROTTLE_MS
mouseDown(s.hud, x, y, { shiftKey: true })
expect(s.commands.at(-1)).toMatchObject({ type: 'NpcBuy', itemId, shiftFill: true, gamble: false })
// Nothing changed locally: the server answers with 0x2A / 0x9C / stat 14.
expect(s.world.containers.shop.size).toBe(shopSize)
expect(s.world.self.gold).toBe(gold)
expect(s.hud.inventory.cursorItem).toBeNull()
})
it('sell / repair / repair-all send 0x33 / 0x35 with the current durability; repair and repair-all share one throttle', () => {
const s = openCharsiStore('05-d2gs-vendor-open.d2cap')
const before = s.commands.length
const scroll = [...s.world.containers.inventory.values()].find((i) => i.code === 'tsc')!
const axe = [...s.world.containers.equipment.values()].find((i) => i.code === 'hax')!
expect(s.model.vendorSell(scroll.id)).toBe(true)
expect(s.model.vendorSell(scroll.id)).toBe(false)
expect(s.model.vendorRepairItem(axe.id)).toBe(true)
expect(s.model.vendorRepairAll()).toBe(false)
s.clock.t += NPC_TRADE_THROTTLE_MS
expect(s.model.vendorRepairAll()).toBe(true)
expect(s.commands.slice(before)).toEqual([
{ type: 'NpcSell', npcId: CHARSI_ID, itemId: scroll.id, itemMode: 0, cost: stubPrice(scroll, 1, { world: s.world, npcClass: 154 }) },
{ type: 'NpcRepairItem', npcId: CHARSI_ID, itemId: axe.id, itemMode: 0, durability: 28 },
{ type: 'NpcRepairAll', npcId: CHARSI_ID },
])
expect(s.world.containers.inventory.has(scroll.id)).toBe(true)
expect(s.world.containers.equipment.get(axe.id)?.curDurability).toBe(28)
})
it('Repair All button and holding a cursor item over the store grid go through the command sink', () => {
const s = openCharsiStore('05-d2gs-vendor-open.d2cap')
const before = s.commands.length
const wp = s.hud.worldPanels
// Charsi button table [buy, sell, repair, repairAll]; slot 3 = Repair All.
wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, {
onClose: () => {},
onWaypointTeleport: () => {},
onVendorRepairAll: () => s.model.vendorRepairAll(),
})
expect(s.commands.slice(before)).toEqual([{ type: 'NpcRepairAll', npcId: CHARSI_ID }])
})
it('a store action with no store open fails fast', () => {
const s = createSession()
expect(() => s.model.vendorBuy(1, false)).toThrow(/no store window/)
expect(() => s.model.vendorRepairAll()).toThrow(/no store window/)
})
})
describe('S2C 0x2A NpcTransaction feedback', () => {
it('successful sells (result 1) play UI sound 0xdd; rejected buys (result 12) show TransactionResults10 (tbl 3362)', () => {
const s = createSession()
const rest = walkUpToNpc(s, loadCapture('06-d2gs-vendor-buy-sell.d2cap'), AKARA_ID)
s.model.openNpcTrade(AKARA_ID)
expect(s.hud.worldPanels.vendorRepairAllCost).toBeNull()
for (const e of rest) {
s.world.apply(e)
s.model.syncFromWorld()
}
expect(s.sounds).toEqual([NPC_SELL_SOUND_ID, NPC_SELL_SOUND_ID])
expect(s.model.lastNpcTransactionMessage).toEqual({ tblKey: 'TransactionResults10', text: requireTbl('TransactionResults10', 'CHI') })
// Gold comes only from stat 14 (2 gold after the two sells).
expect(s.hud.inventory.gold).toBe(s.world.self.gold)
})
})
describe('NPC store tbl strings', () => {
it('every 0x2A message key and store/gold-dialog key is packed in both languages', () => {
const keys = new Set<string>()
for (let result = 0; result <= 20; result++) {
const k = npcTransactionMessageKey(result)
if (k !== null) keys.add(k)
}
expect([...keys].sort()).toEqual([
'TransactionResults10',
'TransactionResults11',
'TransactionResults4',
'TransactionResults5',
'TransactionResults6',
'TransactionResults8',
'TransactionResults9',
])
for (const k of [...keys, 'cost', 'Repair', 'Sell', 'ok', 'Back', 'strDropGoldHowMuch', 'strBankGoldDeposit', 'strBankGoldWithdraw']) {
expect(requireTbl(k, 'ENG').length).toBeGreaterThan(0)
expect(requireTbl(k, 'CHI').length).toBeGreaterThan(0)
}
expect(requireTbl('patchstring#95', 'ENG')).toBe('Repair all equipment: %d')
expect(requireTbl('patchstring#95', 'CHI')).toBe('修復所有裝備:%d')
expect(() => requireTbl('notAPackedKey', 'ENG')).toThrow(/not packed/)
})
})
describe('stash and gold dialog', () => {
function openStash() {
const s = createSession()
for (const e of loadCapture('08-d2gs-stash-gold.d2cap')) s.world.apply(e)
s.model.syncFromWorld()
expect(s.hud.leftPanel).toBe('stash')
s.world.apply({ type: 'StatSet', stat: D2StatId.Gold, value: 5000 })
s.world.apply({ type: 'StatSet', stat: D2StatId.GoldBank, value: 1200 })
s.model.syncFromWorld()
expect(s.hud.inventory.gold).toBe(5000)
expect(s.hud.worldPanels.stashGold).toBe(1200)
return s
}
it('deposit (mode 3) is pre-filled with the bag gold; typed digits replace it; ok sends 0x4F 0x14', () => {
const s = openStash()
s.hud.openGoldDialog(3)
expect(s.hud.goldDialog).toEqual({ mode: 3, amount: 5000, fresh: true })
s.hud.typeGoldDialogKey('7')
s.hud.typeGoldDialogKey('5')
s.hud.confirmGoldDialog()
expect(s.commands).toEqual([{ type: 'ClickButton', buttonId: ClickButtonId.DepositGold, amount: 75 }])
expect(s.hud.inventory.gold).toBe(5000)
expect(s.hud.worldPanels.stashGold).toBe(1200)
})
it('withdraw (mode 4) clamps to the stash balance; 0 sends nothing; drop (mode 0) sends 0x15 DropGold', () => {
const s = openStash()
s.hud.openGoldDialog(4)
s.hud.setGoldDialogAmount(99_999)
expect(s.hud.goldDialog?.amount).toBe(1200)
s.hud.confirmGoldDialog()
s.hud.openGoldDialog(4)
s.hud.setGoldDialogAmount(0)
s.hud.confirmGoldDialog()
s.hud.openGoldDialog(0)
s.hud.setGoldDialogAmount(10)
s.hud.confirmGoldDialog()
expect(s.commands).toEqual([
{ type: 'ClickButton', buttonId: ClickButtonId.WithdrawGold, amount: 1200 },
{ type: 'DropGold', unitId: s.world.selfId, amount: 10 },
])
expect(s.hud.goldDialog).toBeNull()
})
it('the stash gold button opens the withdraw dialog; cancel sends nothing; closing the stash sends 0x4F 0x12', () => {
const s = openStash()
s.hud.worldPanels.handleLeftDockClick('stash', STASH_X + STASH_GOLD_BTN_HIT.relX0 + 10, STASH_Y + STASH_GOLD_BTN_HIT.relY0 + 5, {
onClose: () => {},
onWaypointTeleport: () => {},
onStashGoldButton: () => s.hud.openGoldDialog(4),
})
expect(s.hud.goldDialog?.mode).toBe(4)
s.hud.cancelGoldDialog()
expect(s.commands).toEqual([])
s.hud.closeAllPanels()
expect(s.commands).toEqual([{ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 }])
expect(s.world.npcInteraction.stashOpen).toBe(false)
expect(() => s.hud.openGoldDialog(3)).toThrow(/stash open/)
})
})

View File

@ -31,7 +31,8 @@ import {
type CursorFrameMeta, type CursorFrameMeta,
} from '../src/common/items/cursor-meta.ts' } from '../src/common/items/cursor-meta.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-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 { VENDOR_BUTTON_TABLE_X } from '../src/client/ui/world-panels.ts'
import { seedDemoHud } from './fixtures/hud-demo-state.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', () => { 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() 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) // Explicitly seed the legacy demo Sorceress state (class, level, gold, inventory)
seedDemoHud(hud) seedDemoHud(hud)
const closeNpc = vi.fn()
hud.commandSink = { closeNpc } as unknown as HudCommandSink
// Open Charsi vendor panel // Server opened Charsi's store (0x9C AddToShop items + NpcOpenTrade): HudModel fills and shows it
expect(hud.openVendorSession('Charsi', 'trade', 0)).toBe(true) openGeneratedVendorShop(hud.worldPanels, 'Charsi', 'trade', 24, 0, 7)
hud.showVendorPanel()
expect(hud.state.leftPanel).toBe('vendor') expect(hud.state.leftPanel).toBe('vendor')
expect(hud.state.vendorTradeState).toBe('idle') expect(hud.state.vendorTradeState).toBe('idle')
expect(hud.state.cursorTradeMode).toBeNull() 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') expect(hud.state.cursorTradeMode).toBe('sell')
hud.closeAllPanels() hud.closeAllPanels()
expect(hud.state.leftPanel).toBe('none') expect(hud.state.leftPanel).toBe('none')
expect(closeNpc).toHaveBeenCalledWith(7)
expect(hud.state.cursorTradeMode).toBeNull() expect(hud.state.cursorTradeMode).toBeNull()
expect(hud.state.cursorAnimation).toBe('protate') expect(hud.state.cursorAnimation).toBe('protate')
}) })

View File

@ -235,21 +235,7 @@ describe('Issue #388 — Ground Item & Drop System Ground Truth', () => {
}) })
}) })
describe('4. InventoryPanel Gold Dropping & findFreeGridSlot', () => { describe('4. InventoryPanel 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)
})
it('findFreeGridSlot returns first available (col, row) without overlapping existing items', () => { it('findFreeGridSlot returns first available (col, row) without overlapping existing items', () => {
const gridItems: GridPlacement[] = [ const gridItems: GridPlacement[] = [
{ {

View File

@ -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<typeof itemToUiInventoryItem>; 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 })
}

View File

@ -43,6 +43,7 @@ import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts'
import { InventoryPanel, itemToUiInventoryItem } from '../src/client/ui/inventory.ts' import { InventoryPanel, itemToUiInventoryItem } from '../src/client/ui/inventory.ts'
import { SkillTreePanel } from '../src/client/ui/skill-tree-panel.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 { 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 { ControlBarHud } from '../src/client/ui/control-bar.ts'
import { GlobesHud } from '../src/client/ui/globes.ts' import { GlobesHud } from '../src/client/ui/globes.ts'
import { SkillHotkeysHud } from '../src/client/ui/hotkeys.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('剩余技能点')
expect(textCalls).toContain('角色属性 (A/C)') expect(textCalls).toContain('角色属性 (A/C)')
expect(textCalls).toContain('罗格营地') expect(textCalls).toContain('罗格营地')
expect(textCalls).toContain('私人储物箱') expect(textCalls).toContain('你的私人儲藏箱') // tbl key 'bank' (CHI)
expect(textCalls).toContain('赫拉迪克方块 (已合成 0 次)') expect(textCalls).toContain('赫拉迪克方块 (已合成 0 次)')
expect(textCalls).not.toContain('角色属性 (Character)') 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 // Also test Charsi vendor inventory across all 4 tabs
const wpVendor = new WorldPanelsHud() const wpVendor = new WorldPanelsHud()
wpVendor.lang = 'ENG' 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 tabId of ['armor', 'weapons1', 'weapons2', 'misc'] as const) {
for (const p of wpVendor.vendorTabPlacements[tabId]) { for (const p of wpVendor.vendorTabPlacements[tabId]) {
const { ctx, font, textCalls } = createMockCanvasAndFont() const { ctx, font, textCalls } = createMockCanvasAndFont()

View File

@ -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 // NPC talking dialog body also uses fontformal12 while header uses font16
drawnCalls.length = 0 drawnCalls.length = 0
worldPanels.npcMenu = { worldPanels.npcMenu = {
npcId: null,
npcName: 'Akara', npcName: 'Akara',
x: 400, x: 400,
y: 260, y: 260,
@ -711,6 +712,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
} as unknown as D2FontRenderer } as unknown as D2FontRenderer
worldPanels.npcMenu = { worldPanels.npcMenu = {
npcId: null,
npcName: '阿卡拉', npcName: '阿卡拉',
x: 400, x: 400,
y: 260, y: 260,

View File

@ -8,7 +8,7 @@
* 3. BodyLoc1/BodyLoc2 Equipment Slots Resolution (allowedSlots) with category inheritance * 3. BodyLoc1/BodyLoc2 Equipment Slots Resolution (allowedSlots) with category inheritance
* 4. Bilingual naming (nameZh, baseNameZh) and rich tooltip stats formatting * 4. Bilingual naming (nameZh, baseNameZh) and rich tooltip stats formatting
* 5. Normal and Telekinesis ground item pickup conversion via itemToUiInventoryItem * 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 * 7. Inventory full refusal bounce, audio feedback, and notification retention
*/ */
@ -23,7 +23,6 @@ import {
import { import {
InventoryPanel, InventoryPanel,
resolveItemSpriteRect, resolveItemSpriteRect,
PLAYER_GOLD_CAP,
} from '../src/client/ui/inventory.ts' } from '../src/client/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import { GameEngine } from '../src/server/engine/engine.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 mockCanvas: any
let mockStatus: { textContent: string } let mockStatus: { textContent: string }

View File

@ -22,7 +22,7 @@
* - Authentic allowedSlots resolution and paperdoll equipping * - Authentic allowedSlots resolution and paperdoll equipping
* *
* 4. Gold Currency Accumulation vs Inventory Full Flippy Bounce: * 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 * - Full 10x4 inventory grid refusal, metrics.inventoryRefusals, and parabolic bounce physics
*/ */
@ -37,7 +37,6 @@ import {
resolveItemSpriteRect, resolveItemSpriteRect,
identifyUiItem, identifyUiItem,
isUiItemUnidentified, isUiItemUnidentified,
PLAYER_GOLD_CAP,
type UiInventoryItem, type UiInventoryItem,
type EquipSlotId, type EquipSlotId,
} from '../src/client/ui/inventory.ts' } from '../src/client/ui/inventory.ts'

View File

@ -12,7 +12,6 @@ import {
STASH_PANEL_ORIGIN, STASH_PANEL_ORIGIN,
STASH_GRID_ORIGIN, STASH_GRID_ORIGIN,
STASH_GOLD_CAP, STASH_GOLD_CAP,
PLAYER_GOLD_CAP,
STASH_HEADER_ICON_SOCKET, STASH_HEADER_ICON_SOCKET,
STASH_HEADER_TOP_RECESS, STASH_HEADER_TOP_RECESS,
STASH_HEADER_BOTTOM_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', () => { describe('5. Stash Gold Cap (server-driven transfers)', () => {
let panels: WorldPanelsHud it('exposes STASH_GOLD_CAP = 2,500,000 (D2Common #11060) and performs no local gold mutation', () => {
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', () => {
expect(STASH_GOLD_CAP).toBe(2_500_000) expect(STASH_GOLD_CAP).toBe(2_500_000)
panels.stashGold = 2_490_000 const panels = new WorldPanelsHud()
inv.gold = 100_000 // Deposit/withdraw are ClickButton commands sent by HudModel; D2Game owns the balances.
expect((panels as unknown as Record<string, unknown>)['depositGold']).toBeUndefined()
const deposited = panels.depositGold(50_000, inv) expect((panels as unknown as Record<string, unknown>)['withdrawGold']).toBeUndefined()
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)
}) })
}) })
@ -548,8 +492,8 @@ describe('Milestone M5 (Issue #150): Camp Town Stash Entities Across 5 Acts & 6x
mockFont, mockFont,
) )
const titleCall = drawTextCalls.find(c => c.text.includes('私人储物箱')) const titleCall = drawTextCalls.find(c => c.text.includes('你的私人儲藏箱')) // tbl 'bank'
const goldCall = drawTextCalls.find(c => c.text.includes('储物箱金币')) const goldCall = drawTextCalls.find(c => c.text.includes('儲藏起來的金錢:')) // tbl 'strGoldInStash'
expect(titleCall).toBeDefined() expect(titleCall).toBeDefined()
expect(goldCall).toBeDefined() expect(goldCall).toBeDefined()

View File

@ -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 - 7).toBeGreaterThanOrEqual(INV_GOLD_BOX_RECESS.y)
expect(goldTextCall!.y + 1).toBeLessThanOrEqual(INV_GOLD_BOX_RECESS.y + INV_GOLD_BOX_RECESS.h - 1) 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 // 4. Verify clicking directly on the Gold Coin Button (490, 460) asks for the gold dialog (no local drop)
let droppedGold = 0 let goldButtonPresses = 0
expect( expect(
inv.handleClick(490, 460, { inv.handleClick(490, 460, {
onDropGold: amt => { onGoldButton: () => {
droppedGold = amt goldButtonPresses++
}, },
}), }),
).toBe(true) ).toBe(true)
expect(droppedGold).toBe(10_000) expect(goldButtonPresses).toBe(1)
// 5. Verify D2FontRenderer.drawText uses lining-numeral serif stack (never old-style Georgia) // 5. Verify D2FontRenderer.drawText uses lining-numeral serif stack (never old-style Georgia)
const fontRenderer = new D2FontRenderer() const fontRenderer = new D2FontRenderer()

View File

@ -385,47 +385,5 @@ describe('Diablo II v1.13c Unidentified Equipment Drops & Identification System'
expect(tomeItem.quantity).toBe(4) expect(tomeItem.quantity).toBe(4)
expect(inv.gridItems.some(p => p.item.id === tomeItem.id)).toBe(true) 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)
})
}) })
}) })

View File

@ -33,6 +33,7 @@ import {
hitTestNpcDialogOption, hitTestNpcDialogOption,
resolveVendorButtonLayout, resolveVendorButtonLayout,
} from '../src/client/ui/world-panels.ts' } from '../src/client/ui/world-panels.ts'
import { openGeneratedVendorShop } from './helpers/vendor-shop-fixture.ts'
function readPngHeaderSize(buf: Uint8Array): { width: number; height: number } { function readPngHeaderSize(buf: Uint8Array): { width: number; height: number } {
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) 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 wp = new WorldPanelsHud()
const inv = new InventoryPanel() const inv = new InventoryPanel()
wp.activePlayerInventory = inv 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 { ctx, font, drawImageCalls, strokeRectCalls, fontCalls } = createMockCanvasAndFont()
const assets = { ...createFakeVendorAssets(), inventory: inv } const assets = { ...createFakeVendorAssets(), inventory: inv }
@ -326,7 +327,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
const wp = new WorldPanelsHud() const wp = new WorldPanelsHud()
const inv = new InventoryPanel() const inv = new InventoryPanel()
wp.activePlayerInventory = inv 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 // Force armor tab empty to verify 0x6fb45e5e-0x6fb45f5d tab presence guard
wp.vendorTabPlacements.armor = [] wp.vendorTabPlacements.armor = []
@ -349,7 +350,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
const wp = new WorldPanelsHud() const wp = new WorldPanelsHud()
const inv = new InventoryPanel() const inv = new InventoryPanel()
wp.activePlayerInventory = inv wp.activePlayerInventory = inv
expect(wp.openVendorForNpc('Gheed', 'gamble', 30, 0)).toBe(true) openGeneratedVendorShop(wp, 'Gheed', 'gamble', 30, 0)
const { ctx, font, drawImageCalls, fontCalls } = createMockCanvasAndFont() const { ctx, font, drawImageCalls, fontCalls } = createMockCanvasAndFont()
wp.drawLeftDockPanel(ctx, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font) 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() const inv = new InventoryPanel()
inv.gold = 50000 inv.gold = 50000
wp.activePlayerInventory = inv 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 // Repair All total is supplied by HudModel (VendorRepairAllFn over priceItem113c type 3)
const helm: UiInventoryItem = { const expectedCost = 1234
id: 'damaged-cap', wp.vendorRepairAllCost = expectedCost
code: 'cap', let repairAllCalls = 0
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)
// Open-interval boundary check: relX = tableX (116) or tableX + 45 (161) must NOT hit // Open-interval boundary check: relX = tableX (116) or tableX + 45 (161) must NOT hit
expect(wp.hitTestVendorButtonSlot(80 + 116, 60 + 390)).toBeNull() expect(wp.hitTestVendorButtonSlot(80 + 116, 60 + 390)).toBeNull()
@ -403,6 +389,9 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
}, },
onWaypointTeleport: () => {}, onWaypointTeleport: () => {},
inventory: inv, inventory: inv,
onVendorRepairAll: () => {
repairAllCalls++
},
} }
// Click slot 0 (Buy) -> latches 'buy' (button frame 3, cursor frame 3) // 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) ).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) wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, callbacks)
expect(closed).toBe(false) expect(closed).toBe(false)
expect(wp.vendorTradeState).toBe('idle') expect(wp.vendorTradeState).toBe('idle')
expect(wp.getActiveVendorCursorFrame()).toBeNull() expect(wp.getActiveVendorCursorFrame()).toBeNull()
expect(wp.pressedMomentarySlot).toBe(3) expect(wp.pressedMomentarySlot).toBe(3)
expect(helm.durability?.current).toBe(12) expect(repairAllCalls).toBe(1)
wp.releaseMomentaryVendorButton() wp.releaseMomentaryVendorButton()
expect(wp.pressedMomentarySlot).toBeNull() expect(wp.pressedMomentarySlot).toBeNull()
// Switch to Akara and click slot 3 (Close) -> closes panel, slot 2 (blank) is ignored // 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() 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) wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, callbacks)
expect(closed).toBe(true) 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 wp = new WorldPanelsHud()
const inv = new InventoryPanel() const inv = new InventoryPanel()
inv.gold = 999999 inv.gold = 999999
wp.activePlayerInventory = inv wp.activePlayerInventory = inv
expect(wp.openVendorForNpc('Charsi', 'trade', 24, 0)).toBe(true) openGeneratedVendorShop(wp, 'Charsi', 'trade', 24, 0)
expect(wp.activeVendorTab).toBe('armor') expect(wp.activeVendorTab).toBe('armor')
// Leave only 1 non-permStore item on the armor tab and buy it // The server removed the last armor item (0x9D RemoveFromShop after a buy): HudModel re-applies the
const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.permStore && !p.item.rawItem?.permStore) // same store without it and D2Client 0x6fb3f710 advances to the next non-empty tab.
expect(nonPerm).toBeDefined() const remaining = (['weapons1', 'weapons2', 'misc'] as const).flatMap((id, i) =>
wp.vendorTabPlacements.armor = [nonPerm!] wp.vendorTabPlacements[id].map(placement => ({ storePage: i + 2, placement })),
)
const res = wp.buyFromActiveVendor(nonPerm!.col, nonPerm!.row, inv, false) wp.setVendorShop({ npcId: 1, descriptor: wp.activeVendorDescriptor!, mode: 'trade', items: remaining })
expect(res.ok).toBe(true)
expect(wp.vendorTabPlacements.armor).toHaveLength(0) expect(wp.vendorTabPlacements.armor).toHaveLength(0)
expect(wp.activeVendorTab).toBe('weapons1') expect(wp.activeVendorTab).toBe('weapons1')
}) })
@ -642,6 +630,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
openedVendorMode = mode openedVendorMode = mode
}, },
onIdentifyAll: () => {}, onIdentifyAll: () => {},
onCancel: () => {},
}, },
font, font,
) )
@ -654,7 +643,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
const wp = new WorldPanelsHud() const wp = new WorldPanelsHud()
const inv = new InventoryPanel() const inv = new InventoryPanel()
wp.activePlayerInventory = inv 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 // Set player context: Level 10 Sorceress with 25 Str, 25 Dex
wp.setPlayerContext({ wp.setPlayerContext({
@ -860,7 +849,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
dex: 500, dex: 500,
classCode: 'sor', classCode: 'sor',
}) })
expect(wp.openVendorForNpc('Gheed', 'gamble', 99, 0)).toBe(true) openGeneratedVendorShop(wp, 'Gheed', 'gamble', 99, 0)
expect(wp.gamblePlacements.length).toBeGreaterThan(0) expect(wp.gamblePlacements.length).toBeGreaterThan(0)
// Even when hovering a gamble item with an empty cursor, every gamble item background must be RED // Even when hovering a gamble item with an empty cursor, every gamble item background must be RED