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:
parent
7367f2a36a
commit
206b59e72d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue