diablo2-web/tests/vendor-panel-ui.test.ts

475 lines
18 KiB
TypeScript

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('關閉')
})
})