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' <<<<<<< HEAD import type { D2ColorCode, D2FontName as FontSizeName, D2FontRenderer } from '../src/ui/font.ts' ======= import type { D2ColorCode, D2FontRenderer, D2FontName } from '../src/ui/font.ts' >>>>>>> ce00574 (fix(ui): restore authentic D2 v1.13c NPC dialog popup menu (Fixes #489)) 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, computeNpcDialogLayout, drawStoneBoxFrame, hitTestNpcDialogOption, 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: D2FontName 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?: D2FontName; 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: D2FontName = '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?: D2FontName }>, 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', name: 'Cap', nameZh: '帽子', baseNameZh: '帽子', quality: 'normal', invFile: 'invcap', invWidth: 2, invHeight: 2, allowedSlots: ['helm'], stats: [], identified: true, durability: { current: 4, max: 12 }, } inv.equipped.helm = 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.permStore && !p.item.rawItem?.permStore) 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('關閉') }) it('verifies baked boxpieces.png and focus16.png dimensions and MPQ byte parity', async () => { expect(BAKED_UI_MANIFEST.images.boxPieces).toBe('/ui/boxpieces.png') expect(BAKED_UI_MANIFEST.images.focus16).toBe('/ui/focus16.png') const publicUiDir = resolve(process.cwd(), 'public/ui') const boxPiecesBytes = new Uint8Array(readFileSync(resolve(publicUiDir, 'boxpieces.png'))) const focus16Bytes = new Uint8Array(readFileSync(resolve(publicUiDir, 'focus16.png'))) expect(readPngHeaderSize(boxPiecesBytes)).toEqual({ width: 308, height: 15 }) expect(readPngHeaderSize(focus16Bytes)).toEqual({ width: 160, height: 20 }) 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 bpSheet = decodeDc6(await archives.read('data/global/ui/menu/boxpieces.dc6')) const bpFrames = bpSheet.groups[0]!.frames expect(bpFrames).toHaveLength(22) const expectedBpPng = encodeIndexedPng({ width: 308, height: 15, pixels: stitchIndexed( 308, 15, bpFrames.slice(0, 22).map((frame, i) => ({ frame, x: i * 14, y: 0 })), ), palette: pl2.rgb, transparentIndex: 0, }) expect(Buffer.compare(Buffer.from(boxPiecesBytes), Buffer.from(expectedBpPng))).toBe(0) const focusSheet = decodeDc6(await archives.read('data/global/ui/CURSOR/focus16.dc6')) const focusFrames = focusSheet.groups[0]!.frames expect(focusFrames).toHaveLength(8) const expectedFocusPng = encodeIndexedPng({ width: 160, height: 20, pixels: stitchIndexed( 160, 20, focusFrames.slice(0, 8).map((frame, i) => ({ frame, x: i * 20, y: 0 })), ), palette: pl2.rgb, transparentIndex: 0, }) expect(Buffer.compare(Buffer.from(focus16Bytes), Buffer.from(expectedFocusPng))).toBe(0) } }) it('verifies D2Client.dll 0x6fb53580 computeNpcDialogLayout auto-sizing, anchor, and 800x600 clamping', () => { const wp = new WorldPanelsHud() expect(wp.openNpcMenuByName('Charsi', 400, 220)).toBe(true) const { font } = createMockCanvasAndFont() const opts = wp.npcMenu!.options! // Charsi options: 交談 (3381), 交易/修理 (3334), 取消 (4142) -> 3 options expect(opts.map(o => ({ id: o.id, strIdx: o.strIdx, labelZh: o.labelZh }))).toEqual([ { id: 'talk', strIdx: 3381, labelZh: '交談' }, { id: 'trade', strIdx: 3334, labelZh: '交易/修理' }, { id: 'cancel', strIdx: 4142, labelZh: '取消' }, ]) // Initial selection is option 0 ('talk') per 0x6fb53829 -> 0x6fb53370 expect(wp.npcMenu!.hoveredOptionIdx).toBe(0) // With mock font (10px per char): 'Charsi' is 60px, '交易/修理' is 50px -> maxLineWidth = 60 // width = 60 + 20 = 80; height = (21 + 3 * 15) + 15 = 81 // left = 400 - 40 = 360; top = 220 - 21 = 199 const layout = computeNpcDialogLayout('Charsi', opts, 400, 220, font) expect(layout).toEqual({ left: 360, top: 199, width: 80, height: 81, headerY: 220, optionYs: [235, 250, 265], }) // Viewport clamping (0x6fb53624..0x6fb5366c): const clampedTopLeft = computeNpcDialogLayout('Charsi', opts, 5, 5, font) expect(clampedTopLeft.left).toBe(10) expect(clampedTopLeft.top).toBe(10) const clampedBottomRight = computeNpcDialogLayout('Charsi', opts, 795, 590, font) expect(clampedBottomRight.left).toBe(800 - 80) expect(clampedBottomRight.top).toBe(600 - 81 - 48) }) it('draws NPC popup menu with boxpieces.dc6 stone frame (0x6fb6e4c0), Font16 gold header, blue selected option, white unselected options, and no CSS strokeRect or divider', () => { const wp = new WorldPanelsHud() expect(wp.openNpcMenuByName('Charsi', 400, 220)).toBe(true) const { ctx, font, drawImageCalls, fillRectCalls, strokeRectCalls, fontCalls } = createMockCanvasAndFont() const boxPiecesImg = createFakeImage(308, 15, 'boxPieces') // Fail-fast if boxPiecesImg is missing in drawStoneBoxFrame expect(() => drawStoneBoxFrame(ctx, 360, 199, 80, 81, null)).toThrow( /Missing required baked UI asset boxpieces\.png/, ) wp.drawNpcMenu(ctx, font, { boxPiecesImg }) // 1. Dark translucent background fill at (left + 1, top, width - 2, height - 2) -> (361, 199, 78, 79), NO hover fillRect! expect(fillRectCalls).toEqual([[361, 199, 78, 79]]) expect(strokeRectCalls).toHaveLength(0) expect(ctx.beginPath).not.toHaveBeenCalled() // 2. Stone border pieces drawn from boxPiecesImg, ending with the 4 corners (frames 0, 1, 8, 9) const bpCalls = drawImageCalls.filter(c => c.tag === 'boxPieces') expect(bpCalls.length).toBeGreaterThanOrEqual(12) expect(bpCalls.slice(-4).map(c => c.args)).toEqual([ [0 * 14, 0, 14, 15, 360, 199 + 12 - 15, 14, 15], [1 * 14, 0, 14, 15, 360 + 80 - 12, 199 + 12 - 15, 14, 15], [8 * 14, 0, 14, 15, 360, 199 + 81 + 1 - 15, 14, 15], [9 * 14, 0, 14, 15, 360 + 80 - 12, 199 + 81 + 1 - 15, 14, 15], ]) // 3. Font16 text lines: Gold header ('Charsi'), Blue selected option 0 ('交談'), White unselected options ('交易/修理', '取消') expect(fontCalls).toEqual([ { type: 'drawText', text: 'Charsi', x: 400, y: 220, size: 'font16', color: 'gold' }, { type: 'drawText', text: '交談', x: 400, y: 235, size: 'font16', color: 'blue' }, { type: 'drawText', text: '交易/修理', x: 400, y: 250, size: 'font16', color: 'white' }, { type: 'drawText', text: '取消', x: 400, y: 265, size: 'font16', color: 'white' }, ]) // 4. Open-interval hit-testing (0x6fb534b0): move to option 1 (rowY = 250, hit y ∈ (239, 254), hit x ∈ (375, 425)) const layout = wp.getNpcMenuLayout(font)! expect(hitTestNpcDialogOption(layout, 375, 248)).toBeNull() expect(hitTestNpcDialogOption(layout, 425, 248)).toBeNull() expect(hitTestNpcDialogOption(layout, 400, 239)).toBeNull() expect(hitTestNpcDialogOption(layout, 400, 254)).toBeNull() expect(hitTestNpcDialogOption(layout, 400, 248)).toBe(1) wp.handleNpcMenuMove(400, 248, font) expect(wp.npcMenu!.hoveredOptionIdx).toBe(1) // Moving outside selectable options preserves current selection per 0x6fb533de wp.handleNpcMenuMove(365, 210, font) expect(wp.npcMenu!.hoveredOptionIdx).toBe(1) // Clicking option 1 ('交易/修理') opens vendor in 'trade' mode and closes npcMenu let openedVendorMode: string | null = null wp.handleNpcMenuClick( 400, 248, { onOpenVendor: (_desc, mode) => { openedVendorMode = mode }, onIdentifyAll: () => {}, }, font, ) expect(openedVendorMode).toBe('trade') expect(wp.npcMenu).toBeNull() }) })