306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|
||
})
|