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