diablo2-web/tests/client/core-hud-parity.test.ts

306 lines
11 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.

/**
* Parity Test Suite for Core HUD, Mini-Panel, Belts, Death Screen & Experience Table.
* Covers Issues #636, #637, #638, #639, #640, #641.
*/
import { describe, expect, it, vi } from 'vitest'
import {
ControlBarHud,
MULTIPLAYER_MINIPANEL_ITEMS,
SINGLEPLAYER_MINIPANEL_ITEMS,
getMiniPanelTooltip,
MINIPANEL_BOUNDS,
MINIPANEL_SINGLE_BOUNDS,
} from '../../src/client/ui/control-bar.ts'
import {
BeltModel,
BELT_POTION_CATALOG,
BELT_CODE_TO_ROWS,
getBeltRowsForCode,
getBeltSlotBox,
itemToBeltPotion,
areBeltPotionsCompatible,
BELT_BASE_X,
BELT_BASE_Y,
BELT_PITCH_X,
BELT_PITCH_Y,
BELT_CELL_SIZE,
} from '../../src/common/items/belt-model.ts'
import {
D2_EXPERIENCE_TABLE_99,
getExperienceForLevel,
getNextLevelExperience,
EXPERIENCE_TABLE_BY_CLASS,
parseExperienceTable,
EXPERIENCE_TXT_RAW,
} from '../../src/common/skills/formulas.ts'
describe('Core HUD Parity Suite (Issues #636–#641)', () => {
describe('Issue #636: Mini-Panel 8-Button Layout & Authentic Parity', () => {
it('defines 8 authentic buttons for multiplayer and 7 for single-player', () => {
expect(MULTIPLAYER_MINIPANEL_ITEMS.length).toBe(8)
expect(SINGLEPLAYER_MINIPANEL_ITEMS.length).toBe(7)
const mpActions = MULTIPLAYER_MINIPANEL_ITEMS.map((m) => m.action)
expect(mpActions).toEqual(['char', 'inv', 'skill', 'party', 'automap', 'msg', 'quest', 'menu'])
const spActions = SINGLEPLAYER_MINIPANEL_ITEMS.map((m) => m.action)
expect(spActions).toEqual(['char', 'inv', 'skill', 'automap', 'msg', 'quest', 'menu'])
// Waypoint / stash must be eliminated
expect(mpActions).not.toContain('waypoint')
expect(spActions).not.toContain('waypoint')
})
it('indexes icon frames as iconGroup * 2 + (isPressed ? 1 : 0)', () => {
for (const item of MULTIPLAYER_MINIPANEL_ITEMS) {
expect(typeof item.iconGroup).toBe('number')
const normalFrame = item.iconGroup * 2
const pressedFrame = item.iconGroup * 2 + 1
expect(pressedFrame).toBe(normalFrame + 1)
}
// Party iconGroup is 3
const party = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'party')!
expect(party.iconGroup).toBe(3)
// Game Menu iconGroup is 7
const menu = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'menu')!
expect(menu.iconGroup).toBe(7)
})
it('formats tooltips with canonical .tbl keys and minipanelspecial hotkey format', () => {
const charItem = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'char')!
expect(getMiniPanelTooltip(charItem, 'ENG')).toBe('Character (A)')
expect(getMiniPanelTooltip(charItem, 'CHI')).toBe('人物(A)')
const invItem = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'inv')!
expect(getMiniPanelTooltip(invItem, 'ENG')).toBe('Inventory (I)')
expect(getMiniPanelTooltip(invItem, 'CHI')).toBe('物品(I)')
// Game Menu already has (Esc) in its tbl string, does not double-append
const menuItem = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'menu')!
expect(getMiniPanelTooltip(menuItem, 'ENG')).toBe('Game Menu (Esc)')
expect(getMiniPanelTooltip(menuItem, 'CHI')).toBe('遊戲選單(Esc)')
})
it('handles mouse clicks and hover correctly across 20px button pitch', () => {
const hud = new ControlBarHud()
hud.miniPanelOpen = true
hud.isMultiplayer = true
const onMiniPanelAction = vi.fn()
const state = {
level: 1,
xp: 0,
currentLevelXp: 0,
nextLevelXp: 500,
stamina: 100,
maxStamina: 100,
isRunning: false,
unspentStatPoints: 0,
unspentSkillPoints: 0,
}
// Button 0 (char): x = 320..339, y = 525..544
hud.handleMouseMove(325, 530)
expect(hud.hoveredElement).toBe('char')
hud.handleClick(325, 530, state, { onToggleRun: vi.fn(), onMiniPanelAction })
expect(onMiniPanelAction).toHaveBeenCalledWith('char')
// Button 3 (party): x = 380..399
hud.handleMouseMove(385, 530)
expect(hud.hoveredElement).toBe('party')
hud.handleClick(385, 530, state, { onToggleRun: vi.fn(), onMiniPanelAction })
expect(onMiniPanelAction).toHaveBeenCalledWith('party')
// Button 7 (menu): x = 460..479
hud.handleMouseMove(465, 530)
expect(hud.hoveredElement).toBe('menu')
hud.handleClick(465, 530, state, { onToggleRun: vi.fn(), onMiniPanelAction })
expect(onMiniPanelAction).toHaveBeenCalledWith('menu')
})
})
describe('Issue #637: Authentic Death Presentation Parity', () => {
it('calculates exact 1.13c centered screen offsets (W/2, H/2 - 94) and (W/2, H/2 - 46)', () => {
const W = 800
const H = 600
const centerX = Math.round(W / 2)
const centerY = Math.round(H / 2)
const yHardcore = centerY - 94 // 300 - 94 = 206
const yInst = centerY - 46 // 300 - 46 = 254
expect(centerX).toBe(400)
expect(yHardcore).toBe(206)
expect(yInst).toBe(254)
})
})
describe('Issue #638: Belt Slot Grid Indexing & Left-Click Interaction', () => {
it('accesses belt slot using getSlot(col, row) without inverted indexing', () => {
const belt = new BeltModel()
const potion = itemToBeltPotion({ code: 'hp1', id: 'pot-hp1' })!
// Put potion in col 2, row 1
belt.grid[1]![2] = potion
expect(belt.getSlot(2, 1)).toBe(potion)
expect(belt.getSlot(1, 2)).toBeNull()
})
})
describe('Issue #639: Experience Table & Experience.txt 1.13c Parity', () => {
it('parses raw Experience.txt correctly into 100 levels (indices 0..99)', () => {
const parsed = parseExperienceTable(EXPERIENCE_TXT_RAW)
expect(parsed.maxLevel).toBe(99)
expect(Object.keys(parsed.tableByClass).length).toBe(7)
// All classes identical progression
for (const cls of ['amazon', 'sorceress', 'necromancer', 'paladin', 'barbarian', 'druid', 'assassin']) {
const table = parsed.tableByClass[cls]!
expect(table.length).toBe(100)
expect(table[1]).toBe(0)
expect(table[2]).toBe(500)
expect(table[17]).toBe(275207)
expect(table[30]).toBe(4663553)
expect(table[90]).toBe(1618470619)
expect(table[99]).toBe(3520485254)
}
})
it('getExperienceForLevel and getNextLevelExperience conform to 1.13c', () => {
expect(getExperienceForLevel(1)).toBe(0)
expect(getExperienceForLevel(2)).toBe(500)
expect(getExperienceForLevel(17)).toBe(275207)
expect(getExperienceForLevel(30)).toBe(4663553)
expect(getExperienceForLevel(90)).toBe(1618470619)
expect(getExperienceForLevel(99)).toBe(3520485254)
expect(getNextLevelExperience(1)).toBe(500)
expect(getNextLevelExperience(16)).toBe(275207)
expect(getNextLevelExperience(29)).toBe(4663553)
expect(getNextLevelExperience(89)).toBe(1618470619)
expect(getNextLevelExperience(98)).toBe(3520485254)
expect(getNextLevelExperience(99)).toBe(3520485254)
})
})
describe('Issue #640: Belts.txt Dynamic Rows & Coordinate Parity', () => {
it('conforms to 1.13c belts.txt coordinates (base x=423, base y=562, pitch x=31, pitch y=32, box=29x29)', () => {
expect(BELT_BASE_X).toBe(423)
expect(BELT_BASE_Y).toBe(562)
expect(BELT_PITCH_X).toBe(31)
expect(BELT_PITCH_Y).toBe(32)
expect(BELT_CELL_SIZE).toBe(29)
// Row 0 box
const box0 = getBeltSlotBox(0, 0)
expect(box0).toEqual({ x: 423, y: 562, width: 29, height: 29 })
// Row 1 box
const box1 = getBeltSlotBox(0, 1)
expect(box1).toEqual({ x: 423, y: 530, width: 29, height: 29 })
// Row 3 box (top row of 4x4 belt)
const box3 = getBeltSlotBox(3, 3)
expect(box3).toEqual({ x: 423 + 3 * 31, y: 562 - 3 * 32, width: 29, height: 29 })
})
it('dynamically adapts maxRows according to equipped belt code', () => {
const belt = new BeltModel()
// Default (no equipped belt): 1 row (4 boxes)
belt.setEquippedBelt(null)
expect(belt.maxRows).toBe(1)
expect(getBeltRowsForCode(null)).toBe(1)
// Sash (lbl): 2 rows (8 boxes)
belt.setEquippedBelt('lbl')
expect(belt.maxRows).toBe(2)
// Light Belt (vbl): 2 rows (8 boxes)
belt.setEquippedBelt('vbl')
expect(belt.maxRows).toBe(2)
// Belt (mbl): 3 rows (12 boxes)
belt.setEquippedBelt('mbl')
expect(belt.maxRows).toBe(3)
// Heavy Belt (tbl): 3 rows (12 boxes)
belt.setEquippedBelt('tbl')
expect(belt.maxRows).toBe(3)
// Girdle (hbl): 4 rows (16 boxes)
belt.setEquippedBelt('hbl')
expect(belt.maxRows).toBe(4)
// Exceptional & Elite belts: 4 rows
for (const code of ['zlb', 'zvb', 'zmb', 'ztb', 'zhb', 'ulc', 'uvc', 'umc', 'utc', 'uhc']) {
belt.setEquippedBelt(code)
expect(belt.maxRows).toBe(4)
}
})
it('slotAtPoint hit tests only visible rows according to maxRows', () => {
const belt = new BeltModel()
belt.setEquippedBelt('lbl') // 2 rows
belt.lockedOpen = true // expanded to maxRows (2)
// Row 0 col 0
expect(belt.slotAtPoint(425, 565)).toEqual({ row: 0, col: 0 })
// Row 1 col 0
expect(belt.slotAtPoint(425, 535)).toEqual({ row: 1, col: 0 })
// Row 2 col 0 (outside 2-row belt)
expect(belt.slotAtPoint(425, 500)).toBeNull()
})
})
describe('Issue #641: Belt Potion Assets & Scroll Eligibility', () => {
it('maps authentic invFile names per 1.13c misc.txt', () => {
expect(BELT_POTION_CATALOG['vps']?.invFile).toBe('invwps') // Stamina
expect(BELT_POTION_CATALOG['yps']?.invFile).toBe('invnps') // Antidote
expect(BELT_POTION_CATALOG['wms']?.invFile).toBe('invyps') // Thawing
expect(BELT_POTION_CATALOG['rvs']?.invFile).toBe('invvps') // Rejuv
expect(BELT_POTION_CATALOG['rvl']?.invFile).toBe('invvpl') // Full Rejuv
expect(BELT_POTION_CATALOG['tsc']?.invFile).toBe('invbsc') // Town Portal Scroll
expect(BELT_POTION_CATALOG['isc']?.invFile).toBe('invrsc') // Identify Scroll
})
it('allows scrolls (tsc/isc) to be placed into belt slots', () => {
const tsc = itemToBeltPotion({ code: 'tsc', id: 'scroll-tp' })
expect(tsc).not.toBeNull()
expect(tsc?.kind).toBe('scroll')
expect(tsc?.invFile).toBe('invbsc')
const isc = itemToBeltPotion({ code: 'isc', id: 'scroll-id' })
expect(isc).not.toBeNull()
expect(isc?.kind).toBe('scroll')
expect(isc?.invFile).toBe('invrsc')
const belt = new BeltModel()
expect(belt.setSlot(0, 0, tsc)).toBe(true)
expect(belt.getSlot(0, 0)?.code).toBe('tsc')
})
it('rejects weapon throwing potions and invalid non-beltable items', () => {
for (const throwPot of ['gpm', 'opm', 'gpl', 'opl', 'gps', 'ops']) {
expect(itemToBeltPotion({ code: throwPot })).toBeNull()
}
expect(itemToBeltPotion({ code: 'axe' })).toBeNull()
expect(itemToBeltPotion({ code: 'key' })).toBeNull()
})
it('requires scrolls to have identical code for column compatibility', () => {
const tsc1 = itemToBeltPotion({ code: 'tsc' })!
const tsc2 = itemToBeltPotion({ code: 'tsc' })!
const isc = itemToBeltPotion({ code: 'isc' })!
const hp = itemToBeltPotion({ code: 'hp1' })!
expect(areBeltPotionsCompatible(tsc1, tsc2)).toBe(true)
expect(areBeltPotionsCompatible(tsc1, isc)).toBe(false)
expect(areBeltPotionsCompatible(tsc1, hp)).toBe(false)
})
})
})