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,
|
type TblLang,
|
||||||
} from '../../i18n/lang.ts'
|
} from '../../i18n/lang.ts'
|
||||||
import type { AtlasFrame } from '../../render/atlas.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 { cleanChiItemName } from '../../ui/ground-labels.ts'
|
||||||
import { HudManager } from '../../ui/hud-manager.ts'
|
import { HudManager } from '../../ui/hud-manager.ts'
|
||||||
import type { Minimap } from '../../ui/minimap.ts'
|
import type { Minimap } from '../../ui/minimap.ts'
|
||||||
|
|
@ -260,23 +260,37 @@ export interface HeroLightingAndMonsterControlsContext {
|
||||||
readonly setLightingState: (preset: LightingPreset, enabled: boolean) => void
|
readonly setLightingState: (preset: LightingPreset, enabled: boolean) => void
|
||||||
readonly getMultipliers: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier }
|
readonly getMultipliers: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier }
|
||||||
readonly setMultipliers: (density: DensityMultiplier, elite: EliteMultiplier) => void
|
readonly setMultipliers: (density: DensityMultiplier, elite: EliteMultiplier) => void
|
||||||
|
readonly getMonsterArtMap?: (() => Map<string, LoadedMonsterArt>) | undefined
|
||||||
readonly setMonsterArtMap: (map: Map<string, LoadedMonsterArt>) => void
|
readonly setMonsterArtMap: (map: Map<string, LoadedMonsterArt>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HeroLightingAndMonsterControls {
|
export interface HeroLightingAndMonsterControls {
|
||||||
|
readonly heroGen: LoadGeneration
|
||||||
|
readonly monsterGen: LoadGeneration
|
||||||
|
readonly lightingGen: LoadGeneration
|
||||||
|
readonly invalidateMonsterLoads: () => void
|
||||||
readonly syncSorceressAppearance: () => Promise<boolean>
|
readonly syncSorceressAppearance: () => Promise<boolean>
|
||||||
readonly switchLighting: (preset: string) => LightingPreset
|
readonly switchLighting: (preset: string) => LightingPreset
|
||||||
readonly switchHero: (newHeroToken: string) => Promise<boolean>
|
readonly switchHero: (newHeroToken: string) => Promise<boolean>
|
||||||
|
readonly changeHeroClass: (newHeroToken: string) => Promise<boolean>
|
||||||
readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
|
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(
|
export function createHeroLightingAndMonsterControls(
|
||||||
ctx: HeroLightingAndMonsterControlsContext,
|
ctx: HeroLightingAndMonsterControlsContext,
|
||||||
): HeroLightingAndMonsterControls {
|
): HeroLightingAndMonsterControls {
|
||||||
const soCompositor = new SorceressLayerCompositor(ctx.getPackEntityBase() ?? 'samples/d2-packs')
|
const soCompositor = new SorceressLayerCompositor(ctx.getPackEntityBase() ?? 'samples/d2-packs')
|
||||||
|
const heroGen = new LoadGeneration()
|
||||||
|
const monsterGen = new LoadGeneration()
|
||||||
|
const lightingGen = new LoadGeneration()
|
||||||
let activeAppearanceKey = ''
|
let activeAppearanceKey = ''
|
||||||
let appearanceSeq = 0
|
let appearanceSeq = 0
|
||||||
|
|
||||||
|
const invalidateMonsterLoads = (): void => {
|
||||||
|
monsterGen.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
const syncSorceressAppearance = async (): Promise<boolean> => {
|
const syncSorceressAppearance = async (): Promise<boolean> => {
|
||||||
const hudInst = ctx.getHudManager() ?? (
|
const hudInst = ctx.getHudManager() ?? (
|
||||||
typeof window !== 'undefined'
|
typeof window !== 'undefined'
|
||||||
|
|
@ -320,7 +334,7 @@ export function createHeroLightingAndMonsterControls(
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
const prevChar = ctx.getCharacter()
|
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({
|
ctx.setCharacter({
|
||||||
handle: newHandle,
|
handle: newHandle,
|
||||||
groups,
|
groups,
|
||||||
|
|
@ -346,6 +360,7 @@ export function createHeroLightingAndMonsterControls(
|
||||||
}
|
}
|
||||||
|
|
||||||
const switchLighting = (preset: string): LightingPreset => {
|
const switchLighting = (preset: string): LightingPreset => {
|
||||||
|
const { gen } = lightingGen.next()
|
||||||
const nextPreset = normalizeLightingPreset(preset)
|
const nextPreset = normalizeLightingPreset(preset)
|
||||||
const cfg = LIGHTING_PRESETS[nextPreset]
|
const cfg = LIGHTING_PRESETS[nextPreset]
|
||||||
ctx.setLightingState(nextPreset, cfg.enabled)
|
ctx.setLightingState(nextPreset, cfg.enabled)
|
||||||
|
|
@ -367,12 +382,15 @@ export function createHeroLightingAndMonsterControls(
|
||||||
const msgPrefix = isEn ? 'Scene lighting switched to: ' : '已切换场景光照为:'
|
const msgPrefix = isEn ? 'Scene lighting switched to: ' : '已切换场景光照为:'
|
||||||
ctx.status.textContent = `${msgPrefix}${modeLabel}`
|
ctx.status.textContent = `${msgPrefix}${modeLabel}`
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (ctx.status.textContent.startsWith(msgPrefix)) ctx.status.textContent = ''
|
if (lightingGen.isCurrent(gen) && ctx.status.textContent.startsWith(msgPrefix)) {
|
||||||
|
ctx.status.textContent = ''
|
||||||
|
}
|
||||||
}, 2500)
|
}, 2500)
|
||||||
return nextPreset
|
return nextPreset
|
||||||
}
|
}
|
||||||
|
|
||||||
const switchHero = async (newHeroToken: string): Promise<boolean> => {
|
const switchHero = async (newHeroToken: string): Promise<boolean> => {
|
||||||
|
const { gen } = heroGen.next()
|
||||||
const nextToken = normalizeHeroToken(newHeroToken)
|
const nextToken = normalizeHeroToken(newHeroToken)
|
||||||
if (nextToken === ctx.getCurrentHeroToken() && ctx.getCharacter() !== null) return true
|
if (nextToken === ctx.getCurrentHeroToken() && ctx.getCharacter() !== null) return true
|
||||||
const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so
|
const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so
|
||||||
|
|
@ -388,9 +406,17 @@ export function createHeroLightingAndMonsterControls(
|
||||||
ctx.getPackEntityAct(),
|
ctx.getPackEntityAct(),
|
||||||
nextToken,
|
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) {
|
if (nextChar !== null) {
|
||||||
const prevChar = ctx.getCharacter()
|
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.setCharacter(nextChar)
|
||||||
ctx.setCurrentHeroToken(nextToken)
|
ctx.setCurrentHeroToken(nextToken)
|
||||||
state.heroToken = nextToken
|
state.heroToken = nextToken
|
||||||
|
|
@ -400,8 +426,10 @@ export function createHeroLightingAndMonsterControls(
|
||||||
const hudManager = ctx.getHudManager()
|
const hudManager = ctx.getHudManager()
|
||||||
if (hudManager !== null) hudManager.switchClass(nextInfo.code as CharacterClassCode)
|
if (hudManager !== null) hudManager.switchClass(nextInfo.code as CharacterClassCode)
|
||||||
activeAppearanceKey = ''
|
activeAppearanceKey = ''
|
||||||
if (nextToken === 'so') await syncSorceressAppearance()
|
if (nextToken === 'so') {
|
||||||
else {
|
await syncSorceressAppearance()
|
||||||
|
if (!heroGen.isCurrent(gen)) return false
|
||||||
|
} else {
|
||||||
state.characterAppearanceKey = undefined
|
state.characterAppearanceKey = undefined
|
||||||
state.characterAppearance = 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)} layers).`
|
||||||
: `${switchedPrefix}${heroDispName}(${String(curChar?.members ?? nextChar.members)} 层素材)。`
|
: `${switchedPrefix}${heroDispName}(${String(curChar?.members ?? nextChar.members)} 层素材)。`
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (ctx.status.textContent.startsWith(switchedPrefix)) ctx.status.textContent = ''
|
if (heroGen.isCurrent(gen) && ctx.status.textContent.startsWith(switchedPrefix)) {
|
||||||
|
ctx.status.textContent = ''
|
||||||
|
}
|
||||||
}, 3000)
|
}, 3000)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -429,6 +459,7 @@ export function createHeroLightingAndMonsterControls(
|
||||||
}
|
}
|
||||||
|
|
||||||
const reloadMonsters = async (options?: { density?: number | string; elite?: number | string }): Promise<void> => {
|
const reloadMonsters = async (options?: { density?: number | string; elite?: number | string }): Promise<void> => {
|
||||||
|
const { gen } = monsterGen.next()
|
||||||
let { densityMultiplier, eliteMultiplier } = ctx.getMultipliers()
|
let { densityMultiplier, eliteMultiplier } = ctx.getMultipliers()
|
||||||
if (options?.density !== undefined) densityMultiplier = parseDensityMultiplier(options.density)
|
if (options?.density !== undefined) densityMultiplier = parseDensityMultiplier(options.density)
|
||||||
if (options?.elite !== undefined) eliteMultiplier = parseEliteMultiplier(options.elite)
|
if (options?.elite !== undefined) eliteMultiplier = parseEliteMultiplier(options.elite)
|
||||||
|
|
@ -460,6 +491,10 @@ export function createHeroLightingAndMonsterControls(
|
||||||
runtime.monsterPacks = []
|
runtime.monsterPacks = []
|
||||||
engine.world.monsters.length = 0
|
engine.world.monsters.length = 0
|
||||||
engine.streamingManager = undefined
|
engine.streamingManager = undefined
|
||||||
|
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
|
||||||
|
ctx.renderer.deleteAtlas(oldArt.handle)
|
||||||
|
}
|
||||||
|
ctx.setMonsterArtMap(new Map())
|
||||||
reportMonsterPlan(runtime)
|
reportMonsterPlan(runtime)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -538,20 +573,69 @@ export function createHeroLightingAndMonsterControls(
|
||||||
ctx.getPackEntityBase(),
|
ctx.getPackEntityBase(),
|
||||||
ctx.getPackEntityAct(),
|
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)
|
ctx.setMonsterArtMap(loaded.map)
|
||||||
recordMonsterArtLoad(loaded)
|
recordMonsterArtLoad(loaded)
|
||||||
|
} else {
|
||||||
|
for (const oldArt of ctx.getMonsterArtMap?.().values() ?? []) {
|
||||||
|
ctx.renderer.deleteAtlas(oldArt.handle)
|
||||||
|
}
|
||||||
|
ctx.setMonsterArtMap(new Map())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
heroGen,
|
||||||
|
monsterGen,
|
||||||
|
lightingGen,
|
||||||
|
invalidateMonsterLoads,
|
||||||
syncSorceressAppearance,
|
syncSorceressAppearance,
|
||||||
switchLighting,
|
switchLighting,
|
||||||
switchHero,
|
switchHero,
|
||||||
|
changeHeroClass: switchHero,
|
||||||
reloadMonsters,
|
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: {
|
export async function createActSceneHudManager(options: {
|
||||||
readonly runtime: MapRuntime
|
readonly runtime: MapRuntime
|
||||||
readonly engine: GameEngine
|
readonly engine: GameEngine
|
||||||
|
|
@ -597,6 +681,10 @@ export async function createActSceneHudManager(options: {
|
||||||
onDropItemToGround: (item) => {
|
onDropItemToGround: (item) => {
|
||||||
const player = engine.world.player
|
const player = engine.world.player
|
||||||
const groundItem = engine.dropItem(item, player.x, player.y)
|
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'
|
const isEn = getTblLang() === 'ENG'
|
||||||
status.textContent = isEn
|
status.textContent = isEn
|
||||||
? `Dropped "${groundItem.name}" on ground`
|
? `Dropped "${groundItem.name}" on ground`
|
||||||
|
|
@ -604,7 +692,13 @@ export async function createActSceneHudManager(options: {
|
||||||
},
|
},
|
||||||
onDropGoldToGround: (amount) => {
|
onDropGoldToGround: (amount) => {
|
||||||
const player = engine.world.player
|
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) {
|
if (groundGold) {
|
||||||
const isEn = getTblLang() === 'ENG'
|
const isEn = getTblLang() === 'ENG'
|
||||||
status.textContent = isEn
|
status.textContent = isEn
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
drawItemSocketOverlay,
|
drawItemSocketOverlay,
|
||||||
findFreeGridSlot,
|
findFreeGridSlot,
|
||||||
getEffectiveItemReqClass,
|
getEffectiveItemReqClass,
|
||||||
|
getInventoryGoldLimit,
|
||||||
getTomeQuantity,
|
getTomeQuantity,
|
||||||
identifyUiItem,
|
identifyUiItem,
|
||||||
isScrollForTome,
|
isScrollForTome,
|
||||||
|
|
@ -36,9 +37,77 @@ import {
|
||||||
type PlayerItemRequirementContext,
|
type PlayerItemRequirementContext,
|
||||||
type UiInventoryItem,
|
type UiInventoryItem,
|
||||||
} from './inventory.ts'
|
} from './inventory.ts'
|
||||||
|
import type { Item } from '../game/items.ts'
|
||||||
import type { CharacterClassCode } from '../game/classes.ts'
|
import type { CharacterClassCode } from '../game/classes.ts'
|
||||||
|
import { D2Rng } from '../game/d2-rng.ts'
|
||||||
import { getTblLang, type TblLang } from '../i18n/lang.ts'
|
import { getTblLang, type TblLang } from '../i18n/lang.ts'
|
||||||
import { BAKED_UI_MANIFEST } from './baked-ui-meta.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 {
|
import {
|
||||||
type TownNpcServiceDescriptor,
|
type TownNpcServiceDescriptor,
|
||||||
type VendorNpcId,
|
type VendorNpcId,
|
||||||
|
|
@ -619,7 +688,18 @@ export class WorldPanelsHud {
|
||||||
lang: TblLang = getTblLang()
|
lang: TblLang = getTblLang()
|
||||||
selectedActTab = 1
|
selectedActTab = 1
|
||||||
selectedQuestIdx = 0
|
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
|
cubeTransmuteCount = 0
|
||||||
escapeMenuOpen = false
|
escapeMenuOpen = false
|
||||||
lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null
|
lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null
|
||||||
|
|
@ -642,6 +722,13 @@ export class WorldPanelsHud {
|
||||||
return this._vendorSession
|
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
|
activeVendorDescriptor: TownNpcServiceDescriptor | null = null
|
||||||
vendorMode: 'trade' | 'gamble' = 'trade'
|
vendorMode: 'trade' | 'gamble' = 'trade'
|
||||||
activeVendorTab: VendorTabId = 'armor'
|
activeVendorTab: VendorTabId = 'armor'
|
||||||
|
|
@ -897,7 +984,7 @@ export class WorldPanelsHud {
|
||||||
descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string,
|
descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string,
|
||||||
mode: 'trade' | 'gamble' = 'trade',
|
mode: 'trade' | 'gamble' = 'trade',
|
||||||
cLvl = 85,
|
cLvl = 85,
|
||||||
difficulty: 0 | 1 | 2 = 0,
|
difficulty: 0 | 1 | 2 | 'normal' | 'nightmare' | 'hell' = 0,
|
||||||
): boolean {
|
): boolean {
|
||||||
const descriptor =
|
const descriptor =
|
||||||
typeof descriptorOrName === 'object'
|
typeof descriptorOrName === 'object'
|
||||||
|
|
@ -907,13 +994,20 @@ export class WorldPanelsHud {
|
||||||
: getTownNpcDescriptorByName(descriptorOrName)
|
: getTownNpcDescriptorByName(descriptorOrName)
|
||||||
if (!descriptor || descriptor.vendorId === null) return false
|
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
|
// Fail-fast validation against D2Client.dll 0x6fb3f430 button layout table
|
||||||
resolveVendorButtonLayout(descriptor.hcIdx, mode)
|
resolveVendorButtonLayout(descriptor.hcIdx, mode)
|
||||||
|
|
||||||
this.activeVendorDescriptor = descriptor
|
this.activeVendorDescriptor = descriptor
|
||||||
this.vendorMode = mode
|
this.vendorMode = mode
|
||||||
this.vendorCharLevel = Math.max(1, cLvl)
|
this.vendorCharLevel = Math.max(1, cLvl)
|
||||||
this.vendorDifficulty = difficulty
|
this.vendorDifficulty = diffNum
|
||||||
this.vendorTradeState = 'idle'
|
this.vendorTradeState = 'idle'
|
||||||
this.hoveredVendorButtonSlot = null
|
this.hoveredVendorButtonSlot = null
|
||||||
this.pressedMomentarySlot = null
|
this.pressedMomentarySlot = null
|
||||||
|
|
@ -925,7 +1019,7 @@ export class WorldPanelsHud {
|
||||||
if (mode === 'gamble') {
|
if (mode === 'gamble') {
|
||||||
const gambleInv = this.vendorSession.getOrCreateGambleStore(vendorId, this.vendorCharLevel)
|
const gambleInv = this.vendorSession.getOrCreateGambleStore(vendorId, this.vendorCharLevel)
|
||||||
this.gamblePlacements = gambleInv.getAllItems().map(p => {
|
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)
|
const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, isGambleMystery: true }, tables)
|
||||||
return { item: uiItem, col: p.x, row: p.y }
|
return { item: uiItem, col: p.x, row: p.y }
|
||||||
})
|
})
|
||||||
|
|
@ -933,13 +1027,13 @@ export class WorldPanelsHud {
|
||||||
return true
|
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']
|
const tabIds: VendorTabId[] = ['armor', 'weapons1', 'weapons2', 'misc']
|
||||||
for (const tabId of tabIds) {
|
for (const tabId of tabIds) {
|
||||||
const inv = store.tabs[tabId]
|
const inv = store.tabs[tabId]
|
||||||
this.vendorTabPlacements[tabId] = inv.getAllItems().map(p => {
|
this.vendorTabPlacements[tabId] = inv.getAllItems().map(p => {
|
||||||
const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, difficulty, this.vendorCharLevel, false)
|
const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, diffNum, this.vendorCharLevel, false)
|
||||||
const sellCost = this.vendorSession.getSellPrice(p.item, vendorId, difficulty, this.vendorCharLevel)
|
const sellCost = this.vendorSession.getSellPrice(p.item, vendorId, diffNum, this.vendorCharLevel)
|
||||||
const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, sellCost }, tables)
|
const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, sellCost }, tables)
|
||||||
return { item: uiItem, col: p.x, row: p.y }
|
return { item: uiItem, col: p.x, row: p.y }
|
||||||
})
|
})
|
||||||
|
|
@ -963,6 +1057,9 @@ export class WorldPanelsHud {
|
||||||
if (item.rawItem) {
|
if (item.rawItem) {
|
||||||
return this.vendorSession.getSellPrice(item.rawItem, vendorId, this.vendorDifficulty, this.vendorCharLevel)
|
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))
|
return Math.max(1, Math.floor(150))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -990,7 +1087,7 @@ export class WorldPanelsHud {
|
||||||
isShiftClick = false,
|
isShiftClick = false,
|
||||||
): { ok: boolean; reason?: string; boughtCount: number; totalCost: number } {
|
): { ok: boolean; reason?: string; boughtCount: number; totalCost: number } {
|
||||||
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
|
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 vendorId = this.activeVendorDescriptor.vendorId
|
||||||
const placements = this.getActiveVendorPlacements()
|
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,
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
)
|
)
|
||||||
if (hitIdx === -1) {
|
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 hit = placements[hitIdx]!
|
||||||
const unitCost = hit.item.buyCost ?? 1
|
const unitCost = hit.item.buyCost ?? 1
|
||||||
|
|
||||||
if (playerInventory.gold < unitCost) {
|
if (playerInventory.gold < unitCost) {
|
||||||
this.showVendorStatus(this.lang === 'ENG' ? 'Not enough gold' : '金币不足', 'red')
|
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()
|
const tables = getEmbeddedDropTables()
|
||||||
|
|
@ -1015,7 +1112,7 @@ export class WorldPanelsHud {
|
||||||
const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight)
|
const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight)
|
||||||
if (!freeSlot) {
|
if (!freeSlot) {
|
||||||
this.showVendorStatus(this.lang === 'ENG' ? 'Inventory full' : '背包空间不足', 'red')
|
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(
|
const gambleRes = this.vendorSession.buyGambleItem(
|
||||||
vendorId,
|
vendorId,
|
||||||
|
|
@ -1026,7 +1123,7 @@ export class WorldPanelsHud {
|
||||||
)
|
)
|
||||||
if (!gambleRes.success || !gambleRes.item) {
|
if (!gambleRes.success || !gambleRes.item) {
|
||||||
this.showVendorStatus(this.lang === 'ENG' ? 'Cannot gamble this item' : '无法赌博该物品', 'red')
|
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
|
playerInventory.gold = gambleRes.remainingGold
|
||||||
const sellCost = this.vendorSession.getSellPrice(gambleRes.item, vendorId, this.vendorDifficulty, this.vendorCharLevel)
|
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}`,
|
this.lang === 'ENG' ? `Gambled: ${resolvedUi.name}` : `赌博获得: ${resolvedUi.nameZh}`,
|
||||||
'gold',
|
'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
|
// Trade Mode: Shift-Click bulk buy for Scrolls/Potions/Keys
|
||||||
|
|
@ -1062,7 +1159,7 @@ export class WorldPanelsHud {
|
||||||
if (!freeSlot) {
|
if (!freeSlot) {
|
||||||
if (boughtCount === 0) {
|
if (boughtCount === 0) {
|
||||||
this.showVendorStatus(this.lang === 'ENG' ? 'Inventory full' : '背包空间不足', 'red')
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -1071,7 +1168,7 @@ export class WorldPanelsHud {
|
||||||
boughtCount++
|
boughtCount++
|
||||||
|
|
||||||
const rawClone = hit.item.rawItem
|
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
|
: undefined
|
||||||
const sellCost = rawClone
|
const sellCost = rawClone
|
||||||
? this.vendorSession.getSellPrice(rawClone, vendorId, this.vendorDifficulty, this.vendorCharLevel)
|
? this.vendorSession.getSellPrice(rawClone, vendorId, this.vendorDifficulty, this.vendorCharLevel)
|
||||||
|
|
@ -1079,7 +1176,7 @@ export class WorldPanelsHud {
|
||||||
|
|
||||||
const clonedUiItem: UiInventoryItem = {
|
const clonedUiItem: UiInventoryItem = {
|
||||||
...hit.item,
|
...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 } : {}),
|
...(rawClone ? { rawItem: rawClone } : {}),
|
||||||
sellCost,
|
sellCost,
|
||||||
}
|
}
|
||||||
|
|
@ -1104,17 +1201,18 @@ export class WorldPanelsHud {
|
||||||
: `已购买 ${hit.item.nameZh}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} 金币)`,
|
: `已购买 ${hit.item.nameZh}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} 金币)`,
|
||||||
'gold',
|
'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(
|
sellToActiveVendor(
|
||||||
item: UiInventoryItem,
|
rawItem: UiInventoryItem | Item,
|
||||||
playerInventory: InventoryPanel,
|
playerInventory: InventoryPanel,
|
||||||
): { ok: boolean; goldReceived: number } {
|
): { ok: boolean; goldReceived: number } {
|
||||||
|
const item = rawItem as UiInventoryItem
|
||||||
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
|
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
|
||||||
return { ok: false, goldReceived: 0 }
|
return makeVendorSellResult(false, 0)
|
||||||
}
|
}
|
||||||
const vendorId = this.activeVendorDescriptor.vendorId
|
const vendorId = this.activeVendorDescriptor.vendorId
|
||||||
// Quest items cannot be sold
|
// Quest items cannot be sold
|
||||||
|
|
@ -1123,15 +1221,29 @@ export class WorldPanelsHud {
|
||||||
this.lang === 'ENG' ? 'Cannot sell quest item' : '任务物品无法出售',
|
this.lang === 'ENG' ? 'Cannot sell quest item' : '任务物品无法出售',
|
||||||
'red',
|
'red',
|
||||||
)
|
)
|
||||||
return { ok: false, goldReceived: 0 }
|
return makeVendorSellResult(false, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sellPrice = this.getPlayerItemSellPrice(item)
|
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)
|
// Place into vendor store tab for buyback (unless in gamble mode or item is permStore)
|
||||||
if (this.vendorMode === 'trade' && !item.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 targetTab: VendorTabId = kind === 'armor' ? 'armor' : kind === 'weapon' ? 'weapons1' : 'misc'
|
||||||
let tabPlacements = this.vendorTabPlacements[targetTab]
|
let tabPlacements = this.vendorTabPlacements[targetTab]
|
||||||
let freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10)
|
let freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10)
|
||||||
|
|
@ -1169,7 +1281,7 @@ export class WorldPanelsHud {
|
||||||
: `已出售 ${item.nameZh} (+${sellPrice} 金币)`,
|
: `已出售 ${item.nameZh} (+${sellPrice} 金币)`,
|
||||||
'gold',
|
'gold',
|
||||||
)
|
)
|
||||||
return { ok: true, goldReceived: sellPrice }
|
return makeVendorSellResult(true, sellPrice)
|
||||||
}
|
}
|
||||||
|
|
||||||
repairSingleUiItem(
|
repairSingleUiItem(
|
||||||
|
|
@ -1526,7 +1638,9 @@ export class WorldPanelsHud {
|
||||||
return this.getStashOverlaps(col, row, w, h).length === 0
|
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 col = 0; col <= STASH_GRID_ORIGIN.cols - item.invWidth; col++) {
|
||||||
for (let row = 0; row <= STASH_GRID_ORIGIN.rows - item.invHeight; row++) {
|
for (let row = 0; row <= STASH_GRID_ORIGIN.rows - item.invHeight; row++) {
|
||||||
if (this.canPlaceInStash(col, row, item.invWidth, item.invHeight)) {
|
if (this.canPlaceInStash(col, row, item.invWidth, item.invHeight)) {
|
||||||
|
|
@ -1541,15 +1655,22 @@ export class WorldPanelsHud {
|
||||||
clickStashCell(
|
clickStashCell(
|
||||||
col: number,
|
col: number,
|
||||||
row: number,
|
row: number,
|
||||||
cursorItem: UiInventoryItem | null,
|
rawCursorItem: UiInventoryItem | Item | null,
|
||||||
): { handled: boolean; newCursorItem: UiInventoryItem | 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) {
|
if (!cursorItem) {
|
||||||
const hit = this.stashItems.find(
|
const hit = this.stashItems.find(
|
||||||
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
)
|
)
|
||||||
if (!hit) return { handled: false, newCursorItem: null }
|
if (!hit) return makeRes(false, null)
|
||||||
this.stashItems = this.stashItems.filter(p => p !== hit)
|
this.stashItems = this.stashItems.filter(p => p !== hit)
|
||||||
return { handled: true, newCursorItem: hit.item }
|
return makeRes(true, hit.item)
|
||||||
}
|
}
|
||||||
|
|
||||||
const directHit = this.stashItems.find(
|
const directHit = this.stashItems.find(
|
||||||
|
|
@ -1564,9 +1685,9 @@ export class WorldPanelsHud {
|
||||||
setTomeQuantity(directHit.item, curQty + addQty)
|
setTomeQuantity(directHit.item, curQty + addQty)
|
||||||
if (scrollQty > addQty) {
|
if (scrollQty > addQty) {
|
||||||
setTomeQuantity(cursorItem, 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)) {
|
if (directHit && canSocketUiItem(cursorItem, directHit.item)) {
|
||||||
|
|
@ -1574,9 +1695,9 @@ export class WorldPanelsHud {
|
||||||
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
|
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
|
||||||
if (qty > 1) {
|
if (qty > 1) {
|
||||||
setTomeQuantity(cursorItem, 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 (
|
if (
|
||||||
|
|
@ -1585,13 +1706,13 @@ export class WorldPanelsHud {
|
||||||
col + cursorItem.invWidth > STASH_GRID_ORIGIN.cols ||
|
col + cursorItem.invWidth > STASH_GRID_ORIGIN.cols ||
|
||||||
row + cursorItem.invHeight > STASH_GRID_ORIGIN.rows
|
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)
|
const overlaps = this.getStashOverlaps(col, row, cursorItem.invWidth, cursorItem.invHeight)
|
||||||
if (overlaps.length === 0) {
|
if (overlaps.length === 0) {
|
||||||
this.stashItems.push({ item: cursorItem, col, row })
|
this.stashItems.push({ item: cursorItem, col, row })
|
||||||
return { handled: true, newCursorItem: null }
|
return makeRes(true, null)
|
||||||
}
|
}
|
||||||
if (overlaps.length === 1) {
|
if (overlaps.length === 1) {
|
||||||
const single = overlaps[0]!
|
const single = overlaps[0]!
|
||||||
|
|
@ -1604,9 +1725,9 @@ export class WorldPanelsHud {
|
||||||
setTomeQuantity(single.item, curQty + addQty)
|
setTomeQuantity(single.item, curQty + addQty)
|
||||||
if (scrollQty > addQty) {
|
if (scrollQty > addQty) {
|
||||||
setTomeQuantity(cursorItem, 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)) {
|
if (canSocketUiItem(cursorItem, single.item)) {
|
||||||
|
|
@ -1614,28 +1735,34 @@ export class WorldPanelsHud {
|
||||||
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
|
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
|
||||||
if (qty > 1) {
|
if (qty > 1) {
|
||||||
setTomeQuantity(cursorItem, 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 = this.stashItems.filter(p => p !== single)
|
||||||
this.stashItems.push({ item: cursorItem, col, row })
|
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 {
|
quickTransferToStash(rawItem: UiInventoryItem | Item, fromInventory: InventoryPanel): boolean {
|
||||||
const hitIdx = fromInventory.gridItems.findIndex(p => p.item.id === item.id)
|
const hitIdx = fromInventory.gridItems.findIndex(
|
||||||
|
p => p.item === rawItem || (rawItem.id !== undefined && p.item.id === String(rawItem.id)),
|
||||||
|
)
|
||||||
if (hitIdx === -1) return false
|
if (hitIdx === -1) return false
|
||||||
|
const item = fromInventory.gridItems[hitIdx]!.item
|
||||||
if (!this.autoPlaceInStash(item)) return false
|
if (!this.autoPlaceInStash(item)) return false
|
||||||
fromInventory.gridItems.splice(hitIdx, 1)
|
fromInventory.gridItems.splice(hitIdx, 1)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
quickTransferFromStash(item: UiInventoryItem, toInventory: InventoryPanel): boolean {
|
quickTransferFromStash(rawItem: UiInventoryItem | Item, toInventory: InventoryPanel): boolean {
|
||||||
const hitIdx = this.stashItems.findIndex(p => p.item.id === item.id)
|
const hitIdx = this.stashItems.findIndex(
|
||||||
|
p => p.item === rawItem || (rawItem.id !== undefined && p.item.id === String(rawItem.id)),
|
||||||
|
)
|
||||||
if (hitIdx === -1) return false
|
if (hitIdx === -1) return false
|
||||||
|
const item = this.stashItems[hitIdx]!.item
|
||||||
if (!toInventory.autoPlaceInGrid(item)) return false
|
if (!toInventory.autoPlaceInGrid(item)) return false
|
||||||
this.stashItems.splice(hitIdx, 1)
|
this.stashItems.splice(hitIdx, 1)
|
||||||
return true
|
return true
|
||||||
|
|
@ -1799,18 +1926,30 @@ export class WorldPanelsHud {
|
||||||
|
|
||||||
depositGold(amount: number, playerInventory: { gold: number }): number {
|
depositGold(amount: number, playerInventory: { gold: number }): number {
|
||||||
if (amount <= 0) return 0
|
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
|
if (available <= 0) return 0
|
||||||
playerInventory.gold -= available
|
playerInventory.gold -= available
|
||||||
this.stashGold += available
|
this._stashGold += available
|
||||||
return 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
|
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
|
if (available <= 0) return 0
|
||||||
this.stashGold -= available
|
this._stashGold -= available
|
||||||
playerInventory.gold += available
|
playerInventory.gold += available
|
||||||
return 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)
|
const engine = createTestEngine(1, 0)
|
||||||
expect(engine.maxGoldCapacity).toBe(10_000)
|
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(pile).toBeDefined()
|
||||||
expect(engine.groundItems.count).toBe(1)
|
expect(engine.groundItems.count).toBe(1)
|
||||||
expect(engine.ground.length).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)
|
const engine = createTestEngine(1, 7000)
|
||||||
expect(engine.maxGoldCapacity).toBe(10_000)
|
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)
|
expect(pile.amount).toBe(8000)
|
||||||
|
|
||||||
const res = engine.pickupGold(pile.id)
|
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', () => {
|
it('T5: graphic tier updates on partial pickup remainder', () => {
|
||||||
const engine = createTestEngine(1, 9970)
|
const engine = createTestEngine(1, 9970)
|
||||||
// Drop 80 gold pile (tier 1: medium, 50-499)
|
// 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'])
|
expect(resolveGroundItemSpriteRect(pile)).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_1'])
|
||||||
|
|
||||||
// Pickup 30 gold -> remainder 50 gold (still medium tier: 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', () => {
|
it('T6: rejects pickup when inventory gold is already at capacity', () => {
|
||||||
const engine = createTestEngine(1, 10_000)
|
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 initialPickups = engine.metrics.pickups
|
||||||
|
|
||||||
const res = engine.pickupGold(pile.id)
|
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', () => {
|
it('T7: walkover auto-pickup when player steps within pickupRadius', () => {
|
||||||
const engine = createTestEngine(1, 0)
|
const engine = createTestEngine(1, 0)
|
||||||
// Drop gold at (105, 105), near player spawn (100, 100)
|
// 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.groundItems.count).toBe(1)
|
||||||
expect(engine.gold).toBe(0)
|
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)
|
expect(item.value).toBe(50_000)
|
||||||
|
|
||||||
const engine = createTestEngine(10, 0) // Level 10 cap: 100,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)
|
expect(pile.amount).toBe(50_000)
|
||||||
engine.pickupGold(pile.id)
|
engine.pickupGold(pile.id)
|
||||||
expect(engine.gold).toBe(50_000)
|
expect(engine.gold).toBe(50_000)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue