/** * tests/world-panels.test.ts * * Unit tests for Diablo II 1.13c Waypoint and World Panels UI Parity (Issue #378). */ import { describe, expect, it, vi } from 'vitest' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { WorldPanelsHud, ACT_WAYPOINTS, CUBE_TRANSMUTE_BTN_BOUNDS, CUBE_CLOSE_BTN_BOUNDS, } from '../src/ui/world-panels.ts' import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' import { D2FontRenderer } from '../src/ui/font.ts' import { encodeIndexedPng } from '../scripts/png.ts' import { decodeDc6 } from '../src/formats/dc6.ts' import { decodePl2 } from '../src/formats/pl2.ts' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' function getPngDimensions(filePath: string): { width: number; height: number } { const buf = readFileSync(filePath) if (buf.toString('ascii', 1, 4) !== 'PNG') throw new Error('Not a PNG file') const width = buf.readUInt32BE(16) const height = buf.readUInt32BE(20) return { width, height } } describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => { it('verifies waypoint-tabs.png, waypoint-icons.png, and quest-tabs.png asset dimensions', () => { const waypointTabsPath = join(process.cwd(), 'public/ui/waypoint-tabs.png') const waypointIconsPath = join(process.cwd(), 'public/ui/waypoint-icons.png') const questTabsPath = join(process.cwd(), 'public/ui/quest-tabs.png') expect(existsSync(waypointTabsPath)).toBe(true) expect(existsSync(waypointIconsPath)).toBe(true) expect(existsSync(questTabsPath)).toBe(true) // 10 frames of 63x31 = 630x31 const wpTabsDim = getPngDimensions(waypointTabsPath) expect(wpTabsDim.width).toBe(630) expect(wpTabsDim.height).toBe(31) // 5 frames of 30x30 = 150x30 const wpIconsDim = getPngDimensions(waypointIconsPath) expect(wpIconsDim.width).toBe(150) expect(wpIconsDim.height).toBe(30) // 10 frames of 63x31 = 630x31 const qTabsDim = getPngDimensions(questTabsPath) expect(qTabsDim.width).toBe(630) expect(qTabsDim.height).toBe(31) }) it('renders waypoint tabs and icons using drawImage slices when assets are present', () => { const hud = new WorldPanelsHud() const font = new D2FontRenderer() const drawImageCalls: any[] = [] const ctx = { drawImage: vi.fn((...args: any[]) => { drawImageCalls.push(args) }), fillRect: vi.fn(), strokeRect: vi.fn(), fillText: vi.fn(), save: vi.fn(), restore: vi.fn(), measureText: vi.fn(() => ({ width: 60 })), fillStyle: '', strokeStyle: '', globalAlpha: 1, } as unknown as CanvasRenderingContext2D const mockTabsImg = { complete: true, naturalWidth: 630, naturalHeight: 31, } as unknown as HTMLImageElement const mockIconsImg = { complete: true, naturalWidth: 150, naturalHeight: 30, } as unknown as HTMLImageElement hud.selectedActTab = 2 // Set current waypoint to Lut Gholein (waypointId: 9, levelId: 40) hud.setCurrentWaypoint(9, 40) hud.drawLeftDockPanel( ctx, 'waypoint', { questBgImg: null, waypointBgImg: null, borderLeftImg: null, stashBgImg: null, cubeBgImg: null, vendorBgImg: null, buySellBtnImg: null, waypointTabsImg: mockTabsImg, waypointIconsImg: mockIconsImg, }, font, ) // Verify 5 tab draws from mockTabsImg: // Act 1 (inactive, frame 1 -> sx 1 * 63 = 63) // Act 2 (active, frame 2 -> sx 2 * 63 = 126) // Act 3 (inactive, frame 5 -> sx 5 * 63 = 315) // Act 4 (inactive, frame 7 -> sx 7 * 63 = 441) // Act 5 (inactive, frame 9 -> sx 9 * 63 = 567) const tabDraws = drawImageCalls.filter((c) => c[0] === mockTabsImg) expect(tabDraws.length).toBe(5) expect(tabDraws[0][1]).toBe(1 * 63) // Act 1 (inactive) sx = 63 expect(tabDraws[1][1]).toBe(2 * 63) // Act 2 (active) sx = 126 expect(tabDraws[2][1]).toBe(5 * 63) // Act 3 (inactive) sx = 315 // Verify waypoint icons drawn from mockIconsImg for Act 2: // Ground Truth D2Client.dll 1.13c: // - Index 0 (Lut Gholein): current waypoint -> Frame 0 (sx = 0) // - Indices 1..8: activated destination waypoints -> Frame 3 (sx = 90) const iconDraws = drawImageCalls.filter((c) => c[0] === mockIconsImg) const act2WpCount = (ACT_WAYPOINTS[2] ?? []).length expect(iconDraws.length).toBe(act2WpCount) expect(iconDraws[0][1]).toBe(0) // Current waypoint: Frame 0 (sx = 0) for (let i = 1; i < act2WpCount; i++) { expect(iconDraws[i][1]).toBe(90) // Destination waypoints: Frame 3 (sx = 90) expect(iconDraws[i][3]).toBe(30) expect(iconDraws[i][4]).toBe(30) } }) it('Issue #393: renders hovered states and omits locked waypoint icons per D2 1.13c parity', () => { const hud = new WorldPanelsHud() const font = new D2FontRenderer() const mockTabsImg = { complete: true, naturalWidth: 630, naturalHeight: 31, } as unknown as HTMLImageElement const mockIconsImg = { complete: true, naturalWidth: 150, naturalHeight: 30, } as unknown as HTMLImageElement const assets = { questBgImg: null, waypointBgImg: null, borderLeftImg: null, stashBgImg: null, cubeBgImg: null, vendorBgImg: null, buySellBtnImg: null, waypointTabsImg: mockTabsImg, waypointIconsImg: mockIconsImg, } // 1. In Act 4 (3 waypoints: Pandemonium Fortress, City of the Damned, River of Flame) hud.selectedActTab = 4 hud.setCurrentWaypoint(27, 103) // Pandemonium Fortress is current waypoint // Hover over current waypoint (index 0) hud.hoveredWaypointIdx = 0 let drawCalls: any[] = [] const ctx = { drawImage: vi.fn((...args: any[]) => drawCalls.push(args)), fillRect: vi.fn(), strokeRect: vi.fn(), save: vi.fn(), restore: vi.fn(), measureText: vi.fn(() => ({ width: 60 })), fillText: vi.fn(), } as unknown as CanvasRenderingContext2D hud.drawLeftDockPanel(ctx, 'waypoint', assets, font) let iconDraws = drawCalls.filter((c) => c[0] === mockIconsImg) expect(iconDraws.length).toBe(3) expect(iconDraws[0][1]).toBe(30) // Hovered current waypoint: Frame 1 (sx = 30) expect(iconDraws[1][1]).toBe(90) // Unhovered destination waypoint: Frame 3 (sx = 90) expect(iconDraws[2][1]).toBe(90) // Unhovered destination waypoint: Frame 3 (sx = 90) // Hover over destination waypoint (index 1: City of the Damned) hud.hoveredWaypointIdx = 1 drawCalls = [] hud.drawLeftDockPanel(ctx, 'waypoint', assets, font) iconDraws = drawCalls.filter((c) => c[0] === mockIconsImg) expect(iconDraws.length).toBe(3) expect(iconDraws[0][1]).toBe(0) // Unhovered current waypoint: Frame 0 (sx = 0) expect(iconDraws[1][1]).toBe(120) // Hovered destination waypoint: Frame 4 (sx = 120) expect(iconDraws[2][1]).toBe(90) // Unhovered destination waypoint: Frame 3 (sx = 90) // 2. Locked waypoint: locked waypoints have NO icon drawn per D2Client.dll 0x6fb5b308 (je 0x6fb5b355) hud.hoveredWaypointIdx = null hud.setWaypointUnlocked(29, false) // Lock River of Flame (index 2) drawCalls = [] hud.drawLeftDockPanel(ctx, 'waypoint', assets, font) iconDraws = drawCalls.filter((c) => c[0] === mockIconsImg) expect(iconDraws.length).toBe(2) // Only index 0 and 1 drawn, index 2 omitted expect(iconDraws[0][1]).toBe(0) // Frame 0 expect(iconDraws[1][1]).toBe(90) // Frame 3 }) it('updates hoveredWaypointIdx on mouse move and highlights hovered waypoint', () => { const hud = new WorldPanelsHud() hud.selectedActTab = 1 const ox = 80 const oy = 60 // Hover over first waypoint (i = 0): wy = oy + 62 = 122 .. 153 hud.handleMouseMove(ox + 50, oy + 70) expect(hud.hoveredWaypointIdx).toBe(0) // Move away hud.handleMouseMove(ox + 50, oy + 500) expect(hud.hoveredWaypointIdx).toBeNull() }) it('handles clicking waypoint tabs and locked vs unlocked waypoints', () => { const hud = new WorldPanelsHud() const onWaypointTeleport = vi.fn() const onClose = vi.fn() const ox = 80 const oy = 60 // 1. Click Act 3 tab (tab 3: logicalX approx ox + 8 + 2 * 61 + 10 = ox + 140, logicalY oy + 20) const tabClickHandled = hud.handleLeftDockClick('waypoint', ox + 140, oy + 20, { onWaypointTeleport, onClose, }) expect(tabClickHandled).toBe(true) expect(hud.selectedActTab).toBe(3) // 2. In Act 3: // Set Kurast Docks (index 0, waypointId: 18, levelId: 75) as current waypoint hud.setCurrentWaypoint(18, 75) const wps = ACT_WAYPOINTS[3]! // Mark index 1 (Spider Forest, waypointId: 19) as locked via hud method hud.setWaypointUnlocked(19, false) // Click locked waypoint (index 1): should NOT trigger teleport or close const lockedWy = oy + 62 + 1 * 36 + 10 const lockedClick = hud.handleLeftDockClick('waypoint', ox + 50, lockedWy, { onWaypointTeleport, onClose, }) expect(lockedClick).toBe(true) expect(onWaypointTeleport).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled() // Click current waypoint (index 0, Kurast Docks): should NOT trigger teleport (player already here) const currentWy = oy + 62 + 0 * 36 + 10 const currentClick = hud.handleLeftDockClick('waypoint', ox + 50, currentWy, { onWaypointTeleport, onClose, }) expect(currentClick).toBe(true) expect(onWaypointTeleport).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled() // Click unlocked destination waypoint (index 2, Great Marsh): should trigger teleport and close const destWy = oy + 62 + 2 * 36 + 10 const destClick = hud.handleLeftDockClick('waypoint', ox + 50, destWy, { onWaypointTeleport, onClose, }) expect(destClick).toBe(true) expect(onWaypointTeleport).toHaveBeenCalledWith(3, wps[2]!.slug, wps[2]!.levelId) expect(onClose).toHaveBeenCalled() }) }) describe('Diablo II 1.13c Horadric Cube Transmute & Close Button Parity', () => { it('verifies miniconvert.png dimensions, manifest entry, and byte-exact parity against MPQ miniconvert.dc6', async () => { expect(BAKED_UI_MANIFEST.images.miniConvert).toBe('/ui/miniconvert.png') const miniConvertPath = join(process.cwd(), 'public/ui/miniconvert.png') expect(existsSync(miniConvertPath)).toBe(true) expect(getPngDimensions(miniConvertPath)).toEqual({ width: 64, height: 32 }) expect(CUBE_TRANSMUTE_BTN_BOUNDS).toEqual({ x: 224, y: 320, relX: 144, relY: 260, w: 32, h: 32, hitMinX: 224, hitMaxX: 264, hitMinY: 317, hitMaxY: 357, tooltipCenterX: 238, tooltipAnchorY: 317, strIdx: 3341, labelZh: '改變', labelEn: 'Transmute', }) expect(CUBE_CLOSE_BTN_BOUNDS).toEqual({ x: 355, y: 443, relX: 275, relY: 383, w: 32, h: 32, hitMinX: 355, hitMaxX: 395, hitMinY: 440, hitMaxY: 480, tooltipCenterX: 369, tooltipAnchorY: 440, strIdx: 4144, labelZh: '關閉', labelEn: 'Close', }) const d2dataPath = join(process.cwd(), 'samples/d2/d2data.mpq') const d2expPath = join(process.cwd(), 'samples/d2/d2exp.mpq') if (existsSync(d2dataPath) && existsSync(d2expPath)) { const archives = new MountedArchives() archives.add('d2data.mpq', await MpqArchive.open(await fileSource(d2dataPath))) archives.add('d2exp.mpq', await MpqArchive.open(await fileSource(d2expPath))) const pl2 = decodePl2(await archives.read('data/global/palette/ACT1/pal.pl2')) const sheet = decodeDc6(await archives.read('data/global/ui/Panel/miniconvert.dc6')) const frames = sheet.groups[0]!.frames expect(frames).toHaveLength(2) expect(frames[0]!.width).toBe(32) expect(frames[0]!.height).toBe(32) expect(frames[1]!.width).toBe(32) expect(frames[1]!.height).toBe(32) const pixels = new Uint8Array(64 * 32) for (let i = 0; i < 2; i++) { const f = frames[i]! for (let y = 0; y < 32; y++) { for (let x = 0; x < 32; x++) { const idx = y * 32 + x if (f.mask[idx] !== 0 && f.indices[idx] !== 0) { pixels[y * 64 + i * 32 + x] = f.indices[idx]! } } } } const expectedPng = encodeIndexedPng({ width: 64, height: 32, pixels, palette: pl2.rgb, transparentIndex: 0, }) const actualBytes = readFileSync(miniConvertPath) expect(Buffer.compare(actualBytes, Buffer.from(expectedPng))).toBe(0) } }) it('draws Horadric Cube Transmute button (miniconvert.png) at (ox+144, oy+260) and Close button at (ox+275, oy+383), switching frames on press and rendering hover tooltips', () => { const hud = new WorldPanelsHud() const drawImageCalls: any[] = [] const textCalls: Array<{ text: string; x: number; y: number; opts?: any }> = [] const ctx = { drawImage: vi.fn((...args: any[]) => drawImageCalls.push(args)), fillRect: vi.fn(), strokeRect: vi.fn(), fillStyle: '', strokeStyle: '', } as unknown as CanvasRenderingContext2D const font = { measureText: vi.fn((text: string) => text.length * 16), drawText: vi.fn((_ctx: CanvasRenderingContext2D, text: string, x: number, y: number, opts?: any) => { textCalls.push({ text, x, y, opts }) }), } as unknown as D2FontRenderer const mockCubeBg = { tag: 'cubeBg' } as unknown as HTMLImageElement const mockBuySellBtn = { tag: 'buySellBtn' } as unknown as HTMLImageElement const mockMiniConvert = { tag: 'miniConvert' } as unknown as HTMLImageElement const assets = { borderLeftImg: null, questBgImg: null, waypointBgImg: null, stashBgImg: null, cubeBgImg: mockCubeBg, vendorBgImg: null, buySellBtnImg: mockBuySellBtn, miniConvertImg: mockMiniConvert, } // 1. Unpressed render hud.drawLeftDockPanel(ctx, 'cube', assets, font) const transmuteDraws = drawImageCalls.filter(c => c[0] === mockMiniConvert) const closeDraws = drawImageCalls.filter(c => c[0] === mockBuySellBtn) expect(transmuteDraws).toHaveLength(1) expect(transmuteDraws[0].slice(1)).toEqual([0, 0, 32, 32, 80 + 144, 60 + 260, 32, 32]) expect(closeDraws).toHaveLength(1) expect(closeDraws[0].slice(1)).toEqual([10 * 32, 0, 32, 32, 80 + 275, 60 + 383, 32, 32]) // 2. Click Transmute button (`ox + 160, oy + 276`) -> increments count, sets pressedCubeButton = 'transmute' (frame 1 -> sx = 32) const onClose = vi.fn() hud.handleLeftDockClick('cube', 80 + 160, 60 + 276, { onClose, onWaypointTeleport: () => {}, }) expect(hud.cubeTransmuteCount).toBe(1) expect(hud.pressedCubeButton).toBe('transmute') // Hover over Transmute button -> hoveredCubeButton = 'transmute' hud.handleMouseMove(80 + 160, 60 + 276) expect(hud.hoveredCubeButton).toBe('transmute') expect(hud.pressedCubeButton).toBe('transmute') drawImageCalls.length = 0 textCalls.length = 0 hud.drawLeftDockPanel(ctx, 'cube', assets, font) const pressedTransmuteDraws = drawImageCalls.filter(c => c[0] === mockMiniConvert) expect(pressedTransmuteDraws).toHaveLength(1) expect(pressedTransmuteDraws[0].slice(1)).toEqual([32, 0, 32, 32, 80 + 144, 60 + 260, 32, 32]) const transmuteTooltip = textCalls.find(c => c.text === '改變') expect(transmuteTooltip).toBeDefined() expect(transmuteTooltip!.x).toBe(80 + 158) expect(transmuteTooltip!.opts?.font).toBe('font16') // 3. Moving cursor outside Transmute button clears pressedCubeButton per D2Client.dll 0x6fb49783 hud.handleMouseMove(80 + 290, 60 + 395) expect(hud.hoveredCubeButton).toBe('close') expect(hud.pressedCubeButton).toBeNull() textCalls.length = 0 hud.drawLeftDockPanel(ctx, 'cube', assets, font) const closeTooltip = textCalls.find(c => c.text === '關閉') expect(closeTooltip).toBeDefined() expect(closeTooltip!.x).toBe(80 + 289) expect(closeTooltip!.opts?.font).toBe('font16') // 4. Clicking Close button closes panel hud.handleLeftDockClick('cube', 80 + 290, 60 + 395, { onClose, onWaypointTeleport: () => {}, }) expect(onClose).toHaveBeenCalledTimes(1) }) })