diablo2-web/tests/client/challenger-hud-belts-exp-st...

683 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Adversarial Stress Test Suite for Batch 2 Gate (Challenger 2).
*
* Rigorously stress-tests:
* 1. Belt Dynamic Resizing & Potion Slotting:
* - Rapid equip/unequip cycles across belt capacities (1 to 4 rows).
* - Potion overflow retention, gravity shifting, and non-corruption.
* - Slot indexing bounds fuzzing and left-click pick-to-cursor vs right-click auto-drink separation.
* - Scroll (tsc, isc) manual placement, two-pass auto-slotting, and non-beltable rejection.
* 2. Experience Table Parity:
* - All 7 classes (Ama, Sor, Nec, Pal, Bar, Dru, Ass) across levels 1..99 against independent raw oracle.
* - Exact boundary and level cap invariants (lvl 1, 98->99, lvl 99 cap, invalid levels).
* 3. Mini-Panel & Death Screen:
* - 8-button (MP) vs 7-button (SP) mode toggling, boundaries, and button hit dispatching.
* - Death screen DC6 sprite centering across diverse aspect ratios and resolutions.
*/
import { describe, expect, it, vi } from 'vitest'
import {
BeltModel,
BELT_ROWS,
BELT_COLS,
BELT_BASE_X,
BELT_BASE_Y,
BELT_PITCH_X,
BELT_PITCH_Y,
BELT_CELL_SIZE,
BELT_POTION_CATALOG,
getBeltRowsForCode,
getBeltSlotBox,
itemToBeltPotion,
areBeltPotionsCompatible,
type BeltPotion,
} from '../../src/common/items/belt-model.ts'
import {
ControlBarHud,
MULTIPLAYER_MINIPANEL_ITEMS,
SINGLEPLAYER_MINIPANEL_ITEMS,
MINIPANEL_BOUNDS,
MINIPANEL_SINGLE_BOUNDS,
getMiniPanelTooltip,
} from '../../src/client/ui/control-bar.ts'
import {
D2_EXPERIENCE_TABLE_99,
getExperienceTable,
getExperienceForLevel,
getNextLevelExperience,
EXPERIENCE_TXT_RAW,
EXPERIENCE_TABLE_BY_CLASS,
} from '../../src/common/skills/formulas.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { HudModel } from '../../src/client/ui-model/hud-model.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { getViewportProfile, type ViewportResolution } from '../../src/client/view/viewport-profile.ts'
import type { ClientCommand, ItemData } from '../../src/netproto/index.ts'
// --- Helpers for Test Harness ---
function setupHudEnvironment() {
const world = new ClientWorld()
const hud = new HudManager()
const commands: ClientCommand[] = []
const model = new HudModel({ world, hudManager: hud, onCommand: (c) => commands.push(c) })
return { world, hud, model, commands }
}
function makeTestPotion(code: string, id: string | number): BeltPotion {
const pot = itemToBeltPotion({ code, id: String(id) })
if (!pot) throw new Error(`Failed to create test potion for code ${code}`)
return pot
}
describe('Challenger 2 Empirical Stress Test Harness — HUD, Belts & Experience', () => {
// =========================================================================
// SUITE 1: Belt Dynamic Resizing & Potion Slotting Stress Harness
// =========================================================================
describe('Suite 1: Belt Dynamic Resizing & Potion Slotting', () => {
it('1.1 Rapid equip/unequip fuzzing across 1,000 cycles preserves state without memory corruption', () => {
const belt = new BeltModel()
const beltCodes = [
null, // 1 row (default unequipped)
'lbl', // 2 rows (Sash)
'vbl', // 2 rows (Light Belt)
'mbl', // 3 rows (Belt)
'tbl', // 3 rows (Heavy Belt)
'hbl', // 4 rows (Plated Belt)
'zlb', // 4 rows (Demonhide Sash)
'zvb', // 4 rows (Sharkskin Belt)
'zmb', // 4 rows (Mesh Belt)
'ztb', // 4 rows (Battle Belt)
'zhb', // 4 rows (War Belt)
'ulc', // 4 rows (Spiderweb Sash)
'umc', // 4 rows (Mithril Coil)
'uhc', // 4 rows (Colossus Girdle)
]
// Populate initial 4x4 belt
belt.setEquippedBelt('hbl')
expect(belt.maxRows).toBe(4)
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
belt.setSlot(c, r, makeTestPotion('hp1', `pot-init-${r}-${c}`))
}
}
expect(belt.countTotalPotions()).toBe(16)
// Stress loop: 1,000 rapid switches
for (let cycle = 0; cycle < 1000; cycle++) {
const code = beltCodes[cycle % beltCodes.length]
belt.setEquippedBelt(code)
const expectedRows = getBeltRowsForCode(code)
expect(belt.maxRows).toBe(expectedRows)
expect(belt.grid.length).toBe(4)
for (let r = 0; r < 4; r++) {
expect(belt.grid[r]!.length).toBe(4)
}
// Active rows must follow expansion state
belt.lockedOpen = false
belt.hovered = false
expect(belt.activeRows).toBe(1)
expect(belt.isExpanded).toBe(false)
belt.lockedOpen = true
if (expectedRows > 1) {
expect(belt.isExpanded).toBe(true)
expect(belt.activeRows).toBe(expectedRows)
} else {
expect(belt.isExpanded).toBe(false)
expect(belt.activeRows).toBe(1)
}
}
// After 1,000 cycles, re-equipping a 4-row belt must reveal all 16 original potions intact
belt.setEquippedBelt('hbl')
expect(belt.maxRows).toBe(4)
expect(belt.countTotalPotions()).toBe(16)
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
const pot = belt.getSlot(c, r)
expect(pot).not.toBeNull()
expect(pot?.id).toBe(`pot-init-${r}-${c}`)
}
}
})
it('1.2 Potion overflow retention and gravity drain when unequipping from 4-row to 1-row', () => {
const belt = new BeltModel()
belt.setEquippedBelt('hbl') // 4 rows
// Put distinct potion codes in column 0: row 0 = hp1, row 1 = hp2, row 2 = hp3, row 3 = hp4
belt.setSlot(0, 0, makeTestPotion('hp1', 'pot-0'))
belt.setSlot(0, 1, makeTestPotion('hp2', 'pot-1'))
belt.setSlot(0, 2, makeTestPotion('hp3', 'pot-2'))
belt.setSlot(0, 3, makeTestPotion('hp4', 'pot-3'))
// Unequip belt -> capacity drops to 1 row
belt.setEquippedBelt(null)
expect(belt.maxRows).toBe(1)
// Only row 0 can be directly consumed via useSlot(0, 0)
// useSlot(0, 1) must be rejected because row 1 >= maxRows
expect(belt.useSlot(0, 1)).toBeNull()
expect(belt.useSlot(0, 2)).toBeNull()
expect(belt.useSlot(0, 3)).toBeNull()
// Consume row 0: consumes 'hp1'
const consumed1 = belt.useSlot(0, 0)
expect(consumed1?.code).toBe('hp1')
// Gravity shift: row 1 ('hp2') has shifted down into row 0!
expect(belt.getSlot(0, 0)?.code).toBe('hp2')
// Consume row 0 again: consumes 'hp2'
const consumed2 = belt.useSlot(0, 0)
expect(consumed2?.code).toBe('hp2')
// Gravity shift: row 2 ('hp3') has shifted down into row 0!
expect(belt.getSlot(0, 0)?.code).toBe('hp3')
// Consume row 0 again: consumes 'hp3'
const consumed3 = belt.useSlot(0, 0)
expect(consumed3?.code).toBe('hp3')
// Gravity shift: row 3 ('hp4') has shifted down into row 0!
expect(belt.getSlot(0, 0)?.code).toBe('hp4')
// Consume row 0 again: consumes 'hp4'
const consumed4 = belt.useSlot(0, 0)
expect(consumed4?.code).toBe('hp4')
// Now column 0 is completely empty
expect(belt.getSlot(0, 0)).toBeNull()
expect(belt.useSlot(0, 0)).toBeNull()
})
it('1.3 Fuzzes slot indexing across 5,000 bounds and boundary inputs with zero unhandled exceptions', () => {
const belt = new BeltModel()
belt.setEquippedBelt('hbl')
const potion = makeTestPotion('hp1', 'fuzz-pot')
// Explicit boundary coordinates
const testCoordinates: [number, number][] = [
[-1000, 0],
[0, -1000],
[-1, 0],
[0, -1],
[-1, -1],
[4, 0],
[0, 4],
[4, 4],
[15, 0],
[0, 15],
[16, 16],
[1000, 1000],
[0, 0],
[3, 0],
[0, 3],
[3, 3],
]
for (const [col, row] of testCoordinates) {
const isColValid = col >= 0 && col < BELT_COLS
const isRowValid = row >= 0 && row < BELT_ROWS
if (isColValid && isRowValid) {
expect(belt.setSlot(col, row, potion)).toBe(true)
expect(belt.getSlot(col, row)).toStrictEqual(potion)
expect(belt.setSlot(col, row, null)).toBe(true)
expect(belt.getSlot(col, row)).toBeNull()
} else {
expect(belt.setSlot(col, row, potion)).toBe(false)
expect(belt.getSlot(col, row)).toBeNull()
expect(belt.useSlot(col, row)).toBeNull()
expect(belt.placePotion(potion, col, row)).toBe(potion)
}
}
// Fuzz 5,000 randomized integers
for (let i = 0; i < 5000; i++) {
const randCol = Math.floor(Math.random() * 200) - 100
const randRow = Math.floor(Math.random() * 200) - 100
const isColValid = randCol >= 0 && randCol < BELT_COLS
const isRowValid = randRow >= 0 && randRow < belt.maxRows
if (isColValid && isRowValid) {
expect(belt.setSlot(randCol, randRow, potion)).toBe(true)
expect(belt.getSlot(randCol, randRow)?.code).toBe(potion.code)
belt.setSlot(randCol, randRow, null)
} else {
expect(belt.setSlot(randCol, randRow, potion)).toBe(false)
expect(belt.placePotion(potion, randCol, randRow)).toBe(potion)
}
}
})
it('1.4 Left-click interaction picks up potion to cursor without auto-drinking, right-click drinks', () => {
const { world, hud, model, commands } = setupHudEnvironment()
hud.commandSink = model
// Put an online item into belt slot 0
const onlinePotion = {
id: 777,
itemId: 777,
opcode: 0x9c,
code: 'hp1',
rawCode: 'hp1 ',
action: 4,
category: 1,
location: 2, // belt
container: 'belt',
gridX: 0,
gridY: 0,
bodyLoc: 0,
equippedSlot: 0,
storePage: 1,
storagePage: 1,
worldX: 0,
worldY: 0,
} as unknown as ItemData
world.apply({ type: 'ItemEvent', action: 4, item: onlinePotion, container: 'belt' })
model.syncFromWorld()
// Verify the belt slot is populated in HUD
const beltPotion = hud.belt.getSlot(0, 0)
expect(beltPotion).not.toBeNull()
expect(beltPotion?.code).toBe('hp1')
// Get slot 0 bounding box coordinates
const box = getBeltSlotBox(0, 0)
const clickX = box.x + 10
const clickY = box.y + 10
// Spy on drinkBeltSlot to guarantee left-click NEVER drinks
const drinkSpy = vi.spyOn(hud, 'drinkBeltSlot')
// --- Interaction 1: Left-click on occupied slot with empty cursor ---
hud.inventory.cursorItem = null
hud.handleHudMouseDown({
button: 0, // Left click
shiftKey: false,
clientX: clickX,
clientY: clickY,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as MouseEvent)
// Must NOT drink
expect(drinkSpy).not.toHaveBeenCalled()
// Must emit BeltRemove (0x24) to pick up the potion to cursor
expect(commands).toHaveLength(1)
expect(commands[0]).toEqual({ type: 'BeltRemove', itemId: 777 })
commands.length = 0
drinkSpy.mockClear()
// --- Interaction 2: Left-click with cursor item onto belt slot ---
const cursorItem = { id: 888, code: 'mp1' } as any
hud.inventory.cursorItem = cursorItem
hud.handleHudMouseDown({
button: 0, // Left click
shiftKey: false,
clientX: clickX,
clientY: clickY,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as MouseEvent)
expect(drinkSpy).not.toHaveBeenCalled()
// Occupied slot + cursor -> BeltSwap (0x25)
expect(commands).toHaveLength(1)
expect(commands[0]).toEqual({ type: 'BeltSwap', cursorItemId: 888, beltItemId: 777 })
commands.length = 0
drinkSpy.mockClear()
// --- Interaction 3: Right-click on belt slot -> must DRINK potion ---
hud.handleHudMouseDown({
button: 2, // Right click
shiftKey: false,
clientX: clickX,
clientY: clickY,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as MouseEvent)
expect(drinkSpy).toHaveBeenCalledWith(0, false)
expect(commands).toHaveLength(1)
expect(commands[0]).toEqual({ type: 'BeltUse', itemId: 777, shift: false })
commands.length = 0
drinkSpy.mockClear()
// --- Interaction 4: Press key '1' -> must DRINK slot 0 ---
hud.handleKeyDown({
key: '1',
shiftKey: false,
preventDefault: () => {},
} as unknown as KeyboardEvent)
expect(drinkSpy).toHaveBeenCalledWith(0, false)
expect(commands).toHaveLength(1)
expect(commands[0]).toEqual({ type: 'BeltUse', itemId: 777, shift: false })
})
it('1.5 Scrolls (tsc, isc) auto-slotting, column segregation and non-beltable rejection', () => {
const belt = new BeltModel()
belt.setEquippedBelt('hbl') // 4 rows
const tsc1 = itemToBeltPotion({ code: 'tsc', id: 'tp-1' })!
const tsc2 = itemToBeltPotion({ code: 'tsc', id: 'tp-2' })!
const isc1 = itemToBeltPotion({ code: 'isc', id: 'id-1' })!
const isc2 = itemToBeltPotion({ code: 'isc', id: 'id-2' })!
const hp1 = itemToBeltPotion({ code: 'hp1', id: 'hp-1' })!
expect(tsc1.kind).toBe('scroll')
expect(tsc1.invFile).toBe('invbsc')
expect(isc1.kind).toBe('scroll')
expect(isc1.invFile).toBe('invrsc')
// Step A: Auto-slot first tsc -> goes to col 0, row 0
const res1 = belt.autoPlacePotionSlot(tsc1)
expect(res1).toEqual({ col: 0, row: 0, potion: tsc1 })
// Step B: Auto-slot second tsc -> matches col 0 family, goes to col 0, row 1
const res2 = belt.autoPlacePotionSlot(tsc2)
expect(res2).toEqual({ col: 0, row: 1, potion: tsc2 })
// Step C: Auto-slot isc -> does NOT match col 0 (tsc != isc), goes to col 1, row 0
const res3 = belt.autoPlacePotionSlot(isc1)
expect(res3).toEqual({ col: 1, row: 0, potion: isc1 })
// Step D: Auto-slot second isc -> matches col 1 family, goes to col 1, row 1
const res4 = belt.autoPlacePotionSlot(isc2)
expect(res4).toEqual({ col: 1, row: 1, potion: isc2 })
// Step E: Auto-slot healing potion -> goes to col 2, row 0
const res5 = belt.autoPlacePotionSlot(hp1)
expect(res5).toEqual({ col: 2, row: 0, potion: hp1 })
// Verify slot placements
expect(belt.getSlot(0, 0)?.code).toBe('tsc')
expect(belt.getSlot(0, 1)?.code).toBe('tsc')
expect(belt.getSlot(1, 0)?.code).toBe('isc')
expect(belt.getSlot(1, 1)?.code).toBe('isc')
expect(belt.getSlot(2, 0)?.code).toBe('hp1')
// Step F: Verify strict rejection of non-beltable items
const invalidItems = [
{ code: 'gpm' }, // Strangling Gas Potion (throwing weapon)
{ code: 'opm' }, // Fulminating Potion (throwing weapon)
{ code: 'gpl' }, // Choking Gas Potion
{ code: 'opl' }, // Exploding Potion
{ code: 'gps' }, // Rancid Gas Potion
{ code: 'ops' }, // Oil Potion
{ code: 'axe', kind: 'weapon' },
{ code: 'swor', kind: 'weapon' },
{ code: 'shld', kind: 'armor' },
{ code: 'key' },
{ code: 'r01' }, // El Rune
{ code: 'gcv' }, // Chipped Amethyst
]
for (const item of invalidItems) {
expect(itemToBeltPotion(item)).toBeNull()
expect(belt.autoPlacePotion(item)).toBe(false)
expect(belt.setSlot(3, 0, item)).toBe(false)
}
})
})
// =========================================================================
// SUITE 2: Experience Table Parity Across All 7 Classes × 99 Levels
// =========================================================================
describe('Suite 2: Experience Table Parity Across All 7 Classes × 99 Levels', () => {
// Independent clean-room oracle parser directly parsing the raw TS text constant
function buildExperienceOracle(): Record<string, number[]> {
const lines = EXPERIENCE_TXT_RAW.split(/\r?\n/).filter((l) => l.trim().length > 0)
const header = lines[0]!.split('\t').map((h) => h.trim().toLowerCase())
const oracle: Record<string, number[]> = {
amazon: new Array(100).fill(0),
sorceress: new Array(100).fill(0),
necromancer: new Array(100).fill(0),
paladin: new Array(100).fill(0),
barbarian: new Array(100).fill(0),
druid: new Array(100).fill(0),
assassin: new Array(100).fill(0),
}
for (let i = 1; i < lines.length; i++) {
const parts = lines[i]!.split('\t')
const lvlStr = parts[0]!.trim()
if (lvlStr === 'MaxLvl') continue
const rowLvl = parseInt(lvlStr, 10)
if (Number.isNaN(rowLvl)) continue
const charLvl = rowLvl + 1
if (charLvl >= 1 && charLvl <= 99) {
for (const cls of Object.keys(oracle)) {
const colIdx = header.indexOf(cls)
if (colIdx !== -1) {
oracle[cls]![charLvl] = parseInt(parts[colIdx] ?? '0', 10)
}
}
}
}
return oracle
}
const oracle = buildExperienceOracle()
const classAliases: [string, string][] = [
['amazon', 'Ama'],
['sorceress', 'Sor'],
['necromancer', 'Nec'],
['paladin', 'Pal'],
['barbarian', 'Bar'],
['druid', 'Dru'],
['assassin', 'Ass'],
]
it('2.1 Verifies all 7 classes across all 99 levels against independent raw oracle (693 assertions)', () => {
for (const [canonicalName, alias] of classAliases) {
const expectedTable = oracle[canonicalName]!
expect(expectedTable).toBeDefined()
expect(expectedTable.length).toBe(100)
const tableFromFn = getExperienceTable(canonicalName)
const tableFromAlias = getExperienceTable(alias)
for (let lvl = 1; lvl <= 99; lvl++) {
const expectedXp = expectedTable[lvl]!
expect(tableFromFn[lvl]).toBe(expectedXp)
expect(tableFromAlias[lvl]).toBe(expectedXp)
expect(getExperienceForLevel(lvl, canonicalName)).toBe(expectedXp)
expect(getExperienceForLevel(lvl, alias)).toBe(expectedXp)
}
}
})
it('2.2 Boundary conditions, exact deltas and level cap enforcement', () => {
// Level 1 baseline
expect(getExperienceForLevel(1)).toBe(0)
expect(getNextLevelExperience(1)).toBe(500)
// Level 2 baseline
expect(getExperienceForLevel(2)).toBe(500)
expect(getNextLevelExperience(2)).toBe(1500)
// Key milestone levels
expect(getExperienceForLevel(17)).toBe(275207)
expect(getExperienceForLevel(30)).toBe(4663553)
expect(getExperienceForLevel(90)).toBe(1618470619)
// Level 98 -> 99 delta
const xp98 = getExperienceForLevel(98)
const xp99 = getExperienceForLevel(99)
expect(xp98).toBe(3229426756)
expect(xp99).toBe(3520485254)
expect(xp99 - xp98).toBe(291058498) // Exact 1.13c delta
// Level 98 next level experience
expect(getNextLevelExperience(98)).toBe(3520485254)
// Level 99 CAP: next level experience remains 3,520,485,254 and does not overflow or become NaN
expect(getNextLevelExperience(99)).toBe(3520485254)
expect(getNextLevelExperience(100)).toBe(3520485254)
// Degenerate / invalid inputs
expect(getExperienceForLevel(0)).toBe(0)
expect(getExperienceForLevel(-10)).toBe(0)
expect(getExperienceForLevel(100)).toBe(3520485254)
expect(getExperienceForLevel(999)).toBe(3520485254)
})
})
// =========================================================================
// SUITE 3: Mini-Panel & Death Screen Layout & Responsiveness
// =========================================================================
describe('Suite 3: Mini-Panel & Death Screen', () => {
it('3.1 Multiplayer (8 buttons) vs Single-Player (7 buttons) rapid toggle & click dispatch', () => {
const hud = new ControlBarHud()
const onMiniPanelAction = vi.fn()
const dummyState = {
level: 80,
xp: 1000000,
currentLevelXp: 0,
nextLevelXp: 2000000,
stamina: 100,
maxStamina: 100,
isRunning: false,
unspentStatPoints: 0,
unspentSkillPoints: 0,
}
// 500 rapid toggles between MP and SP
for (let i = 0; i < 500; i++) {
hud.isMultiplayer = i % 2 === 0
hud.miniPanelOpen = true
if (hud.isMultiplayer) {
expect(hud.currentMiniPanelItems.length).toBe(8)
expect(hud.currentMiniPanelBounds).toEqual(MINIPANEL_BOUNDS)
expect(hud.currentButtonStartX).toBe(320)
expect(hud.currentMiniPanelItems.map((m) => m.action)).toContain('party')
} else {
expect(hud.currentMiniPanelItems.length).toBe(7)
expect(hud.currentMiniPanelBounds).toEqual(MINIPANEL_SINGLE_BOUNDS)
expect(hud.currentButtonStartX).toBe(330)
expect(hud.currentMiniPanelItems.map((m) => m.action)).not.toContain('party')
}
}
// Test all 8 button hit dispatches in Multiplayer mode
hud.isMultiplayer = true
hud.miniPanelOpen = true
const mpActions = MULTIPLAYER_MINIPANEL_ITEMS.map((m) => m.action)
for (let idx = 0; idx < mpActions.length; idx++) {
onMiniPanelAction.mockClear()
const clickX = 320 + idx * 20 + 10
const clickY = 535
hud.handleMouseMove(clickX, clickY)
expect(hud.hoveredElement).toBe(mpActions[idx])
hud.handleClick(clickX, clickY, dummyState, { onToggleRun: vi.fn(), onMiniPanelAction })
expect(onMiniPanelAction).toHaveBeenCalledWith(mpActions[idx])
}
// Test all 7 button hit dispatches in Single-Player mode
hud.isMultiplayer = false
hud.miniPanelOpen = true
const spActions = SINGLEPLAYER_MINIPANEL_ITEMS.map((m) => m.action)
for (let idx = 0; idx < spActions.length; idx++) {
onMiniPanelAction.mockClear()
const clickX = 330 + idx * 20 + 10
const clickY = 535
hud.handleMouseMove(clickX, clickY)
expect(hud.hoveredElement).toBe(spActions[idx])
hud.handleClick(clickX, clickY, dummyState, { onToggleRun: vi.fn(), onMiniPanelAction })
expect(onMiniPanelAction).toHaveBeenCalledWith(spActions[idx])
}
})
it('3.2 Death screen DC6 sprite positioning across diverse resolutions and aspect ratios', () => {
const resolutions: ViewportResolution[] = [
'800x600', // 4:3 standard
'1024x768', // 4:3
'1068x600', // wide
'1280x720', // 16:9 widescreen
]
const { hud } = setupHudEnvironment()
hud.isDead = true
for (const res of resolutions) {
const profile = getViewportProfile(res)
hud.setViewportProfile(profile)
const expectedCenterX = Math.round(profile.width / 2)
const expectedCenterY = Math.round(profile.height / 2)
const expectedYHardcore = expectedCenterY - 94
const expectedYInst = expectedCenterY - 46
// Mock canvas context
const drawImageMock = vi.fn()
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
drawImage: drawImageMock,
fillRect: vi.fn(),
fillText: vi.fn(),
} as any
hud.ctx = mockCtx
// Mock death DC6 images with distinct widths and heights
const mockHardcoreImg = { width: 340, height: 40 } as HTMLImageElement
const mockInstImg = { width: 420, height: 24 } as HTMLImageElement
;(hud as any).images.set('deathHardcore', mockHardcoreImg)
;(hud as any).images.set('deathInst', mockInstImg)
hud.render()
// Verify deathHardcore image drawn centered at (centerX - width/2, expectedYHardcore)
expect(drawImageMock).toHaveBeenCalledWith(
mockHardcoreImg,
expectedCenterX - Math.round(mockHardcoreImg.width / 2),
expectedYHardcore,
)
// Verify deathInst image drawn centered at (centerX - width/2, expectedYInst)
expect(drawImageMock).toHaveBeenCalledWith(
mockInstImg,
expectedCenterX - Math.round(mockInstImg.width / 2),
expectedYInst,
)
// Test fallback text rendering when images are absent
;(hud as any).images.delete('deathHardcore')
;(hud as any).images.delete('deathInst')
const fontSpy = vi.spyOn(hud.font, 'drawText')
hud.render()
expect(fontSpy).toHaveBeenCalledWith(
mockCtx,
'YOU HAVE DIED',
expectedCenterX,
expectedYHardcore + 18,
expect.objectContaining({ align: 'center', color: 'red' }),
)
expect(fontSpy).toHaveBeenCalledWith(
mockCtx,
'PRESS ESC TO CONTINUE.',
expectedCenterX,
expectedYInst + 18,
expect.objectContaining({ align: 'center', color: 'gold' }),
)
fontSpy.mockRestore()
}
})
})
})