429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import { CANONICAL_TOWN_STASHES, type TownStashEntity } from '../src/common/world/scene-population.ts'
|
|
import {
|
|
WorldPanelsHud,
|
|
STASH_GOLD_CAP,
|
|
PLAYER_GOLD_CAP,
|
|
} from '../src/client/ui/world-panels.ts'
|
|
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
|
import { GameEngine } from '../src/server/engine/engine.ts'
|
|
import { createIsoTerrain } from '../src/common/world/iso-terrain.ts'
|
|
import { WaypointNetwork } from '../src/common/world/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('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('does not intercept bottom keyhole ornament clicks as non-authentic deposit/withdraw buttons', () => {
|
|
inv.gold = 100_000
|
|
panels.stashGold = 200_000
|
|
|
|
const clickCallbacks = {
|
|
onClose: () => {},
|
|
onWaypointTeleport: () => {},
|
|
inventory: inv as any,
|
|
}
|
|
|
|
// Click bottom keyhole area where non-authentic deposit/withdraw buttons used to be
|
|
panels.handleLeftDockClick('stash', 80 + 84, 60 + 388, clickCallbacks)
|
|
expect(panels.stashGold).toBe(200_000)
|
|
expect(inv.gold).toBe(100_000)
|
|
|
|
panels.handleLeftDockClick('stash', 80 + 168, 60 + 388, clickCallbacks)
|
|
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')
|
|
})
|
|
})
|
|
})
|