683 lines
25 KiB
TypeScript
683 lines
25 KiB
TypeScript
/**
|
||
* 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()
|
||
}
|
||
})
|
||
})
|
||
})
|