713 lines
26 KiB
TypeScript
713 lines
26 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import {
|
|
CANONICAL_TOWN_STASHES,
|
|
type TownStashEntity,
|
|
SceneMouseController,
|
|
type MapRuntime,
|
|
} from '../src/scene/act-scene.ts'
|
|
import {
|
|
WorldPanelsHud,
|
|
STASH_GOLD_CAP,
|
|
PLAYER_GOLD_CAP,
|
|
STASH_DEPOSIT_BTN_BOUNDS,
|
|
STASH_WITHDRAW_BTN_BOUNDS,
|
|
} from '../src/ui/world-panels.ts'
|
|
import { HudManager } from '../src/ui/hud-manager.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'
|
|
|
|
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 {}
|
|
}
|
|
|
|
describe('Challenger M5 Empirical Stress Test: Town Stash Entities, Interaction Geometry & Gold Mechanics', () => {
|
|
describe('1. 5-Act Town Stash Entity Verification', () => {
|
|
it('verifies all 5 town acts spawn a stash entity with ID 267 and token "B6"', () => {
|
|
for (let act = 1; act <= 5; act++) {
|
|
const stash = CANONICAL_TOWN_STASHES[act]
|
|
expect(stash, `Act ${act} stash must be defined`).toBeDefined()
|
|
expect(stash.id).toBe(267)
|
|
expect(stash.objectsTxtId).toBe(267)
|
|
expect(stash.token).toBe('B6')
|
|
expect(stash.act).toBe(act)
|
|
expect(stash.nameZh).toBe('储物箱')
|
|
expect(stash.nameEn).toBe('Stash')
|
|
expect(stash.clickRadiusPx).toBe(48)
|
|
expect(stash.interactionRangePx).toBe(80)
|
|
}
|
|
})
|
|
|
|
it('verifies exact coordinates in each act matching authentic 1.13c ground truth', () => {
|
|
const expectedCoordinates: Record<number, { x: number; y: number; subTileX: number; subTileY: number }> = {
|
|
1: { x: 4512, y: 1720, subTileX: 112, subTileY: 86 }, // Rogue Encampment
|
|
2: { x: 5328, y: 1568, subTileX: 133, subTileY: 78 }, // Lut Gholein
|
|
3: { x: 5264, y: 1584, subTileX: 131, subTileY: 79 }, // Kurast Docks
|
|
4: { x: 704, y: 552, subTileX: 17, subTileY: 27 }, // Pandemonium Fortress
|
|
5: { x: 3792, y: 1448, subTileX: 94, subTileY: 72 }, // Harrogath
|
|
}
|
|
|
|
for (let act = 1; act <= 5; act++) {
|
|
const stash = CANONICAL_TOWN_STASHES[act]
|
|
const expected = expectedCoordinates[act]!
|
|
expect(stash.x, `Act ${act} stash X`).toBe(expected.x)
|
|
expect(stash.y, `Act ${act} stash Y`).toBe(expected.y)
|
|
expect(stash.subTileX, `Act ${act} stash subTileX`).toBe(expected.subTileX)
|
|
expect(stash.subTileY, `Act ${act} stash subTileY`).toBe(expected.subTileY)
|
|
}
|
|
})
|
|
|
|
it('verifies sub-tile conversion alignment against authentic DS1 town origins', () => {
|
|
const townOrigins: Record<number, { originX: number; originY: number }> = {
|
|
1: { originX: 2720, originY: 344 }, // Act 1 Rogue Encampment
|
|
2: { originX: 3200, originY: 320 }, // Act 2 Lut Gholein
|
|
3: { originX: 3168, originY: 320 }, // Act 3 Kurast Docks
|
|
4: { originX: 432, originY: 120 }, // Act 4 Pandemonium Fortress
|
|
5: { originX: 2288, originY: 296 }, // Act 5 Harrogath
|
|
}
|
|
|
|
for (let act = 1; act <= 5; act++) {
|
|
const stash = CANONICAL_TOWN_STASHES[act]!
|
|
const origin = townOrigins[act]!
|
|
// Exact 1.13c DS1 coordinate mapping: worldPos = origin + subTile * 16
|
|
expect(origin.originX + stash.subTileX * 16, `Act ${act} stash world X`).toBe(stash.x)
|
|
expect(origin.originY + stash.subTileY * 16, `Act ${act} stash world Y`).toBe(stash.y)
|
|
}
|
|
})
|
|
|
|
it('verifies safe zone coverage around town stashes', () => {
|
|
for (let act = 1; act <= 5; act++) {
|
|
const stash = CANONICAL_TOWN_STASHES[act]
|
|
// Stash safe zones are configured with radius 160px in runScene
|
|
expect(stash.x).toBeGreaterThan(0)
|
|
expect(stash.y).toBeGreaterThan(0)
|
|
expect(stash.clickRadiusPx).toBe(48)
|
|
expect(stash.interactionRangePx).toBe(80)
|
|
}
|
|
})
|
|
|
|
it('verifies multi-act scene runtime integration: all 5 acts resolve their canonical stash', () => {
|
|
const townLevels: Record<number, { levelId: number; name: string; slug: string }> = {
|
|
1: { levelId: 1, name: 'Rogue Encampment', slug: 'act1-towne1' },
|
|
2: { levelId: 40, name: 'Lut Gholein', slug: 'act2-lutn' },
|
|
3: { levelId: 75, name: 'Kurast Docks', slug: 'act3-docktown3' },
|
|
4: { levelId: 103, name: 'The Pandemonium Fortress', slug: 'act4-fortress' },
|
|
5: { levelId: 109, name: 'Harrogath', slug: 'act5-townwest' },
|
|
}
|
|
|
|
for (let act = 1; act <= 5; act++) {
|
|
const info = townLevels[act]!
|
|
const canonical = CANONICAL_TOWN_STASHES[act]!
|
|
const runtimeStashes: TownStashEntity[] = [canonical]
|
|
|
|
expect(runtimeStashes.length).toBe(1)
|
|
const stash = runtimeStashes[0]!
|
|
expect(stash.id).toBe(267)
|
|
expect(stash.objectsTxtId).toBe(267)
|
|
expect(stash.token).toBe('B6')
|
|
expect(stash.act).toBe(act)
|
|
expect(stash.nameZh).toBe('储物箱')
|
|
expect(stash.nameEn).toBe('Stash')
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('2. Interaction Geometry Stress Testing', () => {
|
|
let engine: GameEngine
|
|
let camera: ViewportCamera
|
|
let input: KeyboardInput
|
|
let waypointNetwork: WaypointNetwork
|
|
let statusElem: HTMLElement
|
|
let controller: SceneMouseController
|
|
let runtime: MapRuntime
|
|
let stashOpenCount: number
|
|
let mockHudManager: any
|
|
|
|
beforeEach(() => {
|
|
stashOpenCount = 0
|
|
const grid = {
|
|
originX: 0,
|
|
originY: 0,
|
|
cellsX: 200,
|
|
cellsY: 200,
|
|
blocked: new Uint8Array(1000 * 1000),
|
|
gridWidth: 1000,
|
|
}
|
|
const terrain = createIsoTerrain(grid, 10000, 5000, { 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, 10000, 5000)
|
|
input = new KeyboardInput()
|
|
waypointNetwork = new WaypointNetwork()
|
|
statusElem = { textContent: '' } as unknown as HTMLElement
|
|
|
|
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]!,
|
|
CANONICAL_TOWN_STASHES[2]!,
|
|
CANONICAL_TOWN_STASHES[3]!,
|
|
CANONICAL_TOWN_STASHES[4]!,
|
|
CANONICAL_TOWN_STASHES[5]!,
|
|
],
|
|
variants: ['towne1'],
|
|
variantParam: 'level',
|
|
variant: 'towne1',
|
|
quadrants: ['towne1'],
|
|
quadrantParam: 'level',
|
|
quadrant: 'towne1',
|
|
cellsX: 200,
|
|
cellsY: 200,
|
|
widthPx: 10000,
|
|
heightPx: 5000,
|
|
grid,
|
|
floors: [],
|
|
walls: [],
|
|
roofs: [],
|
|
objectDrawables: [],
|
|
spawn: { x: 4500, y: 1700 },
|
|
npcs: [],
|
|
monsterPacks: [],
|
|
monsterTypes: [],
|
|
objects: 5,
|
|
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,
|
|
})
|
|
})
|
|
|
|
describe('Click radius (<= 48px hit, > 48px miss)', () => {
|
|
const stash1 = CANONICAL_TOWN_STASHES[1]! // (4512, 1720)
|
|
|
|
it('asserts exact center click hits the stash', () => {
|
|
expect(controller.findNearestStash(stash1.x, stash1.y, 48)).toEqual(stash1)
|
|
})
|
|
|
|
it('asserts clicks at distance <= 48px hit the stash in all cardinal directions', () => {
|
|
expect(controller.findNearestStash(stash1.x + 48, stash1.y, 48)).toEqual(stash1)
|
|
expect(controller.findNearestStash(stash1.x - 48, stash1.y, 48)).toEqual(stash1)
|
|
expect(controller.findNearestStash(stash1.x, stash1.y + 48, 48)).toEqual(stash1)
|
|
expect(controller.findNearestStash(stash1.x, stash1.y - 48, 48)).toEqual(stash1)
|
|
})
|
|
|
|
it('asserts 36-angle radial sweep at radius 47.5px (strictly <= 48px) all hit the stash', () => {
|
|
for (let i = 0; i < 36; i++) {
|
|
const angle = (i * 2 * Math.PI) / 36
|
|
const x = stash1.x + 47.5 * Math.cos(angle)
|
|
const y = stash1.y + 47.5 * Math.sin(angle)
|
|
const hit = controller.findNearestStash(x, y, 48)
|
|
expect(hit, `Angle ${i * 10}deg at distance <= 48 should hit stash`).toEqual(stash1)
|
|
}
|
|
})
|
|
|
|
it('asserts clicks at distance > 48px do NOT hit the stash', () => {
|
|
// Just beyond boundary (49px, 50px, 60px)
|
|
expect(controller.findNearestStash(stash1.x + 49, stash1.y, 48)).toBeNull()
|
|
expect(controller.findNearestStash(stash1.x - 49, stash1.y, 48)).toBeNull()
|
|
expect(controller.findNearestStash(stash1.x, stash1.y + 49, 48)).toBeNull()
|
|
expect(controller.findNearestStash(stash1.x, stash1.y - 49, 48)).toBeNull()
|
|
|
|
// Diagonal beyond boundary (35, 35) -> dist = 49.5px > 48px
|
|
expect(controller.findNearestStash(stash1.x + 35, stash1.y + 35, 48)).toBeNull()
|
|
expect(controller.findNearestStash(stash1.x - 35, stash1.y - 35, 48)).toBeNull()
|
|
|
|
// Distant clicks
|
|
expect(controller.findNearestStash(stash1.x + 100, stash1.y, 48)).toBeNull()
|
|
expect(controller.findNearestStash(0, 0, 48)).toBeNull()
|
|
})
|
|
|
|
it('asserts 36-angle radial sweep at radius 50px all miss the stash', () => {
|
|
for (let i = 0; i < 36; i++) {
|
|
const angle = (i * 2 * Math.PI) / 36
|
|
const x = stash1.x + 50 * Math.cos(angle)
|
|
const y = stash1.y + 50 * Math.sin(angle)
|
|
const hit = controller.findNearestStash(x, y, 48)
|
|
expect(hit, `Angle ${i * 10}deg at distance 50px must not hit stash`).toBeNull()
|
|
}
|
|
})
|
|
|
|
it('discriminates between closest stash when multiple stashes are present', () => {
|
|
const stash3 = CANONICAL_TOWN_STASHES[3]! // (5264, 1584)
|
|
const stash2 = CANONICAL_TOWN_STASHES[2]! // (5328, 1568)
|
|
// Click near stash3
|
|
expect(controller.findNearestStash(stash3.x + 10, stash3.y, 48)).toEqual(stash3)
|
|
// Click near stash2
|
|
expect(controller.findNearestStash(stash2.x + 10, stash2.y, 48)).toEqual(stash2)
|
|
})
|
|
|
|
it('tests custom maxRadius overrides (e.g. 24px, 64px)', () => {
|
|
// At 30px away: misses with maxRadius=24, hits with maxRadius=48 and maxRadius=64
|
|
expect(controller.findNearestStash(stash1.x + 30, stash1.y, 24)).toBeNull()
|
|
expect(controller.findNearestStash(stash1.x + 30, stash1.y, 48)).toEqual(stash1)
|
|
expect(controller.findNearestStash(stash1.x + 60, stash1.y, 64)).toEqual(stash1)
|
|
})
|
|
})
|
|
|
|
describe('Distance threshold (<= 80px immediate open, > 80px pathfind)', () => {
|
|
const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720)
|
|
|
|
it('player at distance <= 80px opens stash immediately without movement', () => {
|
|
const testDistances = [0, 20, 50, 75, 80]
|
|
for (const dist of testDistances) {
|
|
stashOpenCount = 0
|
|
mockHudManager.leftPanel = 'none'
|
|
engine.world.player.x = stash.x - dist
|
|
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('player at distance > 80px schedules pathfinding/walk and does not open stash immediately', () => {
|
|
const testDistances = [81, 100, 250, 500]
|
|
for (const dist of testDistances) {
|
|
stashOpenCount = 0
|
|
mockHudManager.leftPanel = 'none'
|
|
engine.world.player.x = stash.x - dist
|
|
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)
|
|
}
|
|
})
|
|
|
|
it('player walks toward stash and opens automatically upon reaching <= 80px in tick()', () => {
|
|
engine.world.player.x = stash.x - 300
|
|
engine.world.player.y = stash.y
|
|
|
|
controller.handleLeftClickWorld(stash.x, stash.y)
|
|
|
|
expect(stashOpenCount).toBe(0)
|
|
expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash })
|
|
expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y })
|
|
|
|
// Step 1: Walk to 200px away -> still too far
|
|
engine.world.player.x = stash.x - 200
|
|
controller.tick()
|
|
expect(stashOpenCount).toBe(0)
|
|
expect(controller.pendingInteraction).not.toBeNull()
|
|
expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y })
|
|
|
|
// Step 2: Walk to 90px away -> still outside 80px
|
|
engine.world.player.x = stash.x - 90
|
|
controller.tick()
|
|
expect(stashOpenCount).toBe(0)
|
|
expect(controller.pendingInteraction).not.toBeNull()
|
|
|
|
// Step 3: Arrive at 75px away (<= 80px) -> triggers open!
|
|
engine.world.player.x = stash.x - 75
|
|
controller.tick()
|
|
expect(stashOpenCount).toBe(1)
|
|
expect(controller.pendingInteraction).toBeNull()
|
|
expect(controller.navTarget).toBeNull()
|
|
expect(statusElem.textContent).toContain('储物箱')
|
|
})
|
|
|
|
it('cancels pending stash interaction if user left-clicks ground or elsewhere', () => {
|
|
engine.world.player.x = stash.x - 200
|
|
engine.world.player.y = stash.y
|
|
controller.handleLeftClickWorld(stash.x, stash.y)
|
|
expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash })
|
|
|
|
// Click ground at an unrelated point
|
|
controller.handleLeftClickWorld(stash.x + 500, stash.y + 500)
|
|
expect(controller.activeClickType).toBe('ground')
|
|
expect(controller.pendingInteraction).toBeNull()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('3. Gold Mechanics Stress Testing', () => {
|
|
let panels: WorldPanelsHud
|
|
let inv: { gold: number }
|
|
|
|
beforeEach(() => {
|
|
panels = new WorldPanelsHud()
|
|
inv = { gold: 500_000 }
|
|
panels.stashGold = 100_000
|
|
})
|
|
|
|
it('deposit and withdraw valid amounts: character gold + stash gold remains strictly constant', () => {
|
|
const initialTotal = inv.gold + panels.stashGold // 600,000
|
|
|
|
const transferSequence = [10_000, 50_000, 100_000, 25_000, 1_000, 777]
|
|
for (const amt of transferSequence) {
|
|
const deposited = panels.depositGold(amt, inv)
|
|
expect(deposited).toBe(amt)
|
|
expect(inv.gold + panels.stashGold).toBe(initialTotal)
|
|
}
|
|
|
|
const withdrawSequence = [5_000, 20_000, 80_000, 500, 123]
|
|
for (const amt of withdrawSequence) {
|
|
const withdrawn = panels.withdrawGold(amt, inv)
|
|
expect(withdrawn).toBe(amt)
|
|
expect(inv.gold + panels.stashGold).toBe(initialTotal)
|
|
}
|
|
})
|
|
|
|
it('stress tests 100 random valid transactions preserving gold conservation law', () => {
|
|
const initialTotal = inv.gold + panels.stashGold // 600,000
|
|
let seed = 123456789
|
|
const nextRand = () => {
|
|
seed = (seed * 1664525 + 1013904223) >>> 0
|
|
return seed / 4294967296
|
|
}
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
if (nextRand() > 0.5) {
|
|
// Deposit random amount up to player's current gold
|
|
const amt = Math.floor(nextRand() * (inv.gold + 1))
|
|
panels.depositGold(amt, inv)
|
|
} else {
|
|
// Withdraw random amount up to stash's current gold
|
|
const amt = Math.floor(nextRand() * (panels.stashGold + 1))
|
|
panels.withdrawGold(amt, inv)
|
|
}
|
|
// Strict conservation invariant:
|
|
expect(inv.gold + panels.stashGold).toBe(initialTotal)
|
|
expect(inv.gold).toBeGreaterThanOrEqual(0)
|
|
expect(panels.stashGold).toBeGreaterThanOrEqual(0)
|
|
}
|
|
})
|
|
|
|
it('negative values (e.g. -500 gold): must be rejected or no-op without balance mutation', () => {
|
|
const initialInv = inv.gold
|
|
const initialStash = panels.stashGold
|
|
|
|
const negativeAmounts = [-1, -500, -10_000, -2_500_000, -1e9, Number.MIN_SAFE_INTEGER]
|
|
for (const neg of negativeAmounts) {
|
|
expect(panels.depositGold(neg, inv)).toBe(0)
|
|
expect(inv.gold).toBe(initialInv)
|
|
expect(panels.stashGold).toBe(initialStash)
|
|
|
|
expect(panels.withdrawGold(neg, inv)).toBe(0)
|
|
expect(inv.gold).toBe(initialInv)
|
|
expect(panels.stashGold).toBe(initialStash)
|
|
}
|
|
})
|
|
|
|
it('zero and -0 value transfers: must be rejected as no-op', () => {
|
|
expect(panels.depositGold(0, inv)).toBe(0)
|
|
expect(panels.depositGold(-0, inv)).toBe(0)
|
|
expect(panels.withdrawGold(0, inv)).toBe(0)
|
|
expect(panels.withdrawGold(-0, inv)).toBe(0)
|
|
expect(inv.gold).toBe(500_000)
|
|
expect(panels.stashGold).toBe(100_000)
|
|
})
|
|
|
|
it('exceeding player gold on deposit: deposits max available without creating negative player gold', () => {
|
|
inv.gold = 35_000
|
|
panels.stashGold = 50_000
|
|
|
|
const deposited = panels.depositGold(100_000, inv)
|
|
expect(deposited).toBe(35_000)
|
|
expect(inv.gold).toBe(0)
|
|
expect(panels.stashGold).toBe(85_000)
|
|
|
|
// Further deposit when player gold is 0 yields 0
|
|
expect(panels.depositGold(10_000, inv)).toBe(0)
|
|
expect(inv.gold).toBe(0)
|
|
expect(panels.stashGold).toBe(85_000)
|
|
})
|
|
|
|
it('exceeding stash gold on withdraw: withdraws max available without creating negative stash gold', () => {
|
|
panels.stashGold = 42_000
|
|
inv.gold = 10_000
|
|
|
|
const withdrawn = panels.withdrawGold(100_000, inv)
|
|
expect(withdrawn).toBe(42_000)
|
|
expect(panels.stashGold).toBe(0)
|
|
expect(inv.gold).toBe(52_000)
|
|
|
|
// Further withdrawal when stash gold is 0 yields 0
|
|
expect(panels.withdrawGold(10_000, inv)).toBe(0)
|
|
expect(panels.stashGold).toBe(0)
|
|
expect(inv.gold).toBe(52_000)
|
|
})
|
|
|
|
it('gold cap enforcement: character and stash gold cannot exceed 2,500,000', () => {
|
|
expect(STASH_GOLD_CAP).toBe(2_500_000)
|
|
expect(PLAYER_GOLD_CAP).toBe(2_500_000)
|
|
|
|
// Stash cap clamping on deposit
|
|
panels.stashGold = 2_450_000
|
|
inv.gold = 200_000
|
|
const dep1 = panels.depositGold(100_000, inv)
|
|
expect(dep1).toBe(50_000)
|
|
expect(panels.stashGold).toBe(2_500_000)
|
|
expect(inv.gold).toBe(150_000)
|
|
|
|
// Further deposits clamped to 0
|
|
expect(panels.depositGold(50_000, inv)).toBe(0)
|
|
expect(panels.stashGold).toBe(2_500_000)
|
|
expect(inv.gold).toBe(150_000)
|
|
|
|
// Player cap clamping on withdrawal
|
|
inv.gold = 2_480_000
|
|
panels.stashGold = 500_000
|
|
const w1 = panels.withdrawGold(100_000, inv)
|
|
expect(w1).toBe(20_000)
|
|
expect(inv.gold).toBe(2_500_000)
|
|
expect(panels.stashGold).toBe(480_000)
|
|
|
|
// Further withdrawals clamped to 0
|
|
expect(panels.withdrawGold(10_000, inv)).toBe(0)
|
|
expect(inv.gold).toBe(2_500_000)
|
|
expect(panels.stashGold).toBe(480_000)
|
|
})
|
|
|
|
it('boundary caps: handles both character and stash at 2,500,000 or both at 0', () => {
|
|
// Both at zero
|
|
inv.gold = 0
|
|
panels.stashGold = 0
|
|
expect(panels.depositGold(10_000, inv)).toBe(0)
|
|
expect(panels.withdrawGold(10_000, inv)).toBe(0)
|
|
expect(inv.gold).toBe(0)
|
|
expect(panels.stashGold).toBe(0)
|
|
|
|
// Both at maximum 2,500,000
|
|
inv.gold = 2_500_000
|
|
panels.stashGold = 2_500_000
|
|
expect(panels.depositGold(10_000, inv)).toBe(0)
|
|
expect(panels.withdrawGold(10_000, inv)).toBe(0)
|
|
expect(inv.gold).toBe(2_500_000)
|
|
expect(panels.stashGold).toBe(2_500_000)
|
|
})
|
|
|
|
it('infinity and extreme numbers: safely clamps or rejects without corrupting finite state', () => {
|
|
inv.gold = 300_000
|
|
panels.stashGold = 100_000
|
|
|
|
// Deposit Infinity: clamped to player's entire available gold
|
|
const depInf = panels.depositGold(Infinity, inv)
|
|
expect(depInf).toBe(300_000)
|
|
expect(inv.gold).toBe(0)
|
|
expect(panels.stashGold).toBe(400_000)
|
|
|
|
// Withdraw Infinity: clamped to stash's entire available gold
|
|
const wInf = panels.withdrawGold(Infinity, inv)
|
|
expect(wInf).toBe(400_000)
|
|
expect(inv.gold).toBe(400_000)
|
|
expect(panels.stashGold).toBe(0)
|
|
|
|
// Negative infinity: rejected as <= 0
|
|
expect(panels.depositGold(-Infinity, inv)).toBe(0)
|
|
expect(panels.withdrawGold(-Infinity, inv)).toBe(0)
|
|
expect(inv.gold).toBe(400_000)
|
|
expect(panels.stashGold).toBe(0)
|
|
|
|
// Number.MAX_SAFE_INTEGER
|
|
inv.gold = 50_000
|
|
const depMaxSafe = panels.depositGold(Number.MAX_SAFE_INTEGER, inv)
|
|
expect(depMaxSafe).toBe(50_000)
|
|
expect(inv.gold).toBe(0)
|
|
expect(panels.stashGold).toBe(50_000)
|
|
})
|
|
|
|
it('handles simulated UI button clicks for deposit and withdraw', () => {
|
|
inv.gold = 100_000
|
|
panels.stashGold = 200_000
|
|
|
|
const clickCallbacks = {
|
|
onClose: () => {},
|
|
onWaypointTeleport: () => {},
|
|
inventory: inv as any,
|
|
}
|
|
|
|
// Click deposit button (center of STASH_DEPOSIT_BTN_BOUNDS)
|
|
const depX = STASH_DEPOSIT_BTN_BOUNDS.x + 10
|
|
const depY = STASH_DEPOSIT_BTN_BOUNDS.y + 10
|
|
const handledDep = panels.handleLeftDockClick('stash', depX, depY, clickCallbacks)
|
|
expect(handledDep).toBe(true)
|
|
expect(panels.stashGold).toBe(250_000) // Default batch is 50,000
|
|
expect(inv.gold).toBe(50_000)
|
|
|
|
// Click withdraw button (center of STASH_WITHDRAW_BTN_BOUNDS)
|
|
const wX = STASH_WITHDRAW_BTN_BOUNDS.x + 10
|
|
const wY = STASH_WITHDRAW_BTN_BOUNDS.y + 10
|
|
const handledW = panels.handleLeftDockClick('stash', wX, wY, clickCallbacks)
|
|
expect(handledW).toBe(true)
|
|
expect(panels.stashGold).toBe(200_000)
|
|
expect(inv.gold).toBe(100_000)
|
|
})
|
|
})
|
|
|
|
describe('4. Dual-Dock Panel State Consistency', () => {
|
|
let hudCanvas: HTMLCanvasElement
|
|
let hud: HudManager
|
|
|
|
beforeEach(() => {
|
|
hudCanvas = {
|
|
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
|
|
|
|
hud = new HudManager(hudCanvas, {
|
|
onWaypointTeleport: () => {},
|
|
onToggleAutomap: () => {},
|
|
})
|
|
})
|
|
|
|
it('opening stash automatically opens left dock as "stash" and right dock as "inv"', () => {
|
|
expect(hud.leftPanel).toBe('none')
|
|
expect(hud.rightPanel).toBe('none')
|
|
expect(hud.inventory.visible).toBe(false)
|
|
|
|
hud.toggleLeftPanel('stash')
|
|
|
|
expect(hud.leftPanel).toBe('stash')
|
|
expect(hud.rightPanel).toBe('inv')
|
|
expect(hud.inventory.visible).toBe(true)
|
|
})
|
|
|
|
it('opening stash when inventory is already open preserves inventory on right dock', () => {
|
|
hud.toggleRightPanel('inv')
|
|
expect(hud.leftPanel).toBe('none')
|
|
expect(hud.rightPanel).toBe('inv')
|
|
|
|
hud.toggleLeftPanel('stash')
|
|
expect(hud.leftPanel).toBe('stash')
|
|
expect(hud.rightPanel).toBe('inv')
|
|
expect(hud.inventory.visible).toBe(true)
|
|
})
|
|
|
|
it('closing stash via toggle restores left dock to "none"', () => {
|
|
hud.toggleLeftPanel('stash')
|
|
expect(hud.leftPanel).toBe('stash')
|
|
|
|
hud.toggleLeftPanel('stash')
|
|
expect(hud.leftPanel).toBe('none')
|
|
})
|
|
|
|
it('closeAllPanels closes both stash and inventory simultaneously with clean state', () => {
|
|
hud.toggleLeftPanel('stash')
|
|
expect(hud.leftPanel).toBe('stash')
|
|
expect(hud.rightPanel).toBe('inv')
|
|
expect(hud.inventory.visible).toBe(true)
|
|
|
|
const closed = hud.closeAllPanels()
|
|
expect(closed).toBe(true)
|
|
expect(hud.leftPanel).toBe('none')
|
|
expect(hud.rightPanel).toBe('none')
|
|
expect(hud.inventory.visible).toBe(false)
|
|
expect(hud.charSheet.visible).toBe(false)
|
|
expect(hud.skillTree.visible).toBe(false)
|
|
})
|
|
|
|
it('switching from stash to another left panel (e.g. quest) cleanly deactivates stash', () => {
|
|
hud.toggleLeftPanel('stash')
|
|
expect(hud.leftPanel).toBe('stash')
|
|
|
|
hud.toggleLeftPanel('quest')
|
|
expect(hud.leftPanel).toBe('quest')
|
|
expect(hud.rightPanel).toBe('inv') // Inventory remains open on right dock
|
|
|
|
hud.toggleLeftPanel('quest')
|
|
expect(hud.leftPanel).toBe('none')
|
|
})
|
|
|
|
it('cycling through all left dock panels (char, quest, waypoint, stash) maintains dock invariant', () => {
|
|
const leftPanels: ('char' | 'quest' | 'waypoint' | 'stash')[] = ['char', 'quest', 'waypoint', 'stash']
|
|
for (const p of leftPanels) {
|
|
hud.toggleLeftPanel(p)
|
|
expect(hud.leftPanel).toBe(p)
|
|
if (p === 'char') expect(hud.charSheet.visible).toBe(true)
|
|
if (p === 'stash') {
|
|
expect(hud.rightPanel).toBe('inv')
|
|
expect(hud.inventory.visible).toBe(true)
|
|
}
|
|
}
|
|
hud.closeAllPanels()
|
|
expect(hud.leftPanel).toBe('none')
|
|
expect(hud.rightPanel).toBe('none')
|
|
})
|
|
})
|
|
})
|