From db82a5da3250bb1083b4707f107def8fe9435fac Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 22 Sep 2026 05:33:57 +0000 Subject: [PATCH] feat(stash): implement camp town stash entities across 5 acts and 6x8 storage panel (Fixes #150) --- src/scene/act-scene.ts | 208 +++++++++++++- src/ui/hud-manager.ts | 128 ++++++--- src/ui/world-panels.ts | 310 ++++++++++++++++++++- tests/stash.test.ts | 607 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1204 insertions(+), 49 deletions(-) create mode 100644 tests/stash.test.ts diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index ff77266..b59b99d 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -457,7 +457,99 @@ interface ObjectDrawable { readonly page: number } -/** Everything the render loop needs, from either data path. */ +/** Town Stash entity (authentic 1.13c Objects.txt row 267 B6). */ +export interface TownStashEntity { + readonly id: number + readonly objectsTxtId: number + readonly token: string + readonly nameZh: string + readonly nameEn: string + readonly act: number + readonly x: number + readonly y: number + readonly subTileX: number + readonly subTileY: number + readonly clickRadiusPx: number + readonly interactionRangePx: number +} + +/** + * Canonical town stash locations for Acts 1..5. + * Grounded in authentic 1.13c DS1 map objects (row 267 B6). + */ +export const CANONICAL_TOWN_STASHES: Record = { + 1: { + id: 267, + objectsTxtId: 267, + token: 'B6', + nameZh: '储物箱', + nameEn: 'Stash', + act: 1, + x: 4512, + y: 1720, + subTileX: 112, + subTileY: 86, + clickRadiusPx: 48, + interactionRangePx: 80, + }, + 2: { + id: 267, + objectsTxtId: 267, + token: 'B6', + nameZh: '储物箱', + nameEn: 'Stash', + act: 2, + x: 5328, + y: 1568, + subTileX: 133, + subTileY: 78, + clickRadiusPx: 48, + interactionRangePx: 80, + }, + 3: { + id: 267, + objectsTxtId: 267, + token: 'B6', + nameZh: '储物箱', + nameEn: 'Stash', + act: 3, + x: 5264, + y: 1584, + subTileX: 131, + subTileY: 79, + clickRadiusPx: 48, + interactionRangePx: 80, + }, + 4: { + id: 267, + objectsTxtId: 267, + token: 'B6', + nameZh: '储物箱', + nameEn: 'Stash', + act: 4, + x: 704, + y: 552, + subTileX: 17, + subTileY: 27, + clickRadiusPx: 48, + interactionRangePx: 80, + }, + 5: { + id: 267, + objectsTxtId: 267, + token: 'B6', + nameZh: '储物箱', + nameEn: 'Stash', + act: 5, + x: 3792, + y: 1448, + subTileX: 94, + subTileY: 72, + clickRadiusPx: 48, + interactionRangePx: 80, + }, +} + /** Everything the render loop needs, from either data path. */ export interface MapRuntime { readonly source: 'pack' | 'live' @@ -479,6 +571,8 @@ export interface MapRuntime { readonly warps: readonly SceneWarp[] /** The waypoint pedestal, when this level has one. */ readonly waypoints: readonly SceneWaypoint[] + /** Town Stash chest entities. */ + readonly stashes?: readonly TownStashEntity[] readonly variants: readonly string[] readonly variantParam: 'level' | 'variant' readonly variant: string @@ -890,6 +984,7 @@ export function castSkill( export type PendingInteraction = | { readonly kind: 'npc'; readonly npc: NpcEntity } | { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } } + | { readonly kind: 'stash'; readonly stash: TownStashEntity } | { readonly kind: 'monster'; readonly monster: Monster } declare global { @@ -926,7 +1021,7 @@ export class SceneMouseController { navTarget: { x: number; y: number } | null = null pendingInteraction: PendingInteraction | null = null - activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'monster' = 'none' + activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'stash' | 'monster' = 'none' isLeftMouseDown = false isRightMouseDown = false lastClientPos: { x: number; y: number } | null = null @@ -948,6 +1043,53 @@ export class SceneMouseController { ;(this.engine as any).dialog = null } + findNearestStash(worldX: number, worldY: number, maxRadius = 48): TownStashEntity | null { + const runtime = this.getRuntime() + const stashes = runtime.stashes ?? [] + let best: TownStashEntity | null = null + let bestDist = maxRadius + for (const stash of stashes) { + const dist = Math.hypot(stash.x - worldX, stash.y - worldY) + if (dist <= bestDist) { + bestDist = dist + best = stash + } + } + return best + } + + openStash(stash: TownStashEntity): void { + const player = this.engine.world.player + player.facing = facingOf(stash.x - player.x, stash.y - player.y) + this.status.textContent = `打开私人储物箱 (${stash.nameZh})` + this.playStashOpenSound() + if (this.hudManager && this.hudManager.leftPanel !== 'stash') { + this.hudManager.toggleLeftPanel('stash') + } + } + + playStashOpenSound(): void { + try { + const AudioCtx = typeof window !== 'undefined' ? (window.AudioContext || (window as any).webkitAudioContext) : null + if (AudioCtx) { + const ctx = new AudioCtx() + const osc = ctx.createOscillator() + const gain = ctx.createGain() + osc.type = 'triangle' + osc.frequency.setValueAtTime(140, ctx.currentTime) + osc.frequency.exponentialRampToValueAtTime(70, ctx.currentTime + 0.12) + gain.gain.setValueAtTime(0.3, ctx.currentTime) + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15) + osc.connect(gain) + gain.connect(ctx.destination) + osc.start() + osc.stop(ctx.currentTime + 0.15) + } + } catch { + // AudioContext unavailable or blocked + } + } + readonly onContextMenu = (e: MouseEvent): void => { e.preventDefault() } @@ -1105,7 +1247,23 @@ export class SceneMouseController { return } - // 3. Check Monster: living monster within ~32 px + // 3. Check Town Stash: within ~48 px (clickRadiusPx = 48) + const clickedStash = this.findNearestStash(worldX, worldY, 48) + if (clickedStash !== null) { + this.activeClickType = 'stash' + const distToPlayer = Math.hypot(clickedStash.x - player.x, clickedStash.y - player.y) + if (distToPlayer <= clickedStash.interactionRangePx) { + this.navTarget = null + this.pendingInteraction = null + this.openStash(clickedStash) + } else { + this.pendingInteraction = { kind: 'stash', stash: clickedStash } + this.navTarget = { x: clickedStash.x, y: clickedStash.y } + } + return + } + + // 4. Check Monster: living monster within ~32 px let clickedMonster: Monster | null = null let bestMonDist = 36 for (const m of this.engine.world.monsters) { @@ -1263,6 +1421,16 @@ export class SceneMouseController { } else { this.navTarget = { x: worldPos.x, y: worldPos.y } } + } else if (this.pendingInteraction.kind === 'stash') { + const stash = this.pendingInteraction.stash + const dist = Math.hypot(stash.x - player.x, stash.y - player.y) + if (dist <= stash.interactionRangePx) { + this.navTarget = null + this.pendingInteraction = null + this.openStash(stash) + } else { + this.navTarget = { x: stash.x, y: stash.y } + } } else if (this.pendingInteraction.kind === 'monster') { const monster = this.pendingInteraction.monster if (monster.state === 'dead' || monster.hp <= 0) { @@ -1681,6 +1849,36 @@ async function buildPackRuntime( objectDrawables.sort((a, b) => a.depth - b.depth) const objectPages: (AtlasHandle | null)[] = (scene.objectPages ?? []).map(() => null) + const stashes: TownStashEntity[] = [] + for (const obj of scene.objects) { + if (obj.objectsTxtId === 267 || obj.token === 'B6') { + const subTileX = Math.round((obj.x - scene.originX) / 16) + const subTileY = Math.round((obj.y - scene.originY) / 16) + stashes.push({ + id: obj.id ?? 267, + objectsTxtId: 267, + token: 'B6', + nameZh: '储物箱', + nameEn: 'Stash', + act: scene.act, + x: obj.x, + y: obj.y, + subTileX, + subTileY, + clickRadiusPx: 48, + interactionRangePx: 80, + }) + } + } + const effectiveLevelId = scene.levelId ?? entry.levelId ?? -1 + const isTown = [1, 40, 75, 103, 109].includes(effectiveLevelId) || entry.slug.includes('town') + if (stashes.length === 0 && isTown) { + const canonical = CANONICAL_TOWN_STASHES[scene.act] + if (canonical) { + stashes.push(canonical) + } + } + // Collision arrives as runs; expanding it here keeps the JSON small and yields // exactly the grid the live path would have produced. const blocked = new Uint8Array(scene.collision.width * scene.collision.height) @@ -1725,6 +1923,7 @@ async function buildPackRuntime( entrances: scene.entrances ?? [], warps: scene.warps ?? [], waypoints: scene.waypoints ?? [], + stashes, variants: forAct.map(candidate => candidate.label), variantParam: 'level', variant: entry.label, @@ -2552,6 +2751,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st const pos = subTileToScene(ent.x, ent.y, runtime.grid) safeZones.push({ x: pos.x, y: pos.y, radius: 160 }) } + for (const s of runtime.stashes ?? []) { + safeZones.push({ x: s.x, y: s.y, radius: 160 }) + } const resolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => { if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) { return { diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index 6828538..df110c7 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -19,7 +19,7 @@ import { GlobesHud } from './globes.ts' import { BeltHud } from './belt.ts' import { SkillHotkeysHud } from './hotkeys.ts' import { ControlBarHud, type MiniPanelAction } from './control-bar.ts' -import { InventoryPanel, type EquipSlotId, resolveItemSpriteRect } from './inventory.ts' +import { InventoryPanel, type EquipSlotId, resolveItemSpriteRect, INV_GRID_ORIGIN } from './inventory.ts' import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts' import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts' import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts' @@ -330,6 +330,9 @@ export class HudManager { } else { this.leftPanel = kind this.charSheet.visible = kind === 'char' + if (kind === 'stash' && this.rightPanel === 'none') { + this.toggleRightPanel('inv') + } } this.syncPublishedState() } @@ -480,6 +483,12 @@ export class HudManager { this.controlBar.handleMouseMove(pt.x, pt.y) this.inventory.handleMouseMove(pt.x, pt.y) this.skillTree.handleMouseMove(pt.x, pt.y) + if (this.leftPanel === 'stash') { + this.worldPanels.handleMouseMove(pt.x, pt.y) + if (this.worldPanels.hoveredStashItem) { + this.inventory.hoveredItem = this.worldPanels.hoveredStashItem + } + } }) window.addEventListener('mousedown', () => { @@ -550,49 +559,75 @@ export class HudManager { return } - // Left-Half Dock Click - if (this.leftPanel === 'char') { - this.charSheet.handleClick(pt.x, pt.y, (key: BaseStatKey) => { - if (key === 'vit') { - this.maxHp = this.charSheet.attrs.baseMaxHp - this.hp = this.maxHp - this.maxStamina = this.charSheet.attrs.baseMaxStamina - this.stamina = this.maxStamina - } else if (key === 'ene') { - this.maxMana = this.charSheet.attrs.baseMaxMana - this.mana = this.maxMana - } - }) - if (!this.charSheet.visible) this.leftPanel = 'none' - this.syncPublishedState() - return - } else if (this.leftPanel !== 'none') { - this.worldPanels.handleLeftDockClick(this.leftPanel, pt.x, pt.y, { - onClose: () => { - this.leftPanel = 'none' - }, - onWaypointTeleport: (act, slug, levelId) => { - this.callbacks.onWaypointTeleport(act, slug, levelId) - }, - }) - this.syncPublishedState() - return + // Left-Half Dock Click (`0..400`) + if (pt.x <= 400) { + if (this.leftPanel === 'char') { + this.charSheet.handleClick(pt.x, pt.y, (key: BaseStatKey) => { + if (key === 'vit') { + this.maxHp = this.charSheet.attrs.baseMaxHp + this.hp = this.maxHp + this.maxStamina = this.charSheet.attrs.baseMaxStamina + this.stamina = this.maxStamina + } else if (key === 'ene') { + this.maxMana = this.charSheet.attrs.baseMaxMana + this.mana = this.maxMana + } + }) + if (!this.charSheet.visible) this.leftPanel = 'none' + this.syncPublishedState() + return + } else if (this.leftPanel !== 'none') { + this.worldPanels.handleLeftDockClick(this.leftPanel, pt.x, pt.y, { + onClose: () => { + this.leftPanel = 'none' + }, + onWaypointTeleport: (act, slug, levelId) => { + this.callbacks.onWaypointTeleport(act, slug, levelId) + }, + inventory: this.inventory, + isShiftClick: e.shiftKey, + isRightClick: e.button === 2, + }) + this.syncPublishedState() + return + } } - // Right-Half Dock Click - if (this.rightPanel === 'inv') { - this.inventory.handleClick(pt.x, pt.y, { - onOpenCube: () => this.toggleLeftPanel('cube'), - onCastTownPortal: () => this.callbacks.onCastTownPortal?.(), - }) - if (!this.inventory.visible) this.rightPanel = 'none' - this.syncPublishedState() - return - } else if (this.rightPanel === 'skill') { - this.skillTree.handleClick(pt.x, pt.y) - if (!this.skillTree.visible) this.rightPanel = 'none' - this.syncPublishedState() - return + // Right-Half Dock Click (`400..800`) + if (pt.x >= 400) { + if (this.rightPanel === 'inv') { + if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) { + if ( + pt.x >= INV_GRID_ORIGIN.x && + pt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && + pt.y >= INV_GRID_ORIGIN.y && + pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((pt.x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) + const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) + const hit = this.inventory.gridItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (hit) { + this.worldPanels.quickTransferToStash(hit.item, this.inventory) + this.syncPublishedState() + return + } + } + } + this.inventory.handleClick(pt.x, pt.y, { + onOpenCube: () => this.toggleLeftPanel('cube'), + onCastTownPortal: () => this.callbacks.onCastTownPortal?.(), + }) + if (!this.inventory.visible) this.rightPanel = 'none' + this.syncPublishedState() + return + } else if (this.rightPanel === 'skill') { + this.skillTree.handleClick(pt.x, pt.y) + if (!this.skillTree.visible) this.rightPanel = 'none' + this.syncPublishedState() + return + } } }) } @@ -732,6 +767,7 @@ export class HudManager { vendorBgImg: this.images.get('vendorBg') ?? null, buySellBtnImg: this.images.get('buySellBtn') ?? null, questsAtlasImg: this.images.get('questsAtlas') ?? null, + itemsAtlasImg: this.images.get('itemsAtlas') ?? null, }, this.font, ) @@ -821,8 +857,12 @@ export class HudManager { this.worldPanels.drawAreaBanner(ctx, nowMs, this.font) } - // 9. Hover Item Tooltip (Inventory / Equipment) - if (this.rightPanel === 'inv' && this.inventory.hoveredItem && !this.inventory.cursorItem) { + // 9. Hover Item Tooltip (Inventory / Equipment / Stash) + if ( + (this.rightPanel === 'inv' || this.leftPanel === 'stash') && + this.inventory.hoveredItem && + !this.inventory.cursorItem + ) { this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font) } diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 73a0eef..a1f223c 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -9,11 +9,20 @@ */ import type { D2ColorCode, D2FontRenderer } from './font.ts' -import type { UiInventoryItem } from './inventory.ts' +import type { GridPlacement, InventoryPanel, UiInventoryItem } from './inventory.ts' +import { resolveItemSpriteRect } from './inventory.ts' import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts' export type LeftDockPanelKind = 'none' | 'char' | 'quest' | 'waypoint' | 'stash' | 'cube' | 'vendor' +export const STASH_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const +export const STASH_GRID_ORIGIN = { x: 80 + 74, y: 60 + 82, cols: 6, rows: 8, cellPx: 29 } as const +export const STASH_GOLD_CAP = 2_500_000 +export const PLAYER_GOLD_CAP = 2_500_000 +export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } as const +export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const +export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const + export interface QuestEntry { readonly id: string readonly act: number @@ -189,6 +198,137 @@ export class WorldPanelsHud { this.areaBanner = { titleZh, titleEn, untilMs: nowMs + 3600 } } + stashItems: GridPlacement[] = [] + hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null + + /** + * Check whether an item of (w, h) can be placed at (col, row) in the 6x8 stash grid. + * Throws RangeError if placed out of bounds. Returns all overlapping GridPlacements. + */ + getStashOverlaps(col: number, row: number, w: number, h: number): GridPlacement[] { + if (col < 0 || row < 0 || col + w > STASH_GRID_ORIGIN.cols || row + h > STASH_GRID_ORIGIN.rows) { + throw new RangeError('Out of 6x8 stash grid bounds') + } + return this.stashItems.filter(p => { + const ox = col < p.col + p.item.invWidth && col + w > p.col + const oy = row < p.row + p.item.invHeight && row + h > p.row + return ox && oy + }) + } + + canPlaceInStash(col: number, row: number, w: number, h: number): boolean { + if (col < 0 || row < 0 || col + w > STASH_GRID_ORIGIN.cols || row + h > STASH_GRID_ORIGIN.rows) { + return false + } + return this.getStashOverlaps(col, row, w, h).length === 0 + } + + autoPlaceInStash(item: UiInventoryItem): boolean { + 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)) { + this.stashItems.push({ item, col, row }) + return true + } + } + } + return false + } + + clickStashCell( + col: number, + row: number, + cursorItem: UiInventoryItem | null, + ): { handled: boolean; newCursorItem: UiInventoryItem | null } { + 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 } + this.stashItems = this.stashItems.filter(p => p !== hit) + return { handled: true, newCursorItem: hit.item } + } + + if ( + col < 0 || + row < 0 || + col + cursorItem.invWidth > STASH_GRID_ORIGIN.cols || + row + cursorItem.invHeight > STASH_GRID_ORIGIN.rows + ) { + return { handled: false, newCursorItem: 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 } + } + if (overlaps.length === 1) { + const single = overlaps[0]! + this.stashItems = this.stashItems.filter(p => p !== single) + this.stashItems.push({ item: cursorItem, col, row }) + return { handled: true, newCursorItem: single.item } + } + return { handled: false, newCursorItem: cursorItem } + } + + quickTransferToStash(item: UiInventoryItem, fromInventory: InventoryPanel): boolean { + const hitIdx = fromInventory.gridItems.findIndex(p => p.item.id === item.id) + if (hitIdx === -1) return false + 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) + if (hitIdx === -1) return false + if (!toInventory.autoPlaceInGrid(item)) return false + this.stashItems.splice(hitIdx, 1) + return true + } + + 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 (available <= 0) return 0 + playerInventory.gold -= available + this.stashGold += available + return available + } + + withdrawGold(amount: number, playerInventory: { gold: number }): number { + if (amount <= 0) return 0 + const available = Math.max(0, Math.min(amount, this.stashGold, PLAYER_GOLD_CAP - playerInventory.gold)) + if (available <= 0) return 0 + this.stashGold -= available + playerInventory.gold += available + return available + } + + handleMouseMove(logicalX: number, logicalY: number): void { + this.hoveredStashItem = null + if ( + logicalX >= STASH_GRID_ORIGIN.x && + logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx && + logicalY >= STASH_GRID_ORIGIN.y && + logicalY < STASH_GRID_ORIGIN.y + STASH_GRID_ORIGIN.rows * STASH_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((logicalX - STASH_GRID_ORIGIN.x) / STASH_GRID_ORIGIN.cellPx) + const row = Math.floor((logicalY - STASH_GRID_ORIGIN.y) / STASH_GRID_ORIGIN.cellPx) + 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) { + this.hoveredStashItem = { + item: hit.item, + x: STASH_GRID_ORIGIN.x + (hit.col + hit.item.invWidth / 2) * STASH_GRID_ORIGIN.cellPx, + y: STASH_GRID_ORIGIN.y + hit.row * STASH_GRID_ORIGIN.cellPx, + } + } + } + } + handleLeftDockClick( kind: Exclude, logicalX: number, @@ -196,6 +336,9 @@ export class WorldPanelsHud { callbacks: { onClose: () => void onWaypointTeleport: (act: number, slug: string, levelId: number) => void + inventory?: InventoryPanel + isShiftClick?: boolean + isRightClick?: boolean }, ): boolean { if (logicalX > 400) return false @@ -251,6 +394,68 @@ export class WorldPanelsHud { this.cubeTransmuteCount += 1 return true } + } else if (kind === 'stash') { + // 1. Gold deposit button + if ( + logicalX >= STASH_DEPOSIT_BTN_BOUNDS.x && + logicalX <= STASH_DEPOSIT_BTN_BOUNDS.x + STASH_DEPOSIT_BTN_BOUNDS.w && + logicalY >= STASH_DEPOSIT_BTN_BOUNDS.y && + logicalY <= STASH_DEPOSIT_BTN_BOUNDS.y + STASH_DEPOSIT_BTN_BOUNDS.h + ) { + if (callbacks.inventory) { + const depositAmt = Math.min(50_000, callbacks.inventory.gold, STASH_GOLD_CAP - this.stashGold) + if (depositAmt > 0) { + this.depositGold(depositAmt, callbacks.inventory) + } + } + return true + } + + // 2. Gold withdraw button + if ( + logicalX >= STASH_WITHDRAW_BTN_BOUNDS.x && + logicalX <= STASH_WITHDRAW_BTN_BOUNDS.x + STASH_WITHDRAW_BTN_BOUNDS.w && + logicalY >= STASH_WITHDRAW_BTN_BOUNDS.y && + logicalY <= STASH_WITHDRAW_BTN_BOUNDS.y + STASH_WITHDRAW_BTN_BOUNDS.h + ) { + if (callbacks.inventory) { + const withdrawAmt = Math.min(50_000, this.stashGold, PLAYER_GOLD_CAP - callbacks.inventory.gold) + if (withdrawAmt > 0) { + this.withdrawGold(withdrawAmt, callbacks.inventory) + } + } + return true + } + + // 3. Stash 6x8 Grid + if ( + logicalX >= STASH_GRID_ORIGIN.x && + logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx && + logicalY >= STASH_GRID_ORIGIN.y && + logicalY < STASH_GRID_ORIGIN.y + STASH_GRID_ORIGIN.rows * STASH_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((logicalX - STASH_GRID_ORIGIN.x) / STASH_GRID_ORIGIN.cellPx) + const row = Math.floor((logicalY - STASH_GRID_ORIGIN.y) / STASH_GRID_ORIGIN.cellPx) + + if ((callbacks.isShiftClick || callbacks.isRightClick) && callbacks.inventory) { + 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) { + this.quickTransferFromStash(hit.item, callbacks.inventory) + return true + } + } + + if (callbacks.inventory) { + const result = this.clickStashCell(col, row, callbacks.inventory.cursorItem) + if (result.handled) { + callbacks.inventory.cursorItem = result.newCursorItem + return true + } + } + return true + } } return true } @@ -267,6 +472,7 @@ export class WorldPanelsHud { vendorBgImg: HTMLImageElement | null buySellBtnImg: HTMLImageElement | null questsAtlasImg?: HTMLImageElement | null + itemsAtlasImg?: HTMLImageElement | null }, font: D2FontRenderer, ): void { @@ -372,11 +578,111 @@ export class WorldPanelsHud { } } else if (kind === 'stash') { if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy) - font.drawText(ctx, `私人储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 32, { + font.drawText(ctx, '私人储物箱 (Private Stash)', ox + 160, oy + 26, { font: 'fontexocet10', color: 'gold', align: 'center', }) + font.drawText(ctx, `储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 48, { + font: 'font8', + color: 'white', + align: 'center', + }) + + // 6x8 grid cell borders + for (let r = 0; r < STASH_GRID_ORIGIN.rows; r++) { + for (let c = 0; c < STASH_GRID_ORIGIN.cols; c++) { + const cx = STASH_GRID_ORIGIN.x + c * STASH_GRID_ORIGIN.cellPx + const cy = STASH_GRID_ORIGIN.y + r * STASH_GRID_ORIGIN.cellPx + ctx.strokeStyle = 'rgba(64, 52, 38, 0.4)' + ctx.strokeRect(cx + 0.5, cy + 0.5, STASH_GRID_ORIGIN.cellPx - 1, STASH_GRID_ORIGIN.cellPx - 1) + } + } + + // Draw items stored in stash + const atlas = assets.itemsAtlasImg ?? null + for (const placed of this.stashItems) { + const gx = STASH_GRID_ORIGIN.x + placed.col * STASH_GRID_ORIGIN.cellPx + const gy = STASH_GRID_ORIGIN.y + placed.row * STASH_GRID_ORIGIN.cellPx + const gw = placed.item.invWidth * STASH_GRID_ORIGIN.cellPx + const gh = placed.item.invHeight * STASH_GRID_ORIGIN.cellPx + + ctx.fillStyle = + placed.item.quality === 'unique' + ? 'rgba(58, 42, 16, 0.45)' + : placed.item.quality === 'set' + ? 'rgba(16, 52, 22, 0.45)' + : 'rgba(20, 32, 54, 0.45)' + ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2) + + const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects) + if (atlas && rect) { + const scale = Math.min(gw / rect.w, gh / rect.h, 1) + const dw = Math.round(rect.w * scale) + const dh = Math.round(rect.h * scale) + const dx = gx + Math.round((gw - dw) / 2) + const dy = gy + Math.round((gh - dh) / 2) + ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh) + } else { + ctx.fillStyle = + placed.item.quality === 'unique' ? '#908858' : placed.item.quality === 'set' ? '#00c400' : '#4850b8' + ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4) + } + } + + // Deposit button + ctx.fillStyle = 'rgba(20, 16, 12, 0.9)' + ctx.fillRect( + STASH_DEPOSIT_BTN_BOUNDS.x, + STASH_DEPOSIT_BTN_BOUNDS.y, + STASH_DEPOSIT_BTN_BOUNDS.w, + STASH_DEPOSIT_BTN_BOUNDS.h, + ) + ctx.strokeStyle = '#8a7248' + ctx.strokeRect( + STASH_DEPOSIT_BTN_BOUNDS.x + 0.5, + STASH_DEPOSIT_BTN_BOUNDS.y + 0.5, + STASH_DEPOSIT_BTN_BOUNDS.w - 1, + STASH_DEPOSIT_BTN_BOUNDS.h - 1, + ) + font.drawText( + ctx, + '存入', + STASH_DEPOSIT_BTN_BOUNDS.x + STASH_DEPOSIT_BTN_BOUNDS.w / 2, + STASH_DEPOSIT_BTN_BOUNDS.y + 16, + { + font: 'font8', + color: 'gold', + align: 'center', + }, + ) + + // Withdraw button + ctx.fillStyle = 'rgba(20, 16, 12, 0.9)' + ctx.fillRect( + STASH_WITHDRAW_BTN_BOUNDS.x, + STASH_WITHDRAW_BTN_BOUNDS.y, + STASH_WITHDRAW_BTN_BOUNDS.w, + STASH_WITHDRAW_BTN_BOUNDS.h, + ) + ctx.strokeStyle = '#8a7248' + ctx.strokeRect( + STASH_WITHDRAW_BTN_BOUNDS.x + 0.5, + STASH_WITHDRAW_BTN_BOUNDS.y + 0.5, + STASH_WITHDRAW_BTN_BOUNDS.w - 1, + STASH_WITHDRAW_BTN_BOUNDS.h - 1, + ) + font.drawText( + ctx, + '取出', + STASH_WITHDRAW_BTN_BOUNDS.x + STASH_WITHDRAW_BTN_BOUNDS.w / 2, + STASH_WITHDRAW_BTN_BOUNDS.y + 16, + { + font: 'font8', + color: 'gold', + align: 'center', + }, + ) } else if (kind === 'cube') { if (assets.cubeBgImg) ctx.drawImage(assets.cubeBgImg, ox, oy) font.drawText(ctx, `赫拉迪克方块 (已合成 ${this.cubeTransmuteCount} 次)`, ox + 160, oy + 32, { diff --git a/tests/stash.test.ts b/tests/stash.test.ts new file mode 100644 index 0000000..23ad260 --- /dev/null +++ b/tests/stash.test.ts @@ -0,0 +1,607 @@ +/** + * tests/stash.test.ts + * + * Comprehensive Unit and Parity Test Suite for Milestone M5 (Issue #150): + * Camp Town Stash Entities Across 5 Acts & 6x8 Storage Panel. + */ + +import { describe, expect, it, beforeEach } from 'vitest' +import { readFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { + STASH_PANEL_ORIGIN, + STASH_GRID_ORIGIN, + STASH_GOLD_CAP, + PLAYER_GOLD_CAP, + STASH_DEPOSIT_BTN_BOUNDS, + STASH_WITHDRAW_BTN_BOUNDS, + WorldPanelsHud, +} from '../src/ui/world-panels.ts' +import { + InventoryPanel, + type UiInventoryItem, + INV_GRID_ORIGIN, +} from '../src/ui/inventory.ts' +import { + type TownStashEntity, + CANONICAL_TOWN_STASHES, + SceneMouseController, + type MapRuntime, +} from '../src/scene/act-scene.ts' +import { GameEngine } from '../src/game/engine.ts' +import { createIsoTerrain } from '../src/game/iso-terrain.ts' +import { ViewportCamera } from '../src/sim/camera.ts' +import { KeyboardInput } from '../src/sim/input.ts' +import { WaypointNetwork } from '../src/game/portal.ts' +import { HudManager } from '../src/ui/hud-manager.ts' + +class MockWindow extends EventTarget { + innerWidth = 1920 + innerHeight = 1080 + devicePixelRatio = 1 +} + +if (typeof globalThis.window === 'undefined') { + // @ts-ignore + globalThis.window = new MockWindow() +} +if (typeof globalThis.HTMLInputElement === 'undefined') { + // @ts-ignore + globalThis.HTMLInputElement = class HTMLInputElement {} +} +if (typeof globalThis.HTMLSelectElement === 'undefined') { + // @ts-ignore + globalThis.HTMLSelectElement = class HTMLSelectElement {} +} + +function createDummyItem(id: string, code: string, w: number, h: number, nameZh: string): UiInventoryItem { + return { + id, + code, + invFile: code, + name: nameZh, + nameZh, + baseNameZh: nameZh, + quality: 'normal', + invWidth: w, + invHeight: h, + allowedSlots: [], + stats: [], + } +} + +describe('Milestone M5 (Issue #150): Camp Town Stash Entities Across 5 Acts & 6x8 Storage Panel', () => { + describe('1. Authentic Town Stash Entities Across Acts 1..5', () => { + it('defines canonical stash entities for all 5 acts with Objects.txt row 267 B6 token', () => { + for (let act = 1; act <= 5; act++) { + const stash = CANONICAL_TOWN_STASHES[act] + expect(stash, `Expected canonical stash for Act ${act}`).toBeDefined() + expect(stash.act).toBe(act) + expect(stash.objectsTxtId).toBe(267) + expect(stash.token).toBe('B6') + expect(stash.clickRadiusPx).toBe(48) + expect(stash.interactionRangePx).toBe(80) + expect(stash.x).toBeGreaterThan(0) + expect(stash.y).toBeGreaterThan(0) + } + }) + + it('verifies exact 1.13c coordinates for all 5 act towns', () => { + // Act 1: Rogue Encampment (towne1 default) + expect(CANONICAL_TOWN_STASHES[1].x).toBe(4512) + expect(CANONICAL_TOWN_STASHES[1].y).toBe(1720) + expect(CANONICAL_TOWN_STASHES[1].subTileX).toBe(112) + expect(CANONICAL_TOWN_STASHES[1].subTileY).toBe(86) + + // Act 2: Lut Gholein + expect(CANONICAL_TOWN_STASHES[2].x).toBe(5328) + expect(CANONICAL_TOWN_STASHES[2].y).toBe(1568) + + // Act 3: Kurast Docks + expect(CANONICAL_TOWN_STASHES[3].x).toBe(5264) + expect(CANONICAL_TOWN_STASHES[3].y).toBe(1584) + + // Act 4: The Pandemonium Fortress + expect(CANONICAL_TOWN_STASHES[4].x).toBe(704) + expect(CANONICAL_TOWN_STASHES[4].y).toBe(552) + + // Act 5: Harrogath + expect(CANONICAL_TOWN_STASHES[5].x).toBe(3792) + expect(CANONICAL_TOWN_STASHES[5].y).toBe(1448) + }) + + it('verifies prebaked town scenes in samples/d2-packs contain the B6 stash object', () => { + const townScenes: Record = { + 1: 'samples/d2-packs/act1-towne1/scene.json', + 2: 'samples/d2-packs/act2-lutn/scene.json', + 3: 'samples/d2-packs/act3-docktown3/scene.json', + 4: 'samples/d2-packs/act4-fortress/scene.json', + 5: 'samples/d2-packs/act5-townwest/scene.json', + } + + for (let act = 1; act <= 5; act++) { + const p = join(process.cwd(), townScenes[act]!) + if (existsSync(p)) { + const raw = readFileSync(p, 'utf-8') + const scene = JSON.parse(raw) + const stashObj = scene.objects?.find((o: any) => o.objectsTxtId === 267 || o.token === 'B6') + expect(stashObj, `Expected B6 stash object in scene ${townScenes[act]}`).toBeDefined() + expect(stashObj.token).toBe('B6') + expect(stashObj.objectsTxtId).toBe(267) + } + } + }) + }) + + describe('2. Stash Panel & 6x8 Grid Layout & Dimensions', () => { + it('defines authentic 6x8 grid origin and 29x29 cell size inside 320x432 panel', () => { + expect(STASH_PANEL_ORIGIN.w).toBe(320) + expect(STASH_PANEL_ORIGIN.h).toBe(432) + expect(STASH_GRID_ORIGIN.cols).toBe(6) + expect(STASH_GRID_ORIGIN.rows).toBe(8) + expect(STASH_GRID_ORIGIN.cellPx).toBe(29) + + // Absolute grid top-left: ox (80) + 74 = 154, oy (60) + 82 = 142 + expect(STASH_GRID_ORIGIN.x).toBe(154) + expect(STASH_GRID_ORIGIN.y).toBe(142) + + // Absolute grid width: 6 * 29 = 174px -> right boundary = 154 + 174 = 328 + // Absolute grid height: 8 * 29 = 232px -> bottom boundary = 142 + 232 = 374 + expect(STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx).toBe(328) + expect(STASH_GRID_ORIGIN.y + STASH_GRID_ORIGIN.rows * STASH_GRID_ORIGIN.cellPx).toBe(374) + }) + + it('defines deposit and withdraw button bounds inside stash panel', () => { + expect(STASH_DEPOSIT_BTN_BOUNDS.x).toBeGreaterThanOrEqual(STASH_PANEL_ORIGIN.x) + expect(STASH_DEPOSIT_BTN_BOUNDS.x + STASH_DEPOSIT_BTN_BOUNDS.w).toBeLessThanOrEqual( + STASH_PANEL_ORIGIN.x + STASH_PANEL_ORIGIN.w, + ) + expect(STASH_WITHDRAW_BTN_BOUNDS.x).toBeGreaterThanOrEqual(STASH_PANEL_ORIGIN.x) + expect(STASH_WITHDRAW_BTN_BOUNDS.x + STASH_WITHDRAW_BTN_BOUNDS.w).toBeLessThanOrEqual( + STASH_PANEL_ORIGIN.x + STASH_PANEL_ORIGIN.w, + ) + }) + + it('confirms stash background graphic asset exists in public/ui/stash-bg.png', () => { + const bgPath = join(process.cwd(), 'public/ui/stash-bg.png') + expect(existsSync(bgPath)).toBe(true) + }) + }) + + describe('3. Stash Grid Placement, Collision, and Bounds Validation', () => { + let panels: WorldPanelsHud + + beforeEach(() => { + panels = new WorldPanelsHud() + }) + + it('places an item in free stash cells and checks collision overlaps', () => { + const ring = createDummyItem('ring1', 'rin', 1, 1, '乔丹之石 (Stone of Jordan)') + expect(panels.canPlaceInStash(0, 0, 1, 1)).toBe(true) + panels.stashItems.push({ item: ring, col: 0, row: 0 }) + + // Same cell is now blocked + expect(panels.canPlaceInStash(0, 0, 1, 1)).toBe(false) + expect(panels.getStashOverlaps(0, 0, 1, 1)).toHaveLength(1) + expect(panels.getStashOverlaps(0, 0, 1, 1)[0]!.item.id).toBe('ring1') + + // Adjacent cell (1, 0) is free + expect(panels.canPlaceInStash(1, 0, 1, 1)).toBe(true) + }) + + it('rejects multi-cell items overlapping existing items', () => { + const sword = createDummyItem('sword1', 'crs', 2, 3, '水晶剑 (Crystal Sword)') + panels.stashItems.push({ item: sword, col: 1, row: 1 }) + + // An overlapping 2x2 shield at (0, 1) overlaps sword at col 1 + expect(panels.canPlaceInStash(0, 1, 2, 2)).toBe(false) + const overlaps = panels.getStashOverlaps(0, 1, 2, 2) + expect(overlaps).toHaveLength(1) + expect(overlaps[0]!.item.id).toBe('sword1') + + // Non-overlapping placement at (3, 1) is allowed + expect(panels.canPlaceInStash(3, 1, 2, 2)).toBe(true) + }) + + it('strictly enforces 6x8 bounds (throws RangeError in getStashOverlaps, returns false in canPlaceInStash)', () => { + // 2x2 item at col 5, row 0 exceeds 6 cols (5 + 2 = 7 > 6) + expect(panels.canPlaceInStash(5, 0, 2, 2)).toBe(false) + expect(() => panels.getStashOverlaps(5, 0, 2, 2)).toThrow(RangeError) + + // 1x3 item at col 0, row 6 exceeds 8 rows (6 + 3 = 9 > 8) + expect(panels.canPlaceInStash(0, 6, 1, 3)).toBe(false) + expect(() => panels.getStashOverlaps(0, 6, 1, 3)).toThrow(RangeError) + + // Negative coordinates + expect(panels.canPlaceInStash(-1, 0, 1, 1)).toBe(false) + expect(() => panels.getStashOverlaps(-1, 0, 1, 1)).toThrow(RangeError) + }) + + it('autoPlaceInStash finds the first free slot or returns false when full', () => { + const grandCharm = createDummyItem('gc1', 'cm3', 1, 3, '超大型护身符 (Grand Charm)') + const placed = panels.autoPlaceInStash(grandCharm) + expect(placed).toBe(true) + expect(panels.stashItems).toHaveLength(1) + expect(panels.stashItems[0]!.col).toBe(0) + expect(panels.stashItems[0]!.row).toBe(0) + + // Second grand charm should be placed at col 0, row 3 (underneath) + const gc2 = createDummyItem('gc2', 'cm3', 1, 3, '超大型护身符 (Grand Charm 2)') + expect(panels.autoPlaceInStash(gc2)).toBe(true) + expect(panels.stashItems[1]!.col).toBe(0) + expect(panels.stashItems[1]!.row).toBe(3) + }) + + it('clickStashCell handles picking up, placing, and swapping items', () => { + const helm = createDummyItem('helm1', 'cap', 2, 2, '军帽 (Shako)') + panels.stashItems.push({ item: helm, col: 0, row: 0 }) + + // 1. Pick up item with empty cursor + const pickRes = panels.clickStashCell(0, 0, null) + expect(pickRes.handled).toBe(true) + expect(pickRes.newCursorItem?.id).toBe('helm1') + expect(panels.stashItems).toHaveLength(0) + + // 2. Place item down at (2, 2) + const placeRes = panels.clickStashCell(2, 2, pickRes.newCursorItem) + expect(placeRes.handled).toBe(true) + expect(placeRes.newCursorItem).toBeNull() + expect(panels.stashItems).toHaveLength(1) + expect(panels.stashItems[0]!.col).toBe(2) + expect(panels.stashItems[0]!.row).toBe(2) + + // 3. Swap with held ring + const ring = createDummyItem('ring2', 'rin', 1, 1, '布尔凯索之戒') + const swapRes = panels.clickStashCell(2, 2, ring) + expect(swapRes.handled).toBe(true) + expect(swapRes.newCursorItem?.id).toBe('helm1') + expect(panels.stashItems).toHaveLength(1) + expect(panels.stashItems[0]!.item.id).toBe('ring2') + }) + }) + + describe('4. Quick Transfer Between Inventory and Stash', () => { + let panels: WorldPanelsHud + let inv: InventoryPanel + + beforeEach(() => { + panels = new WorldPanelsHud() + inv = new InventoryPanel() + }) + + it('quickTransferToStash moves item from inventory to stash and removes from inventory', () => { + const item = createDummyItem('transfer1', 'rin', 1, 1, '测试戒指') + inv.gridItems = [{ item, col: 0, row: 0 }] + expect(panels.stashItems).toHaveLength(0) + + const success = panels.quickTransferToStash(item, inv) + expect(success).toBe(true) + expect(inv.gridItems).toHaveLength(0) + expect(panels.stashItems).toHaveLength(1) + expect(panels.stashItems[0]!.item.id).toBe('transfer1') + }) + + it('quickTransferToStash fails without removing item if stash is full', () => { + // Fill stash with 48 1x1 items + for (let r = 0; r < 8; r++) { + for (let c = 0; c < 6; c++) { + panels.stashItems.push({ + item: createDummyItem(`fill-${r}-${c}`, 'rin', 1, 1, '填充物'), + col: c, + row: r, + }) + } + } + expect(panels.stashItems).toHaveLength(48) + + const overflowItem = createDummyItem('overflow', 'rin', 1, 1, '溢出物品') + inv.gridItems = [{ item: overflowItem, col: 0, row: 0 }] + + const success = panels.quickTransferToStash(overflowItem, inv) + expect(success).toBe(false) + expect(inv.gridItems).toHaveLength(1) + expect(panels.stashItems).toHaveLength(48) + }) + + it('quickTransferFromStash moves item from stash to inventory and removes from stash', () => { + const item = createDummyItem('fromStash1', 'rin', 1, 1, '储物箱戒指') + panels.stashItems = [{ item, col: 0, row: 0 }] + inv.gridItems = [] + + const success = panels.quickTransferFromStash(item, inv) + expect(success).toBe(true) + expect(panels.stashItems).toHaveLength(0) + expect(inv.gridItems).toHaveLength(1) + expect(inv.gridItems[0]!.item.id).toBe('fromStash1') + }) + + it('quickTransferFromStash fails without removing item if inventory is full', () => { + // Fill inventory with 40 1x1 items + inv.gridItems = [] + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 10; c++) { + inv.gridItems.push({ + item: createDummyItem(`inv-fill-${r}-${c}`, 'rin', 1, 1, '背包填充'), + col: c, + row: r, + }) + } + } + expect(inv.gridItems).toHaveLength(40) + + const stashItem = createDummyItem('cantTransfer', 'rin', 1, 1, '无法转移') + panels.stashItems = [{ item: stashItem, col: 0, row: 0 }] + + const success = panels.quickTransferFromStash(stashItem, inv) + expect(success).toBe(false) + expect(panels.stashItems).toHaveLength(1) + expect(inv.gridItems).toHaveLength(40) + }) + }) + + describe('5. Gold Deposit and Withdrawal with Caps', () => { + let panels: WorldPanelsHud + let inv: InventoryPanel + + beforeEach(() => { + panels = new WorldPanelsHud() + inv = new InventoryPanel() + inv.gold = 500_000 + panels.stashGold = 100_000 + }) + + it('deposits gold from inventory into stash up to capacity', () => { + const deposited = panels.depositGold(50_000, inv) + expect(deposited).toBe(50_000) + expect(inv.gold).toBe(450_000) + expect(panels.stashGold).toBe(150_000) + }) + + it('withdraws gold from stash into inventory up to player capacity', () => { + const withdrawn = panels.withdrawGold(40_000, inv) + expect(withdrawn).toBe(40_000) + expect(inv.gold).toBe(540_000) + expect(panels.stashGold).toBe(60_000) + }) + + it('respects STASH_GOLD_CAP (2,500,000) and clamps deposit amounts', () => { + expect(STASH_GOLD_CAP).toBe(2_500_000) + panels.stashGold = 2_490_000 + inv.gold = 100_000 + + const deposited = panels.depositGold(50_000, inv) + expect(deposited).toBe(10_000) + expect(panels.stashGold).toBe(2_500_000) + expect(inv.gold).toBe(90_000) + + // Further deposits do nothing + expect(panels.depositGold(10_000, inv)).toBe(0) + expect(panels.stashGold).toBe(2_500_000) + }) + + it('respects PLAYER_GOLD_CAP (2,500,000) and clamps withdrawal amounts', () => { + expect(PLAYER_GOLD_CAP).toBe(2_500_000) + panels.stashGold = 500_000 + inv.gold = 2_480_000 + + const withdrawn = panels.withdrawGold(50_000, inv) + expect(withdrawn).toBe(20_000) + expect(inv.gold).toBe(2_500_000) + expect(panels.stashGold).toBe(480_000) + + // Further withdrawals do nothing + expect(panels.withdrawGold(10_000, inv)).toBe(0) + expect(inv.gold).toBe(2_500_000) + }) + + it('handles zero or negative transfers safely', () => { + expect(panels.depositGold(0, inv)).toBe(0) + expect(panels.depositGold(-500, inv)).toBe(0) + expect(panels.withdrawGold(0, inv)).toBe(0) + expect(panels.withdrawGold(-500, inv)).toBe(0) + expect(inv.gold).toBe(500_000) + expect(panels.stashGold).toBe(100_000) + }) + }) + + describe('6. SceneMouseController Stash Click & Interaction', () => { + let engine: GameEngine + let camera: ViewportCamera + let input: KeyboardInput + let waypointNetwork: WaypointNetwork + let statusElem: HTMLElement + let controller: SceneMouseController + let runtime: MapRuntime + let stashOpenCount: number + + beforeEach(() => { + stashOpenCount = 0 + const grid = { + originX: 0, + originY: 0, + cellsX: 100, + cellsY: 100, + blocked: new Uint8Array(500 * 500), + gridWidth: 500, + } + const terrain = createIsoTerrain(grid, 8000, 4000, { width: 20, height: 10 }) + engine = new GameEngine(terrain, { + spawn: { x: 4500, y: 1700 }, + stats: [], + monsterCount: 0, + questDefs: [], + npcDefs: [], + } as any) + const mockCanvas = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), + clientWidth: 800, + clientHeight: 600, + width: 800, + height: 600, + addEventListener: () => {}, + removeEventListener: () => {}, + getContext: () => null, + style: { cursor: '' }, + } as unknown as HTMLCanvasElement + + camera = new ViewportCamera(mockCanvas, 8000, 4000) + input = new KeyboardInput() + waypointNetwork = new WaypointNetwork() + statusElem = { textContent: '' } as unknown as HTMLElement + + const mockHudManager = { + leftPanel: 'none', + toggleLeftPanel: (panel: any) => { + if (panel === 'stash') stashOpenCount++ + mockHudManager.leftPanel = panel + }, + clientToLogical: (x: number, y: number) => ({ x, y }), + isPointInterceptedByHud: () => false, + } as unknown as HudManager + + runtime = { + source: 'pack', + base: 'samples/d2-packs', + level: 'Rogue Encampment', + act: 1, + levelId: 1, + entryPath: 'act1-towne1', + entrances: [], + warps: [], + waypoints: [], + stashes: [CANONICAL_TOWN_STASHES[1]!], + variants: ['towne1'], + variantParam: 'level', + variant: 'towne1', + quadrants: ['towne1'], + quadrantParam: 'level', + quadrant: 'towne1', + cellsX: 100, + cellsY: 100, + widthPx: 8000, + heightPx: 4000, + grid, + floors: [], + walls: [], + roofs: [], + objectDrawables: [], + spawn: { x: 4500, y: 1700 }, + npcs: [], + monsterPacks: [], + monsterTypes: [], + objects: 1, + frames: 0, + notes: [], + pages: [], + objectPages: [], + charBases: [], + palette: null, + priorityPages: [], + loadOrder: [], + loadPages: async () => {}, + } + + controller = new SceneMouseController({ + canvas: mockCanvas, + engine, + camera, + input, + getRuntime: () => runtime, + hudManager: mockHudManager, + waypointNetwork, + status: statusElem, + }) + }) + + it('finds stash within 48px click radius and ignores clicks further away', () => { + const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720) + expect(controller.findNearestStash(stash.x, stash.y, 48)).toEqual(stash) + expect(controller.findNearestStash(stash.x + 30, stash.y + 20, 48)).toEqual(stash) + + // 60px away exceeds 48px radius + expect(controller.findNearestStash(stash.x + 50, stash.y + 50, 48)).toBeNull() + }) + + it('opens stash immediately when player is within 80px interaction range', () => { + const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720) + engine.world.player.x = stash.x - 30 + engine.world.player.y = stash.y + + controller.handleLeftClickWorld(stash.x, stash.y) + + expect(controller.activeClickType).toBe('stash') + expect(controller.pendingInteraction).toBeNull() + expect(controller.navTarget).toBeNull() + expect(stashOpenCount).toBe(1) + expect(statusElem.textContent).toContain('储物箱') + }) + + it('walks toward stash when player is outside 80px range, then opens upon arrival in tick()', () => { + const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720) + engine.world.player.x = stash.x - 200 + engine.world.player.y = stash.y + + controller.handleLeftClickWorld(stash.x, stash.y) + + expect(controller.activeClickType).toBe('stash') + expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash }) + expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y }) + expect(stashOpenCount).toBe(0) + + // Simulate player walking and arriving within 80px + engine.world.player.x = stash.x - 60 + controller.tick() + + expect(controller.pendingInteraction).toBeNull() + expect(controller.navTarget).toBeNull() + expect(stashOpenCount).toBe(1) + expect(statusElem.textContent).toContain('储物箱') + }) + }) + + describe('7. HUD Manager Integration & Split Dock Synergy', () => { + let hudCanvas: HTMLCanvasElement + let hud: HudManager + + beforeEach(() => { + hudCanvas = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), + addEventListener: () => {}, + removeEventListener: () => {}, + getContext: () => null, + style: { cursor: '' }, + } as unknown as HTMLCanvasElement + + hud = new HudManager(hudCanvas, { + onWaypointTeleport: () => {}, + onToggleAutomap: () => {}, + }) + }) + + it('toggleLeftPanel("stash") automatically opens inventory on the right dock', () => { + expect(hud.leftPanel).toBe('none') + expect(hud.rightPanel).toBe('none') + + hud.toggleLeftPanel('stash') + expect(hud.leftPanel).toBe('stash') + expect(hud.rightPanel).toBe('inv') + expect(hud.inventory.visible).toBe(true) + + // Toggling stash off leaves right panel intact or allows closing all + hud.toggleLeftPanel('stash') + expect(hud.leftPanel).toBe('none') + }) + + it('closeAllPanels closes stash and inventory together', () => { + hud.toggleLeftPanel('stash') + expect(hud.leftPanel).toBe('stash') + expect(hud.rightPanel).toBe('inv') + + const closed = hud.closeAllPanels() + expect(closed).toBe(true) + expect(hud.leftPanel).toBe('none') + expect(hud.rightPanel).toBe('none') + expect(hud.inventory.visible).toBe(false) + }) + }) +})