259 lines
9.9 KiB
TypeScript
259 lines
9.9 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,
|
|
} 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('stash gold cap is the D2Common #11060 constant (2,500,000); gold transfers are server-driven', () => {
|
|
expect(STASH_GOLD_CAP).toBe(2_500_000)
|
|
// WorldPanelsHud no longer mutates gold locally: deposit/withdraw are sent as
|
|
// ClickButton commands by HudModel and the balance arrives via server stat updates.
|
|
expect((panels as unknown as Record<string, unknown>)['depositGold']).toBeUndefined()
|
|
expect((panels as unknown as Record<string, unknown>)['withdrawGold']).toBeUndefined()
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|
|
})
|