642 lines
26 KiB
TypeScript
642 lines
26 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'
|
|
<<<<<<< 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()
|
|
})
|
|
})
|
|
|