import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { encodeIndexedPng } from '../scripts/png.ts' import { decodeDc6 } from '../src/formats/dc6.ts' import { decodePl2 } from '../src/formats/pl2.ts' import type { SpriteFrame } from '../src/formats/sprite.ts' import { TOWN_NPC_DESCRIPTORS } from '../src/game/npc-table.ts' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' import type { D2ColorCode, D2FontRenderer, FontSizeName } from '../src/ui/font.ts' import { InventoryPanel, type UiInventoryItem } from '../src/ui/inventory.ts' import { BLACKSMITH_CLASS_IDS, TRADER_CLASS_IDS, VENDOR_BUTTON_DRAW_Y, VENDOR_BUTTON_HIT_W, VENDOR_BUTTON_HIT_Y_MAX, VENDOR_BUTTON_HIT_Y_MIN, VENDOR_BUTTON_SPECS, VENDOR_BUTTON_TABLE_X, VENDOR_GRID_ORIGIN, VENDOR_TABS, WorldPanelsHud, resolveVendorButtonLayout, } from '../src/ui/world-panels.ts' function readPngHeaderSize(buf: Uint8Array): { width: number; height: number } { const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) return { width: view.getUint32(16, false), height: view.getUint32(20, false), } } function stitchIndexed( width: number, height: number, placements: ReadonlyArray<{ frame: SpriteFrame; x: number; y: number }>, ): Uint8Array { const out = new Uint8Array(width * height) for (const { frame, x: dstX, y: dstY } of placements) { for (let sy = 0; sy < frame.height; sy++) { const ty = dstY + sy if (ty < 0 || ty >= height) continue for (let sx = 0; sx < frame.width; sx++) { const tx = dstX + sx if (tx < 0 || tx >= width) continue const srcIdx = sy * frame.width + sx if (frame.mask[srcIdx] === 0) continue const color = frame.indices[srcIdx] ?? 0 if (color !== 0) { out[ty * width + tx] = color } } } } return out } function createFakeImage(width: number, height: number, tag: string): HTMLImageElement { return { width, height, tag } as unknown as HTMLImageElement } function createFakeVendorAssets() { return { borderLeftImg: createFakeImage(400, 553, 'borderLeft'), questBgImg: null, waypointBgImg: null, stashBgImg: null, cubeBgImg: null, vendorBgImg: createFakeImage(320, 432, 'vendorBg'), vendorTabsImg: createFakeImage(632, 31, 'vendorTabs'), buySellBtnImg: createFakeImage(736, 32, 'buySellBtn'), itemsAtlasImg: createFakeImage(2048, 2048, 'itemsAtlas'), } } interface DrawImageCall { tag: string args: number[] } interface FontCall { type: 'drawText' | 'drawRightText' | 'drawFramedTooltip' text: string x: number y: number size: FontSizeName color: D2ColorCode | undefined } function createMockCanvasAndFont() { const drawImageCalls: DrawImageCall[] = [] const fillRectCalls: number[][] = [] const strokeRectCalls: number[][] = [] const fontCalls: FontCall[] = [] const ctx = { save: vi.fn(), restore: vi.fn(), drawImage: vi.fn((img: { tag?: string }, ...nums: number[]) => { drawImageCalls.push({ tag: img?.tag ?? 'unknown', args: nums }) }), fillRect: vi.fn((...nums: number[]) => { fillRectCalls.push(nums) }), strokeRect: vi.fn((...nums: number[]) => { strokeRectCalls.push(nums) }), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(), fillText: vi.fn(), fillStyle: '', strokeStyle: '', lineWidth: 1, } as unknown as CanvasRenderingContext2D const font = { loaded: true, measureText: vi.fn((text: string) => text.length * 10), drawText: vi.fn( ( _ctx: CanvasRenderingContext2D, text: string, x: number, y: number, opts?: { font?: FontSizeName; color?: D2ColorCode; align?: 'left' | 'center' | 'right' }, ) => { fontCalls.push({ type: 'drawText', text, x, y, size: opts?.font ?? 'font16', color: opts?.color ?? 'white', }) }, ), drawRightText: vi.fn( ( _ctx: CanvasRenderingContext2D, text: string, rightX: number, y: number, size: FontSizeName = 'font16', color: D2ColorCode = 'white', ) => { fontCalls.push({ type: 'drawRightText', text, x: rightX, y, size, color }) }, ), drawFramedTooltip: vi.fn( ( _ctx: CanvasRenderingContext2D, lines: Array<{ text: string; color?: D2ColorCode; size?: FontSizeName }>, centerX: number, bottomY: number, ) => { fontCalls.push({ type: 'drawFramedTooltip', text: lines.map(l => l.text).join('\n'), x: centerX, y: bottomY, size: lines[0]?.size ?? 'font16', color: lines[0]?.color, }) }, ), } as unknown as D2FontRenderer return { ctx, font, drawImageCalls, fillRectCalls, strokeRectCalls, fontCalls } } describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { it('verifies Monster2 10x10 grid origin and baked UI manifest entries', () => { // Inventory.txt Monster2: gridLeft = 96 (80 + 16), gridTop = 123 (60 + 63), 10x10, 29px expect(VENDOR_GRID_ORIGIN).toEqual({ x: 96, y: 123, cols: 10, rows: 10, cellPx: 29, }) expect(BAKED_UI_MANIFEST.images.vendorBg).toBe('/ui/vendor-bg.png') expect(BAKED_UI_MANIFEST.images.vendorTabs).toBe('/ui/vendor-tabs.png') expect(BAKED_UI_MANIFEST.images.buySellBtn).toBe('/ui/buysellbtn.png') }) it('verifies baked vendor-bg.png, vendor-tabs.png, and buysellbtn.png dimensions and MPQ byte parity', async () => { const publicUiDir = resolve(process.cwd(), 'public/ui') const vendorBgBytes = new Uint8Array(readFileSync(resolve(publicUiDir, 'vendor-bg.png'))) const vendorTabsBytes = new Uint8Array(readFileSync(resolve(publicUiDir, 'vendor-tabs.png'))) const buySellBtnBytes = new Uint8Array(readFileSync(resolve(publicUiDir, 'buysellbtn.png'))) expect(readPngHeaderSize(vendorBgBytes)).toEqual({ width: 320, height: 432 }) expect(readPngHeaderSize(vendorTabsBytes)).toEqual({ width: 632, height: 31 }) expect(readPngHeaderSize(buySellBtnBytes)).toEqual({ width: 736, height: 32 }) // Verify byte-exact PNG parity against freshly decoded MPQ buysell.dc6 and buyselltabs.dc6 const d2dataPath = resolve(process.cwd(), 'samples/d2/d2data.mpq') if (existsSync(d2dataPath)) { const archives = new MountedArchives() archives.add('d2data.mpq', await MpqArchive.open(await fileSource(d2dataPath))) const pl2 = decodePl2(await archives.read('data/global/palette/ACT1/pal.pl2')) const buysellSheet = decodeDc6(await archives.read('data/global/ui/Panel/buysell.dc6')) const bf = buysellSheet.groups[0]!.frames expect(bf).toHaveLength(4) const expectedBgPng = encodeIndexedPng({ width: 320, height: 432, pixels: stitchIndexed(320, 432, [ { frame: bf[0]!, x: 0, y: 0 }, { frame: bf[1]!, x: 256, y: 0 }, { frame: bf[2]!, x: 0, y: 256 }, { frame: bf[3]!, x: 256, y: 256 }, ]), palette: pl2.rgb, }) expect(Buffer.compare(Buffer.from(vendorBgBytes), Buffer.from(expectedBgPng))).toBe(0) const tabsSheet = decodeDc6(await archives.read('data/global/ui/Panel/buyselltabs.dc6')) const tf = tabsSheet.groups[0]!.frames expect(tf).toHaveLength(8) const expectedTabsPng = encodeIndexedPng({ width: 632, height: 31, pixels: stitchIndexed( 632, 31, tf.slice(0, 8).map((frame, i) => ({ frame, x: i * 79, y: 0 })), ), palette: pl2.rgb, transparentIndex: 0, }) expect(Buffer.compare(Buffer.from(vendorTabsBytes), Buffer.from(expectedTabsPng))).toBe(0) } }) it('verifies D2Client.dll 0x6fb3f430 4-button layout across all 16 trade NPCs, 6 gamble NPCs, and non-vendor NPCs', () => { const tradeNpcs = TOWN_NPC_DESCRIPTORS.filter(d => d.canTrade) const repairNpcs = TOWN_NPC_DESCRIPTORS.filter(d => d.canRepair) const gambleNpcs = TOWN_NPC_DESCRIPTORS.filter(d => d.canGamble) const nonVendorNpcs = TOWN_NPC_DESCRIPTORS.filter(d => !d.canTrade && !d.canGamble) expect(tradeNpcs).toHaveLength(16) expect(repairNpcs).toHaveLength(5) expect(gambleNpcs).toHaveLength(6) for (const desc of tradeNpcs) { const layout = resolveVendorButtonLayout(desc.hcIdx, 'trade') if (desc.canRepair) { expect(BLACKSMITH_CLASS_IDS.has(desc.hcIdx)).toBe(true) expect(layout).toEqual(['buy', 'sell', 'repair', 'repairAll']) } else { expect(TRADER_CLASS_IDS.has(desc.hcIdx)).toBe(true) expect(layout).toEqual(['buy', 'sell', 'blank', 'close']) } } // Gamble mode overrides blacksmith/trader check first per 0x6fb3f430 for (const desc of gambleNpcs) { expect(resolveVendorButtonLayout(desc.hcIdx, 'gamble')).toEqual(['buy', 'sell', 'blank', 'close']) } for (const desc of nonVendorNpcs) { expect(() => resolveVendorButtonLayout(desc.hcIdx, 'trade')).toThrow( /D2Client 0x6fb3f430 assigns no trade buttons to MonStats class/, ) } }) it('draws Charsi blacksmith panel with [2, 4, 6, 18] button frames, present tabs, CHI labels, and no extra close button', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() wp.activePlayerInventory = inv expect(wp.openVendorForNpc('Charsi', 'trade', 24, 0)).toBe(true) const { ctx, font, drawImageCalls, strokeRectCalls, fontCalls } = createMockCanvasAndFont() const assets = { ...createFakeVendorAssets(), inventory: inv } wp.drawLeftDockPanel(ctx, 'vendor', assets, font) // 1. vendorBg drawn at (80, 60) const bgCalls = drawImageCalls.filter(c => c.tag === 'vendorBg') expect(bgCalls).toEqual([{ tag: 'vendorBg', args: [80, 60] }]) // 2. Exactly 4 buySellBtn calls (no 5th generic Close button at the end of drawLeftDockPanel!) const btnCalls = drawImageCalls.filter(c => c.tag === 'buySellBtn') expect(btnCalls).toHaveLength(4) expect(btnCalls.map(c => c.args)).toEqual([ [2 * 32, 0, 32, 32, 80 + 115, 60 + VENDOR_BUTTON_DRAW_Y, 32, 32], [4 * 32, 0, 32, 32, 80 + 168, 60 + VENDOR_BUTTON_DRAW_Y, 32, 32], [6 * 32, 0, 32, 32, 80 + 220, 60 + VENDOR_BUTTON_DRAW_Y, 32, 32], [18 * 32, 0, 32, 32, 80 + 272, 60 + VENDOR_BUTTON_DRAW_Y, 32, 32], ]) // 3. Present tabs drawn from vendorTabsImg with frames 0 (selected) and 5..7 (unselected) const tabCalls = drawImageCalls.filter(c => c.tag === 'vendorTabs') expect(tabCalls.length).toBeGreaterThanOrEqual(3) expect(tabCalls[0]!.args).toEqual([0 * 79, 0, 79, 31, 80 + 0, 60, 79, 31]) // 4. Tab labels use CHI .tbl strings ('裝甲', '武器', '其他') in font16 and NO fake strokeRects or vendor title banner expect(strokeRectCalls).toHaveLength(0) const tabLabels = fontCalls.filter(c => c.type === 'drawText').map(c => c.text) expect(tabLabels).toContain('裝甲') expect(tabLabels).toContain('武器') expect(tabLabels).toContain('其他') expect(tabLabels.some(t => t.includes('恰西') || t.includes('Charsi'))).toBe(false) }) it('draws Akara trader panel with [2, 4, 0, 10] button frames and omits empty tabs', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() wp.activePlayerInventory = inv expect(wp.openVendorForNpc('Akara', 'trade', 12, 0)).toBe(true) // Force armor tab empty to verify 0x6fb45e5e-0x6fb45f5d tab presence guard wp.vendorTabPlacements.armor = [] wp.selectVendorTab('armor') expect(wp.activeVendorTab).not.toBe('armor') const { ctx, font, drawImageCalls, fontCalls } = createMockCanvasAndFont() wp.drawLeftDockPanel(ctx, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font) const btnCalls = drawImageCalls.filter(c => c.tag === 'buySellBtn') expect(btnCalls).toHaveLength(4) expect(btnCalls.map(c => c.args[0]! / 32)).toEqual([2, 4, 0, 10]) // Empty 'armor' tab must not draw its tab sprite or '裝甲' label const tabLabels = fontCalls.filter(c => c.type === 'drawText').map(c => c.text) expect(tabLabels).not.toContain('裝甲') }) it('draws Gamble panel with only tab 0 sprite and no tab text labels', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() wp.activePlayerInventory = inv expect(wp.openVendorForNpc('Gheed', 'gamble', 30, 0)).toBe(true) const { ctx, font, drawImageCalls, fontCalls } = createMockCanvasAndFont() wp.drawLeftDockPanel(ctx, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font) const btnCalls = drawImageCalls.filter(c => c.tag === 'buySellBtn') expect(btnCalls.map(c => c.args[0]! / 32)).toEqual([2, 4, 0, 10]) const tabCalls = drawImageCalls.filter(c => c.tag === 'vendorTabs') expect(tabCalls).toEqual([{ tag: 'vendorTabs', args: [0, 0, 79, 31, 80, 60, 79, 31] }]) expect(fontCalls.filter(c => c.type === 'drawText')).toHaveLength(0) }) it('enforces open-interval button hit-testing, latching Buy/Sell/Repair states, Repair All cost tooltip, and Close', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() inv.gold = 50000 wp.activePlayerInventory = inv expect(wp.openVendorForNpc('Charsi', 'trade', 24, 0)).toBe(true) // Damage an equipped item so Repair All has a non-zero cost const helm: UiInventoryItem = { id: 'damaged-cap', code: 'cap', nameEn: 'Cap', nameZh: '帽子', quality: 'normal', invFile: 'invcap', invWidth: 2, invHeight: 2, equipSlots: ['head'], identified: true, durability: { current: 4, max: 12 }, } inv.equipped.head = helm const expectedCost = wp.getRepairAllCost(inv) expect(expectedCost).toBeGreaterThan(0) // Open-interval boundary check: relX = tableX (116) or tableX + 45 (161) must NOT hit expect(wp.hitTestVendorButtonSlot(80 + 116, 60 + 390)).toBeNull() expect(wp.hitTestVendorButtonSlot(80 + 116 + VENDOR_BUTTON_HIT_W, 60 + 390)).toBeNull() expect(wp.hitTestVendorButtonSlot(80 + 130, 60 + VENDOR_BUTTON_HIT_Y_MIN)).toBeNull() expect(wp.hitTestVendorButtonSlot(80 + 130, 60 + VENDOR_BUTTON_HIT_Y_MAX)).toBeNull() expect(wp.hitTestVendorButtonSlot(80 + 130, 60 + 390)).toBe(0) let closed = false const callbacks = { onClose: () => { closed = true }, onWaypointTeleport: () => {}, inventory: inv, } // Click slot 0 (Buy) -> latches 'buy' (frame 3) wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[0] + 20, 60 + 390, callbacks) expect(wp.vendorTradeState).toBe('buy') expect(wp.getVendorButtonFrame(0)).toBe(3) // Click slot 1 (Sell) -> switches to 'sell' (frame 5) wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[1] + 20, 60 + 390, callbacks) expect(wp.vendorTradeState).toBe('sell') expect(wp.getVendorButtonFrame(1)).toBe(5) // Click slot 2 (Repair) -> switches to 'repair' (frame 7) wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[2] + 20, 60 + 390, callbacks) expect(wp.vendorTradeState).toBe('repair') expect(wp.getVendorButtonFrame(2)).toBe(7) expect(wp.repairCursorMode).toBe(true) // Hover slot 3 (Repair All) -> verifies left-anchored framed hover text "修復所有裝備:${cost}" wp.handleMouseMove(80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390) expect(wp.hoveredVendorButtonSlot).toBe(3) const { ctx, font, fontCalls } = createMockCanvasAndFont() wp.drawLeftDockPanel(ctx, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font) expect( fontCalls.some( c => c.type === 'drawText' && c.text === `修復所有裝備:${expectedCost}` && c.x === 80 + VENDOR_BUTTON_TABLE_X[3] + 6, ), ).toBe(true) // Click slot 3 (Repair All on Charsi) -> repairs item, clears state to 'idle', and does NOT close panel! wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, callbacks) expect(closed).toBe(false) expect(wp.vendorTradeState).toBe('idle') expect(wp.pressedMomentarySlot).toBe(3) expect(helm.durability?.current).toBe(12) wp.releaseMomentaryVendorButton() expect(wp.pressedMomentarySlot).toBeNull() // Switch to Akara and click slot 3 (Close) -> closes panel, slot 2 (blank) is ignored wp.openVendorForNpc('Akara', 'trade', 24, 0) expect(wp.hitTestVendorButtonSlot(80 + VENDOR_BUTTON_TABLE_X[2] + 20, 60 + 390)).toBeNull() wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, callbacks) expect(closed).toBe(true) }) it('auto-cycles to the next non-empty tab when buying the last item on the active tab', () => { const wp = new WorldPanelsHud() const inv = new InventoryPanel() inv.gold = 999999 wp.activePlayerInventory = inv expect(wp.openVendorForNpc('Charsi', 'trade', 24, 0)).toBe(true) expect(wp.activeVendorTab).toBe('armor') // Leave only 1 non-permStore item on the armor tab and buy it const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.rawItem?.permStoreItem) expect(nonPerm).toBeDefined() wp.vendorTabPlacements.armor = [nonPerm!] const res = wp.buyFromActiveVendor(nonPerm!.col, nonPerm!.row, inv, false) expect(res.ok).toBe(true) expect(wp.vendorTabPlacements.armor).toHaveLength(0) expect(wp.activeVendorTab).toBe('weapons1') }) it('verifies VENDOR_TABS and VENDOR_BUTTON_SPECS match D2Client.dll 1.13c constants', () => { expect(VENDOR_TABS.map(t => ({ xOffset: t.xOffset, width: t.width, height: t.height, centerX: t.centerX, labelZh: t.labelZh }))).toEqual([ { xOffset: 0, width: 79, height: 31, centerX: 42, labelZh: '裝甲' }, { xOffset: 80, width: 79, height: 31, centerX: 121, labelZh: '武器' }, { xOffset: 160, width: 79, height: 31, centerX: 201, labelZh: '武器' }, { xOffset: 240, width: 79, height: 31, centerX: 281, labelZh: '其他' }, ]) expect(VENDOR_BUTTON_SPECS.buy.labelZh).toBe('買') expect(VENDOR_BUTTON_SPECS.sell.labelZh).toBe('賣') expect(VENDOR_BUTTON_SPECS.repair.labelZh).toBe('修復') expect(VENDOR_BUTTON_SPECS.close.labelZh).toBe('關閉') }) })