diff --git a/src/scene/frontend/toolbar-chat-ui.ts b/src/scene/frontend/toolbar-chat-ui.ts index f17bf7f..439aaca 100644 --- a/src/scene/frontend/toolbar-chat-ui.ts +++ b/src/scene/frontend/toolbar-chat-ui.ts @@ -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) | undefined readonly setMonsterArtMap: (map: Map) => void } export interface HeroLightingAndMonsterControls { + readonly heroGen: LoadGeneration + readonly monsterGen: LoadGeneration + readonly lightingGen: LoadGeneration + readonly invalidateMonsterLoads: () => void readonly syncSorceressAppearance: () => Promise readonly switchLighting: (preset: string) => LightingPreset readonly switchHero: (newHeroToken: string) => Promise + readonly changeHeroClass: (newHeroToken: string) => Promise readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise + readonly respawnMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise } 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 => { 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 => { + 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 => { + 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() + 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 { + 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 { + 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 diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 4f63725..a6f4b23 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -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 } diff --git a/tests/p0-521-gold-duplication.test.ts b/tests/p0-521-gold-duplication.test.ts new file mode 100644 index 0000000..16bb8f4 --- /dev/null +++ b/tests/p0-521-gold-duplication.test.ts @@ -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) + }) +}) diff --git a/tests/physical-gold-piles.test.ts b/tests/physical-gold-piles.test.ts index 75cf521..fe037fd 100644 --- a/tests/physical-gold-piles.test.ts +++ b/tests/physical-gold-piles.test.ts @@ -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)