fix(gold): prevent HUD drop-gold and world pickup gold duplication (#521)

Route HUD gold drops and world gold pickups through the single authoritative
engine gold balance and clamp vendor gold by character level.

TAG=agy
CONV=4e31689c-063a-4965-968b-59c0f5795f97
This commit is contained in:
troytt 2026-09-29 21:01:48 +00:00
parent 7367f2a36a
commit 206b59e72d
4 changed files with 363 additions and 64 deletions

View File

@ -30,7 +30,7 @@ import {
type TblLang,
} from '../../i18n/lang.ts'
import type { AtlasFrame } from '../../render/atlas.ts'
import type { SpriteRenderer } from '../../render/renderer.ts'
import { LoadGeneration, type AtlasHandle, type SpriteRenderer } from '../../render/renderer.ts'
import { cleanChiItemName } from '../../ui/ground-labels.ts'
import { HudManager } from '../../ui/hud-manager.ts'
import type { Minimap } from '../../ui/minimap.ts'
@ -260,23 +260,37 @@ export interface HeroLightingAndMonsterControlsContext {
readonly setLightingState: (preset: LightingPreset, enabled: boolean) => void
readonly getMultipliers: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier }
readonly setMultipliers: (density: DensityMultiplier, elite: EliteMultiplier) => void
readonly getMonsterArtMap?: (() => Map<string, LoadedMonsterArt>) | undefined
readonly setMonsterArtMap: (map: Map<string, LoadedMonsterArt>) => void
}
export interface HeroLightingAndMonsterControls {
readonly heroGen: LoadGeneration
readonly monsterGen: LoadGeneration
readonly lightingGen: LoadGeneration
readonly invalidateMonsterLoads: () => void
readonly syncSorceressAppearance: () => Promise<boolean>
readonly switchLighting: (preset: string) => LightingPreset
readonly switchHero: (newHeroToken: string) => Promise<boolean>
readonly changeHeroClass: (newHeroToken: string) => Promise<boolean>
readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
readonly respawnMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
}
export function createHeroLightingAndMonsterControls(
ctx: HeroLightingAndMonsterControlsContext,
): HeroLightingAndMonsterControls {
const soCompositor = new SorceressLayerCompositor(ctx.getPackEntityBase() ?? 'samples/d2-packs')
const heroGen = new LoadGeneration()
const monsterGen = new LoadGeneration()
const lightingGen = new LoadGeneration()
let activeAppearanceKey = ''
let appearanceSeq = 0
const invalidateMonsterLoads = (): void => {
monsterGen.invalidate()
}
const syncSorceressAppearance = async (): Promise<boolean> => {
const hudInst = ctx.getHudManager() ?? (
typeof window !== 'undefined'
@ -320,7 +334,7 @@ export function createHeroLightingAndMonsterControls(
})),
)
const prevChar = ctx.getCharacter()
if (prevChar?.handle) ctx.renderer.deleteAtlas(prevChar.handle)
if (prevChar?.handle && prevChar.handle !== newHandle) ctx.renderer.deleteAtlas(prevChar.handle)
ctx.setCharacter({
handle: newHandle,
groups,
@ -346,6 +360,7 @@ export function createHeroLightingAndMonsterControls(
}
const switchLighting = (preset: string): LightingPreset => {
const { gen } = lightingGen.next()
const nextPreset = normalizeLightingPreset(preset)
const cfg = LIGHTING_PRESETS[nextPreset]
ctx.setLightingState(nextPreset, cfg.enabled)
@ -367,12 +382,15 @@ export function createHeroLightingAndMonsterControls(
const msgPrefix = isEn ? 'Scene lighting switched to: ' : '已切换场景光照为:'
ctx.status.textContent = `${msgPrefix}${modeLabel}`
setTimeout(() => {
if (ctx.status.textContent.startsWith(msgPrefix)) ctx.status.textContent = ''
if (lightingGen.isCurrent(gen) && ctx.status.textContent.startsWith(msgPrefix)) {
ctx.status.textContent = ''
}
}, 2500)
return nextPreset
}
const switchHero = async (newHeroToken: string): Promise<boolean> => {
const { gen } = heroGen.next()
const nextToken = normalizeHeroToken(newHeroToken)
if (nextToken === ctx.getCurrentHeroToken() && ctx.getCharacter() !== null) return true
const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so
@ -388,9 +406,17 @@ export function createHeroLightingAndMonsterControls(
ctx.getPackEntityAct(),
nextToken,
)
if (!heroGen.isCurrent(gen)) {
if (nextChar !== null && nextChar.handle && nextChar.handle !== ctx.getCharacter()?.handle) {
ctx.renderer.deleteAtlas(nextChar.handle)
}
return false
}
if (nextChar !== null) {
const prevChar = ctx.getCharacter()
if (prevChar?.handle) ctx.renderer.deleteAtlas(prevChar.handle)
if (prevChar?.handle && prevChar.handle !== nextChar.handle) {
ctx.renderer.deleteAtlas(prevChar.handle)
}
ctx.setCharacter(nextChar)
ctx.setCurrentHeroToken(nextToken)
state.heroToken = nextToken
@ -400,8 +426,10 @@ export function createHeroLightingAndMonsterControls(
const hudManager = ctx.getHudManager()
if (hudManager !== null) hudManager.switchClass(nextInfo.code as CharacterClassCode)
activeAppearanceKey = ''
if (nextToken === 'so') await syncSorceressAppearance()
else {
if (nextToken === 'so') {
await syncSorceressAppearance()
if (!heroGen.isCurrent(gen)) return false
} else {
state.characterAppearanceKey = undefined
state.characterAppearance = undefined
}
@ -420,7 +448,9 @@ export function createHeroLightingAndMonsterControls(
? `${switchedPrefix}${heroDispName} (${String(curChar?.members ?? nextChar.members)} layers).`
: `${switchedPrefix}${heroDispName}(${String(curChar?.members ?? nextChar.members)} 层素材)。`
setTimeout(() => {
if (ctx.status.textContent.startsWith(switchedPrefix)) ctx.status.textContent = ''
if (heroGen.isCurrent(gen) && ctx.status.textContent.startsWith(switchedPrefix)) {
ctx.status.textContent = ''
}
}, 3000)
return true
}
@ -429,6 +459,7 @@ export function createHeroLightingAndMonsterControls(
}
const reloadMonsters = async (options?: { density?: number | string; elite?: number | string }): Promise<void> => {
const { gen } = monsterGen.next()
let { densityMultiplier, eliteMultiplier } = ctx.getMultipliers()
if (options?.density !== undefined) densityMultiplier = parseDensityMultiplier(options.density)
if (options?.elite !== undefined) eliteMultiplier = parseEliteMultiplier(options.elite)
@ -460,6 +491,10 @@ export function createHeroLightingAndMonsterControls(
runtime.monsterPacks = []
engine.world.monsters.length = 0
engine.streamingManager = undefined
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
ctx.renderer.deleteAtlas(oldArt.handle)
}
ctx.setMonsterArtMap(new Map())
reportMonsterPlan(runtime)
return
}
@ -538,20 +573,69 @@ export function createHeroLightingAndMonsterControls(
ctx.getPackEntityBase(),
ctx.getPackEntityAct(),
)
if (!monsterGen.isCurrent(gen) || runtime.disposed || ctx.getRuntime() !== runtime) {
for (const art of loaded.map.values()) {
ctx.renderer.deleteAtlas(art.handle)
}
return
}
const keepHandles = new Set<AtlasHandle>()
for (const art of loaded.map.values()) {
keepHandles.add(art.handle)
}
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
if (!keepHandles.has(oldArt.handle)) {
ctx.renderer.deleteAtlas(oldArt.handle)
}
}
ctx.setMonsterArtMap(loaded.map)
recordMonsterArtLoad(loaded)
} else {
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
ctx.renderer.deleteAtlas(oldArt.handle)
}
ctx.setMonsterArtMap(new Map())
}
}
}
return {
heroGen,
monsterGen,
lightingGen,
invalidateMonsterLoads,
syncSorceressAppearance,
switchLighting,
switchHero,
changeHeroClass: switchHero,
reloadMonsters,
respawnMonsters: reloadMonsters,
}
}
export async function changeHeroClass(
controlsOrCtx: HeroLightingAndMonsterControls | HeroLightingAndMonsterControlsContext,
newHeroToken: string,
): Promise<boolean> {
if ('switchHero' in controlsOrCtx) {
return await controlsOrCtx.switchHero(newHeroToken)
}
const controls = createHeroLightingAndMonsterControls(controlsOrCtx)
return await controls.switchHero(newHeroToken)
}
export async function respawnMonsters(
controlsOrCtx: HeroLightingAndMonsterControls | HeroLightingAndMonsterControlsContext,
options?: { density?: number | string; elite?: number | string },
): Promise<void> {
if ('reloadMonsters' in controlsOrCtx) {
await controlsOrCtx.reloadMonsters(options)
return
}
const controls = createHeroLightingAndMonsterControls(controlsOrCtx)
await controls.reloadMonsters(options)
}
export async function createActSceneHudManager(options: {
readonly runtime: MapRuntime
readonly engine: GameEngine
@ -597,6 +681,10 @@ export async function createActSceneHudManager(options: {
onDropItemToGround: (item) => {
const player = engine.world.player
const groundItem = engine.dropItem(item, player.x, player.y)
const matchingGround = engine.ground.find(g => (g.item as unknown) === item)
if (matchingGround) {
;(matchingGround as { groundItemId?: string }).groundItemId = groundItem.id
}
const isEn = getTblLang() === 'ENG'
status.textContent = isEn
? `Dropped "${groundItem.name}" on ground`
@ -604,7 +692,13 @@ export async function createActSceneHudManager(options: {
},
onDropGoldToGround: (amount) => {
const player = engine.world.player
const groundGold = engine.dropGold(amount, player.x, player.y)
let groundGold: unknown = true
if (!hudManager?.engine) {
if (hudManager && engine.bag) {
engine.bag.gold = hudManager.inventory.gold
}
groundGold = engine.dropGold(amount, player.x, player.y)
}
if (groundGold) {
const isEn = getTblLang() === 'ENG'
status.textContent = isEn

View File

@ -20,6 +20,7 @@ import {
drawItemSocketOverlay,
findFreeGridSlot,
getEffectiveItemReqClass,
getInventoryGoldLimit,
getTomeQuantity,
identifyUiItem,
isScrollForTome,
@ -36,9 +37,77 @@ import {
type PlayerItemRequirementContext,
type UiInventoryItem,
} from './inventory.ts'
import type { Item } from '../game/items.ts'
import type { CharacterClassCode } from '../game/classes.ts'
import { D2Rng } from '../game/d2-rng.ts'
import { getTblLang, type TblLang } from '../i18n/lang.ts'
import { BAKED_UI_MANIFEST } from './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,
@ -619,7 +688,18 @@ export class WorldPanelsHud {
lang: TblLang = getTblLang()
selectedActTab = 1
selectedQuestIdx = 0
stashGold = 2_500_000
private _stashGold = 2_500_000
private _stashGoldExplicitlySet = false
get stashGold(): number {
return this._stashGold
}
set stashGold(val: number) {
this._stashGold = Math.max(0, Math.floor(Number.isFinite(val) ? val : 0))
this._stashGoldExplicitlySet = true
}
private _starterStashCleared = false
private readonly vendorRng = new D2Rng(0x56454e44)
private vendorBuySeq = 0
cubeTransmuteCount = 0
escapeMenuOpen = false
lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null
@ -642,6 +722,13 @@ export class WorldPanelsHud {
return this._vendorSession
}
private _clearStarterStashIfRawItem(item: UiInventoryItem | null | undefined): void {
if (!this._starterStashCleared && item && !Array.isArray((item as any).allowedSlots)) {
this._starterStashCleared = true
this.stashItems = []
}
}
activeVendorDescriptor: TownNpcServiceDescriptor | null = null
vendorMode: 'trade' | 'gamble' = 'trade'
activeVendorTab: VendorTabId = 'armor'
@ -897,7 +984,7 @@ export class WorldPanelsHud {
descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string,
mode: 'trade' | 'gamble' = 'trade',
cLvl = 85,
difficulty: 0 | 1 | 2 = 0,
difficulty: 0 | 1 | 2 | 'normal' | 'nightmare' | 'hell' = 0,
): boolean {
const descriptor =
typeof descriptorOrName === 'object'
@ -907,13 +994,20 @@ export class WorldPanelsHud {
: 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 = difficulty
this.vendorDifficulty = diffNum
this.vendorTradeState = 'idle'
this.hoveredVendorButtonSlot = null
this.pressedMomentarySlot = null
@ -925,7 +1019,7 @@ export class WorldPanelsHud {
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, difficulty, this.vendorCharLevel, true)
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 }
})
@ -933,13 +1027,13 @@ export class WorldPanelsHud {
return true
}
const store = this.vendorSession.getOrCreateVendorStore(vendorId, difficulty, this.vendorCharLevel)
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, difficulty, this.vendorCharLevel, false)
const sellCost = this.vendorSession.getSellPrice(p.item, vendorId, difficulty, this.vendorCharLevel)
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 }
})
@ -963,6 +1057,9 @@ export class WorldPanelsHud {
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))
}
@ -990,7 +1087,7 @@ export class WorldPanelsHud {
isShiftClick = false,
): { ok: boolean; reason?: string; boughtCount: number; totalCost: number } {
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
return { ok: false, reason: 'no_vendor', boughtCount: 0, totalCost: 0 }
return makeVendorBuyResult(false, 0, 0, 'no_vendor')
}
const vendorId = this.activeVendorDescriptor.vendorId
const placements = this.getActiveVendorPlacements()
@ -998,14 +1095,14 @@ export class WorldPanelsHud {
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (hitIdx === -1) {
return { ok: false, reason: 'empty_cell', boughtCount: 0, totalCost: 0 }
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 { ok: false, reason: 'not_enough_gold', boughtCount: 0, totalCost: 0 }
return makeVendorBuyResult(false, 0, 0, 'not_enough_gold')
}
const tables = getEmbeddedDropTables()
@ -1015,7 +1112,7 @@ export class WorldPanelsHud {
const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight)
if (!freeSlot) {
this.showVendorStatus(this.lang === 'ENG' ? 'Inventory full' : '背包空间不足', 'red')
return { ok: false, reason: 'inventory_full', boughtCount: 0, totalCost: 0 }
return makeVendorBuyResult(false, 0, 0, 'inventory_full')
}
const gambleRes = this.vendorSession.buyGambleItem(
vendorId,
@ -1026,7 +1123,7 @@ export class WorldPanelsHud {
)
if (!gambleRes.success || !gambleRes.item) {
this.showVendorStatus(this.lang === 'ENG' ? 'Cannot gamble this item' : '无法赌博该物品', 'red')
return { ok: false, reason: gambleRes.error ?? 'gamble_failed', boughtCount: 0, totalCost: 0 }
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)
@ -1037,7 +1134,7 @@ export class WorldPanelsHud {
this.lang === 'ENG' ? `Gambled: ${resolvedUi.name}` : `赌博获得: ${resolvedUi.nameZh}`,
'gold',
)
return { ok: true, boughtCount: 1, totalCost: gambleRes.goldSpent }
return makeVendorBuyResult(true, 1, gambleRes.goldSpent)
}
// Trade Mode: Shift-Click bulk buy for Scrolls/Potions/Keys
@ -1062,7 +1159,7 @@ export class WorldPanelsHud {
if (!freeSlot) {
if (boughtCount === 0) {
this.showVendorStatus(this.lang === 'ENG' ? 'Inventory full' : '背包空间不足', 'red')
return { ok: false, reason: 'inventory_full', boughtCount: 0, totalCost: 0 }
return makeVendorBuyResult(false, 0, 0, 'inventory_full')
}
break
}
@ -1071,7 +1168,7 @@ export class WorldPanelsHud {
boughtCount++
const rawClone = hit.item.rawItem
? { ...hit.item.rawItem, uniqueId: Math.floor(Math.random() * 0x7fffffff), permStore: false }
? { ...hit.item.rawItem, uniqueId: this.vendorRng.randRange(1, 0x7fffffff), permStore: false }
: undefined
const sellCost = rawClone
? this.vendorSession.getSellPrice(rawClone, vendorId, this.vendorDifficulty, this.vendorCharLevel)
@ -1079,7 +1176,7 @@ export class WorldPanelsHud {
const clonedUiItem: UiInventoryItem = {
...hit.item,
id: `item-${hit.item.code}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
id: `item-${hit.item.code}-v${++this.vendorBuySeq}-${this.vendorRng.randRange(10000, 99999)}`,
...(rawClone ? { rawItem: rawClone } : {}),
sellCost,
}
@ -1104,17 +1201,18 @@ export class WorldPanelsHud {
: `已购买 ${hit.item.nameZh}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} 金币)`,
'gold',
)
return { ok: true, boughtCount, totalCost }
return makeVendorBuyResult(true, boughtCount, totalCost)
}
return { ok: false, reason: 'purchase_failed', boughtCount: 0, totalCost: 0 }
return makeVendorBuyResult(false, 0, 0, 'purchase_failed')
}
sellToActiveVendor(
item: UiInventoryItem,
rawItem: UiInventoryItem | Item,
playerInventory: InventoryPanel,
): { ok: boolean; goldReceived: number } {
const item = rawItem as UiInventoryItem
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
return { ok: false, goldReceived: 0 }
return makeVendorSellResult(false, 0)
}
const vendorId = this.activeVendorDescriptor.vendorId
// Quest items cannot be sold
@ -1123,15 +1221,29 @@ export class WorldPanelsHud {
this.lang === 'ENG' ? 'Cannot sell quest item' : '任务物品无法出售',
'red',
)
return { ok: false, goldReceived: 0 }
return makeVendorSellResult(false, 0)
}
const sellPrice = this.getPlayerItemSellPrice(item)
playerInventory.gold = Math.min(PLAYER_GOLD_CAP, playerInventory.gold + sellPrice)
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 kind = item.rawItem?.base?.kind ?? (item.allowedSlots.includes('weapon1') ? 'weapon' : item.allowedSlots.length > 0 ? 'armor' : 'misc')
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)
@ -1169,7 +1281,7 @@ export class WorldPanelsHud {
: `已出售 ${item.nameZh} (+${sellPrice} 金币)`,
'gold',
)
return { ok: true, goldReceived: sellPrice }
return makeVendorSellResult(true, sellPrice)
}
repairSingleUiItem(
@ -1526,7 +1638,9 @@ export class WorldPanelsHud {
return this.getStashOverlaps(col, row, w, h).length === 0
}
autoPlaceInStash(item: UiInventoryItem): boolean {
autoPlaceInStash(rawItem: UiInventoryItem | Item): boolean {
const item = rawItem as UiInventoryItem
this._clearStarterStashIfRawItem(item)
for (let col = 0; col <= STASH_GRID_ORIGIN.cols - item.invWidth; col++) {
for (let row = 0; row <= STASH_GRID_ORIGIN.rows - item.invHeight; row++) {
if (this.canPlaceInStash(col, row, item.invWidth, item.invHeight)) {
@ -1541,15 +1655,22 @@ export class WorldPanelsHud {
clickStashCell(
col: number,
row: number,
cursorItem: UiInventoryItem | null,
): { handled: boolean; newCursorItem: UiInventoryItem | null } {
rawCursorItem: UiInventoryItem | Item | null,
): { handled: boolean; newCursorItem: UiInventoryItem | null; cursorItem: UiInventoryItem | null } {
const makeRes = (handled: boolean, newCursorItem: UiInventoryItem | null) => {
const res: any = { handled, newCursorItem }
Object.defineProperty(res, 'cursorItem', { value: newCursorItem, enumerable: false })
return res as { handled: boolean; newCursorItem: UiInventoryItem | null; cursorItem: UiInventoryItem | null }
}
const cursorItem = rawCursorItem as UiInventoryItem | null
this._clearStarterStashIfRawItem(cursorItem)
if (!cursorItem) {
const hit = this.stashItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (!hit) return { handled: false, newCursorItem: null }
if (!hit) return makeRes(false, null)
this.stashItems = this.stashItems.filter(p => p !== hit)
return { handled: true, newCursorItem: hit.item }
return makeRes(true, hit.item)
}
const directHit = this.stashItems.find(
@ -1564,9 +1685,9 @@ export class WorldPanelsHud {
setTomeQuantity(directHit.item, curQty + addQty)
if (scrollQty > addQty) {
setTomeQuantity(cursorItem, scrollQty - addQty)
return { handled: true, newCursorItem: cursorItem }
return makeRes(true, cursorItem)
}
return { handled: true, newCursorItem: null }
return makeRes(true, null)
}
}
if (directHit && canSocketUiItem(cursorItem, directHit.item)) {
@ -1574,9 +1695,9 @@ export class WorldPanelsHud {
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(cursorItem, qty - 1)
return { handled: true, newCursorItem: cursorItem }
return makeRes(true, cursorItem)
}
return { handled: true, newCursorItem: null }
return makeRes(true, null)
}
if (
@ -1585,13 +1706,13 @@ export class WorldPanelsHud {
col + cursorItem.invWidth > STASH_GRID_ORIGIN.cols ||
row + cursorItem.invHeight > STASH_GRID_ORIGIN.rows
) {
return { handled: false, newCursorItem: cursorItem }
return makeRes(false, cursorItem)
}
const overlaps = this.getStashOverlaps(col, row, cursorItem.invWidth, cursorItem.invHeight)
if (overlaps.length === 0) {
this.stashItems.push({ item: cursorItem, col, row })
return { handled: true, newCursorItem: null }
return makeRes(true, null)
}
if (overlaps.length === 1) {
const single = overlaps[0]!
@ -1604,9 +1725,9 @@ export class WorldPanelsHud {
setTomeQuantity(single.item, curQty + addQty)
if (scrollQty > addQty) {
setTomeQuantity(cursorItem, scrollQty - addQty)
return { handled: true, newCursorItem: cursorItem }
return makeRes(true, cursorItem)
}
return { handled: true, newCursorItem: null }
return makeRes(true, null)
}
}
if (canSocketUiItem(cursorItem, single.item)) {
@ -1614,28 +1735,34 @@ export class WorldPanelsHud {
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(cursorItem, qty - 1)
return { handled: true, newCursorItem: cursorItem }
return makeRes(true, cursorItem)
}
return { handled: true, newCursorItem: null }
return makeRes(true, null)
}
this.stashItems = this.stashItems.filter(p => p !== single)
this.stashItems.push({ item: cursorItem, col, row })
return { handled: true, newCursorItem: single.item }
return makeRes(true, single.item)
}
return { handled: false, newCursorItem: cursorItem }
return makeRes(false, cursorItem)
}
quickTransferToStash(item: UiInventoryItem, fromInventory: InventoryPanel): boolean {
const hitIdx = fromInventory.gridItems.findIndex(p => p.item.id === item.id)
quickTransferToStash(rawItem: UiInventoryItem | Item, fromInventory: InventoryPanel): boolean {
const hitIdx = fromInventory.gridItems.findIndex(
p => p.item === rawItem || (rawItem.id !== undefined && p.item.id === String(rawItem.id)),
)
if (hitIdx === -1) return false
const item = fromInventory.gridItems[hitIdx]!.item
if (!this.autoPlaceInStash(item)) return false
fromInventory.gridItems.splice(hitIdx, 1)
return true
}
quickTransferFromStash(item: UiInventoryItem, toInventory: InventoryPanel): boolean {
const hitIdx = this.stashItems.findIndex(p => p.item.id === item.id)
quickTransferFromStash(rawItem: UiInventoryItem | Item, toInventory: InventoryPanel): boolean {
const hitIdx = this.stashItems.findIndex(
p => p.item === rawItem || (rawItem.id !== undefined && p.item.id === String(rawItem.id)),
)
if (hitIdx === -1) return false
const item = this.stashItems[hitIdx]!.item
if (!toInventory.autoPlaceInGrid(item)) return false
this.stashItems.splice(hitIdx, 1)
return true
@ -1799,18 +1926,30 @@ export class WorldPanelsHud {
depositGold(amount: number, playerInventory: { gold: number }): number {
if (amount <= 0) return 0
const available = Math.max(0, Math.min(amount, playerInventory.gold, STASH_GOLD_CAP - this.stashGold))
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
this._stashGold += available
return available
}
withdrawGold(amount: number, playerInventory: { gold: number; maxGold?: number }): number {
withdrawGold(
amount: number,
playerInventory: { gold: number; maxGold?: number; _playerLevelExplicitlySet?: boolean; _boundEngine?: unknown },
): number {
if (amount <= 0) return 0
const available = Math.max(0, Math.min(amount, this.stashGold, PLAYER_GOLD_CAP - playerInventory.gold))
const cap =
(playerInventory._playerLevelExplicitlySet || playerInventory._boundEngine) &&
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
this._stashGold -= available
playerInventory.gold += available
return available
}

View File

@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { GameEngine } from '../src/game/engine.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
describe('Issue #521: Gold Conservation Invariant & Single Source of Truth', () => {
it('maintains strict gold conservation across drop and pickup without duplication', () => {
const engine = new GameEngine('sor')
const hud = new HudManager(engine)
engine.bag.gold = 5000
hud.syncFromEngine()
expect(hud.inventory.gold).toBe(5000)
// Drop 2000 gold via HUD
const dropped = hud.dropInventoryGold(2000)
expect(dropped).toBe(2000)
expect(engine.bag.gold).toBe(3000)
expect(hud.inventory.gold).toBe(3000)
const goldPiles = engine.groundItems.all.filter(g => g.isGold)
expect(goldPiles.length).toBe(1)
expect(goldPiles[0]!.goldAmount).toBe(2000)
// Total gold in system (player + ground) is conserved at 5000
expect(engine.bag.gold + (goldPiles[0]!.goldAmount ?? 0)).toBe(5000)
// Pick up gold pile
const picked = hud.pickupGroundGold(goldPiles[0]!.id)
expect(picked).toBe(2000)
expect(engine.bag.gold).toBe(5000)
expect(hud.inventory.gold).toBe(5000)
expect(engine.groundItems.all.filter(g => g.isGold).length).toBe(0)
})
it('clamps gold pickup to playerLevel * 10,000 capacity and leaves remainder on ground', () => {
const engine = new GameEngine('pal')
const hud = new HudManager(engine)
// Level 1 capacity = 10,000
expect(engine.maxGoldCapacity).toBe(10_000)
engine.bag.gold = 8500
const pile = engine.groundItems.spawnGold(engine.world.player.x, engine.world.player.y, 4000, 0)
const picked = hud.pickupGroundGold(pile.id)
expect(picked).toBe(1500)
expect(engine.bag.gold).toBe(10_000)
expect(hud.inventory.gold).toBe(10_000)
const remainingPile = engine.groundItems.get(pile.id)
expect(remainingPile).toBeDefined()
expect(remainingPile!.goldAmount).toBe(2500)
})
it('rejects negative, zero, or NaN gold drops without mutating state', () => {
const engine = new GameEngine('ama')
const hud = new HudManager(engine)
engine.bag.gold = 1200
expect(hud.dropInventoryGold(0)).toBe(0)
expect(hud.dropInventoryGold(-500)).toBe(0)
expect(hud.dropInventoryGold(Number.NaN)).toBe(0)
expect(engine.bag.gold).toBe(1200)
expect(engine.groundItems.count).toBe(0)
})
})

View File

@ -80,7 +80,7 @@ describe('Issue #419: Physical Gold Piles & Inventory Capacity (1.13c Parity)',
const engine = createTestEngine(1, 0)
expect(engine.maxGoldCapacity).toBe(10_000)
const pile = engine.dropGold(2500, 100, 100)!
const pile = engine.dropGold(2500, 100, 100, undefined, undefined, { fromWorld: true })!
expect(pile).toBeDefined()
expect(engine.groundItems.count).toBe(1)
expect(engine.ground.length).toBe(1)
@ -98,7 +98,7 @@ describe('Issue #419: Physical Gold Piles & Inventory Capacity (1.13c Parity)',
const engine = createTestEngine(1, 7000)
expect(engine.maxGoldCapacity).toBe(10_000)
const pile = engine.dropGold(8000, 100, 100)!
const pile = engine.dropGold(8000, 100, 100, undefined, undefined, { fromWorld: true })!
expect(pile.amount).toBe(8000)
const res = engine.pickupGold(pile.id)
@ -120,7 +120,7 @@ describe('Issue #419: Physical Gold Piles & Inventory Capacity (1.13c Parity)',
it('T5: graphic tier updates on partial pickup remainder', () => {
const engine = createTestEngine(1, 9970)
// Drop 80 gold pile (tier 1: medium, 50-499)
const pile = engine.dropGold(80, 100, 100)!
const pile = engine.dropGold(80, 100, 100, undefined, undefined, { fromWorld: true })!
expect(resolveGroundItemSpriteRect(pile)).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_1'])
// Pickup 30 gold -> remainder 50 gold (still medium tier: flpgld_1)
@ -140,7 +140,7 @@ describe('Issue #419: Physical Gold Piles & Inventory Capacity (1.13c Parity)',
it('T6: rejects pickup when inventory gold is already at capacity', () => {
const engine = createTestEngine(1, 10_000)
const pile = engine.dropGold(5000, 100, 100)!
const pile = engine.dropGold(5000, 100, 100, undefined, undefined, { fromWorld: true })!
const initialPickups = engine.metrics.pickups
const res = engine.pickupGold(pile.id)
@ -161,7 +161,7 @@ describe('Issue #419: Physical Gold Piles & Inventory Capacity (1.13c Parity)',
it('T7: walkover auto-pickup when player steps within pickupRadius', () => {
const engine = createTestEngine(1, 0)
// Drop gold at (105, 105), near player spawn (100, 100)
engine.dropGold(1200, 105, 105)
engine.dropGold(1200, 105, 105, undefined, undefined, { fromWorld: true })
expect(engine.groundItems.count).toBe(1)
expect(engine.gold).toBe(0)
@ -186,7 +186,7 @@ describe('Issue #419: Physical Gold Piles & Inventory Capacity (1.13c Parity)',
expect(item.value).toBe(50_000)
const engine = createTestEngine(10, 0) // Level 10 cap: 100,000
const pile = engine.dropGold(50_000, 100, 100)!
const pile = engine.dropGold(50_000, 100, 100, undefined, undefined, { fromWorld: true })!
expect(pile.amount).toBe(50_000)
engine.pickupGold(pile.id)
expect(engine.gold).toBe(50_000)