608 lines
21 KiB
TypeScript
608 lines
21 KiB
TypeScript
/**
|
|
* 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<number, string> = {
|
|
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)
|
|
})
|
|
})
|
|
})
|