192 lines
6.8 KiB
TypeScript
192 lines
6.8 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import {
|
|
WorldPanelsHud,
|
|
HIRE_DIALOG_WIDTH,
|
|
HIRE_DIALOG_HEIGHT,
|
|
HIRE_CONFIRM_WIDTH,
|
|
HIRE_CONFIRM_HEIGHT,
|
|
} from '../../src/client/ui/world-panels.ts'
|
|
import { resolveHirelingCandidates } from '../../src/common/units/hireling-table.ts'
|
|
|
|
function createMockCtx() {
|
|
const drawnTexts: string[] = []
|
|
let fillRectCount = 0
|
|
let strokeRectCount = 0
|
|
return {
|
|
drawnTexts,
|
|
get fillRectCount() {
|
|
return fillRectCount
|
|
},
|
|
get strokeRectCount() {
|
|
return strokeRectCount
|
|
},
|
|
fillStyle: '',
|
|
strokeStyle: '',
|
|
fillRect: vi.fn(() => fillRectCount++),
|
|
strokeRect: vi.fn(() => strokeRectCount++),
|
|
fillText: vi.fn((text: string) => drawnTexts.push(text)),
|
|
drawImage: vi.fn(),
|
|
save: vi.fn(),
|
|
restore: vi.fn(),
|
|
} as unknown as CanvasRenderingContext2D & { drawnTexts: string[]; fillRectCount: number; strokeRectCount: number }
|
|
}
|
|
|
|
describe('WorldPanelsHud — Mercenary Hire Stone Dialog (0x6faf9440 & 0x6faf7500 parity)', () => {
|
|
it('identifies dialog hitboxes accurately per D2Client.dll 0x6faf9440', () => {
|
|
const wp = new WorldPanelsHud()
|
|
wp.activePanel = 'hire'
|
|
|
|
// Centered at 155, 105 (490x350)
|
|
expect(wp.isPointInHireMercDialog(100, 50)).toBe(false)
|
|
expect(wp.isPointInHireMercDialog(155, 105)).toBe(true)
|
|
expect(wp.isPointInHireMercDialog(400, 300)).toBe(true)
|
|
expect(wp.isPointInHireMercDialog(644, 454)).toBe(true)
|
|
expect(wp.isPointInHireMercDialog(650, 460)).toBe(false)
|
|
|
|
// Confirmation dialog bounds (250, 190, 300, 160)
|
|
wp.replaceMercConfirmOpen = true
|
|
expect(wp.isPointInHireMercDialog(160, 110)).toBe(false)
|
|
expect(wp.isPointInHireMercDialog(250, 190)).toBe(true)
|
|
expect(wp.isPointInHireMercDialog(400, 250)).toBe(true)
|
|
expect(wp.isPointInHireMercDialog(550, 350)).toBe(true)
|
|
})
|
|
|
|
it('draws authentic 490x350 stone dialog with ItemDesc1s gold header and candidate list', () => {
|
|
const wp = new WorldPanelsHud()
|
|
wp.lang = 'ENG'
|
|
wp.activePanel = 'hire'
|
|
wp.playerGold = 5000
|
|
wp.stashGold = 12000
|
|
|
|
const rawCandidates = [
|
|
{ nameId: 3411, seed: 0x1234 },
|
|
{ nameId: 3412, seed: 0x5678 },
|
|
]
|
|
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 10, 0, 0, true, 'ENG')
|
|
|
|
const ctx = createMockCtx()
|
|
wp.drawHireMercDialog(ctx)
|
|
|
|
expect(ctx.fillRectCount).toBeGreaterThan(0)
|
|
// Title with total gold 17000
|
|
expect(ctx.drawnTexts.some((t) => t.includes('17000') && t.includes('Hire which Mercenary?'))).toBe(true)
|
|
// Candidates rendered with Lvl, Life, Def, Cost
|
|
expect(ctx.drawnTexts.some((t) => t.includes('Aliza - Lvl: 8'))).toBe(true)
|
|
expect(ctx.drawnTexts.some((t) => t.includes('Fire Arrow'))).toBe(true)
|
|
// Bottom cancel button
|
|
expect(ctx.drawnTexts.some((t) => t.includes('cancel'))).toBe(true)
|
|
})
|
|
|
|
it('draws ItemDesc1t when hire list is empty', () => {
|
|
const wp = new WorldPanelsHud()
|
|
wp.lang = 'ENG'
|
|
wp.activePanel = 'hire'
|
|
wp.mercHireList = []
|
|
|
|
const ctx = createMockCtx()
|
|
wp.drawHireMercDialog(ctx)
|
|
|
|
expect(ctx.drawnTexts.some((t) => t.includes('There are no Mercenaries left to hire.'))).toBe(true)
|
|
})
|
|
|
|
it('handles candidate hover, scrolling, and direct hire when player has no existing mercenary', () => {
|
|
const wp = new WorldPanelsHud()
|
|
wp.activePanel = 'hire'
|
|
wp.activeNpcId = 150
|
|
|
|
const rawCandidates = Array.from({ length: 10 }, (_, i) => ({
|
|
nameId: 3411 + i,
|
|
seed: 0x1000 + i * 0x100,
|
|
}))
|
|
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 15, 0, 0, true, 'ENG')
|
|
|
|
// 1. Wheel scroll
|
|
expect(wp.handleHireWheel(1)).toBe(true)
|
|
expect(wp.hireScrollOffset).toBe(1)
|
|
expect(wp.handleHireWheel(-1)).toBe(true)
|
|
expect(wp.hireScrollOffset).toBe(0)
|
|
|
|
// 2. Mouse move hover
|
|
wp.handleHireMercMove(200, 160) // over Candidate 0
|
|
expect(wp.hireHoveredIndex).toBe(0)
|
|
|
|
// 3. Direct click to hire when player has no existing mercenary
|
|
let hiredNpcId = 0
|
|
let hiredMercId = 0
|
|
const onHireMerc = vi.fn((npcId: number, mercId: number) => {
|
|
hiredNpcId = npcId
|
|
hiredMercId = mercId
|
|
})
|
|
|
|
const handled = wp.handleHireMercClick(200, 160, { onHireMerc })
|
|
expect(handled).toBe(true)
|
|
expect(onHireMerc).toHaveBeenCalledWith(150, 3411)
|
|
expect(wp.activePanel).toBe('none')
|
|
})
|
|
|
|
it('prompts confirmation dialog when player already possesses a mercenary (0x6faf7500)', () => {
|
|
const wp = new WorldPanelsHud()
|
|
wp.lang = 'ENG'
|
|
wp.activePanel = 'hire'
|
|
wp.activeNpcId = 150
|
|
wp.merc = { mercId: 42, isDead: false } // Existing active mercenary
|
|
|
|
const rawCandidates = [{ nameId: 3411, seed: 0x1234 }]
|
|
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 15, 0, 0, true, 'ENG')
|
|
|
|
const onHireMerc = vi.fn()
|
|
|
|
// First click: selects candidate
|
|
wp.handleHireMercClick(200, 160, { onHireMerc })
|
|
expect(wp.hireSelectedIndex).toBe(0)
|
|
expect(wp.replaceMercConfirmOpen).toBe(false)
|
|
expect(onHireMerc).not.toHaveBeenCalled()
|
|
|
|
// Second click on selected candidate: triggers replacement warning dialog
|
|
wp.handleHireMercClick(200, 160, { onHireMerc })
|
|
expect(wp.replaceMercConfirmOpen).toBe(true)
|
|
expect(wp.pendingHireCandidate?.nameId).toBe(3411)
|
|
|
|
// Render with confirm dialog open
|
|
const ctx = createMockCtx()
|
|
wp.drawHireMercDialog(ctx)
|
|
expect(ctx.drawnTexts.some((t) => t.includes('This Mercenary will replace your current one.'))).toBe(true)
|
|
expect(ctx.drawnTexts.some((t) => t.includes('Yes'))).toBe(true)
|
|
expect(ctx.drawnTexts.some((t) => t.includes('No'))).toBe(true)
|
|
|
|
// Click "No" (cx + 150 = 400, cy + 128 = 318)
|
|
wp.handleHireMercClick(400, 318, { onHireMerc })
|
|
expect(wp.replaceMercConfirmOpen).toBe(false)
|
|
expect(wp.activePanel).toBe('hire')
|
|
expect(onHireMerc).not.toHaveBeenCalled()
|
|
|
|
// Open again and click "Yes" (cx + 150 = 400, cy + 104 = 294)
|
|
wp.handleHireMercClick(200, 160, { onHireMerc })
|
|
expect(wp.replaceMercConfirmOpen).toBe(true)
|
|
|
|
wp.handleHireMercClick(400, 294, { onHireMerc })
|
|
expect(wp.replaceMercConfirmOpen).toBe(false)
|
|
expect(onHireMerc).toHaveBeenCalledWith(150, 3411)
|
|
expect(wp.activePanel).toBe('none')
|
|
})
|
|
|
|
it('clicking cancel button closes hire dialog and re-opens NPC menu (0x6faf9090)', () => {
|
|
const wp = new WorldPanelsHud()
|
|
wp.openNpcMenuByName('Kashya', 400, 220)
|
|
expect(wp.npcMenu).not.toBeNull()
|
|
|
|
wp.clickNpcMenuOption('hire')
|
|
expect(wp.activePanel).toBe('hire')
|
|
expect(wp.npcMenu).toBeNull()
|
|
|
|
// Click Cancel button at dialogX + 245 = 400, dialogY + 315 = 420
|
|
const onClose = vi.fn()
|
|
wp.handleHireMercClick(400, 420, { onClose })
|
|
|
|
expect(wp.activePanel).toBe('none')
|
|
expect(onClose).toHaveBeenCalled()
|
|
expect(wp.npcMenu).not.toBeNull()
|
|
expect(wp.npcMenu?.npcName).toContain('Kashya')
|
|
})
|
|
})
|