diff --git a/public/ui/manifest.json b/public/ui/manifest.json index eb956a8..8582bb0 100644 --- a/public/ui/manifest.json +++ b/public/ui/manifest.json @@ -1,6 +1,6 @@ { "version": "1.13c", - "decodedDc6Count": 680, + "decodedDc6Count": 681, "dc6DecodeFailures": 0, "atlasWidth": 1024, "atlasHeight": 3573, @@ -2503,9 +2503,9 @@ "skl": "flpskl", "skz": "flpskl", "hrb": "flphrb", - "cm1": "flpmss", - "cm2": "flptrch", - "cm3": "flpmss", + "cm1": "flpchm1", + "cm2": "flpchm2", + "cm3": "flpchm3", "rps": "flprps", "rpl": "flprpl", "bps": "flpbps", @@ -3211,7 +3211,7 @@ "flpci1": "flpci1", "Ormus' Robes": "flpqlt_blac", "flpqlt_blac": "flpqlt_blac", - "Gheed's Fortune": "flpmss", + "Gheed's Fortune": "flpchm3", "Stormlash": "flpfla_dgry", "flpfla_dgry": "flpfla_dgry", "Halaberd's Reign": "flpba5", @@ -3482,7 +3482,7 @@ "Sander's Superstition": "flpbwn", "gold": "flpgld", "Horadric Cube": "flpbox", - "gheeds": "flpmss", + "gheeds": "flpchm3", "anni": "flpmss", "torch": "flptrch", "flphax": "flphax", @@ -13134,6 +13134,7 @@ "stashBg": "/ui/stash-bg.png", "cubeBg": "/ui/cube-bg.png", "vendorBg": "/ui/vendor-bg.png", + "vendorTabs": "/ui/vendor-tabs.png", "popbelt": "/ui/popbelt.png", "minipanel": "/ui/minipanel.png", "minipanelBtns": "/ui/minipanel-btns.png", diff --git a/public/ui/vendor-bg.png b/public/ui/vendor-bg.png index a943034..679b3b1 100644 Binary files a/public/ui/vendor-bg.png and b/public/ui/vendor-bg.png differ diff --git a/public/ui/vendor-tabs.png b/public/ui/vendor-tabs.png new file mode 100644 index 0000000..f3953d8 Binary files /dev/null and b/public/ui/vendor-tabs.png differ diff --git a/scripts/pack-ui-assets.ts b/scripts/pack-ui-assets.ts index 943c978..45ec9d9 100644 --- a/scripts/pack-ui-assets.ts +++ b/scripts/pack-ui-assets.ts @@ -280,7 +280,21 @@ async function main(): Promise { const cubeBg = await decodeUiDc6('data\\global\\ui\\Panel\\supertransmogrifier.dc6') savePng('cube-bg.png', 320, 432, stitch320x432Panel(cubeBg, 0), false) - const vendorBg = await decodeUiDc6('data\\global\\ui\\Panel\\NPCInv.dc6') + const vendorBg = await decodeUiDc6('data\\global\\ui\\Panel\\buysell.dc6') + const vbf = vendorBg.groups[0]?.frames + const expectedVendorBgFrames = [ + { w: 256, h: 256 }, + { w: 64, h: 256 }, + { w: 256, h: 176 }, + { w: 64, h: 176 }, + ] + if ( + !vbf || + vbf.length < 4 || + expectedVendorBgFrames.some((exp, i) => vbf[i]?.width !== exp.w || vbf[i]?.height !== exp.h) + ) { + throw new Error('Invalid buysell.dc6 frame geometry: expected 4 frames (256x256, 64x256, 256x176, 64x176)') + } savePng('vendor-bg.png', 320, 432, stitch320x432Panel(vendorBg, 0), false) // 6. Pop-up Belt, Mini-panel, Buttons, Cursors, and Equipment Silhouettes @@ -306,6 +320,14 @@ async function main(): Promise { const bsStrip = stitchHorizontalStrip(buySellBtn.groups[0]!.frames) savePng('buysellbtn.png', bsStrip.width, bsStrip.height, bsStrip.pixels) + const buySellTabs = await decodeUiDc6('data\\global\\ui\\Panel\\buyselltabs.dc6') + const bstFrames = buySellTabs.groups[0]?.frames + if (!bstFrames || bstFrames.length !== 8 || bstFrames.some(f => f.width !== 79 || f.height !== 31)) { + throw new Error('Invalid buyselltabs.dc6 frame geometry: expected 8 frames of 79x31') + } + const bstStrip = stitchHorizontalStrip(bstFrames) + savePng('vendor-tabs.png', bstStrip.width, bstStrip.height, bstStrip.pixels) + const levelBtn = await decodeUiDc6('data\\global\\ui\\Panel\\Level.dc6') const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames) savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels) diff --git a/scripts/pack-ui.ts b/scripts/pack-ui.ts index 413a472..e215a51 100644 --- a/scripts/pack-ui.ts +++ b/scripts/pack-ui.ts @@ -323,7 +323,21 @@ async function main(): Promise { const cubeBg = await decodeUiDc6('data/global/ui/Panel/supertransmogrifier.dc6') savePng('cube-bg.png', 320, 432, stitch320x432Panel(cubeBg, 0), false) - const vendorBg = await decodeUiDc6('data/global/ui/Panel/NPCInv.dc6') + const vendorBg = await decodeUiDc6('data/global/ui/Panel/buysell.dc6') + const vbf = vendorBg.groups[0]?.frames + const expectedVendorBgFrames = [ + { w: 256, h: 256 }, + { w: 64, h: 256 }, + { w: 256, h: 176 }, + { w: 64, h: 176 }, + ] + if ( + !vbf || + vbf.length < 4 || + expectedVendorBgFrames.some((exp, i) => vbf[i]?.width !== exp.w || vbf[i]?.height !== exp.h) + ) { + throw new Error('Invalid buysell.dc6 frame geometry: expected 4 frames (256x256, 64x256, 256x176, 64x176)') + } savePng('vendor-bg.png', 320, 432, stitch320x432Panel(vendorBg, 0), false) // 6. Pop-up Belt, Mini-panel, Buttons, Cursors @@ -349,6 +363,14 @@ async function main(): Promise { const bsStrip = stitchHorizontalStrip(buySellBtn.groups[0]!.frames) savePng('buysellbtn.png', bsStrip.width, bsStrip.height, bsStrip.pixels) + const buySellTabs = await decodeUiDc6('data/global/ui/Panel/buyselltabs.dc6') + const bstFrames = buySellTabs.groups[0]?.frames + if (!bstFrames || bstFrames.length !== 8 || bstFrames.some(f => f.width !== 79 || f.height !== 31)) { + throw new Error('Invalid buyselltabs.dc6 frame geometry: expected 8 frames of 79x31') + } + const bstStrip = stitchHorizontalStrip(bstFrames) + savePng('vendor-tabs.png', bstStrip.width, bstStrip.height, bstStrip.pixels) + const levelBtn = await decodeUiDc6('data/global/ui/Panel/Level.dc6') const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames) savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels) @@ -994,6 +1016,7 @@ async function main(): Promise { stashBg: '/ui/stash-bg.png', cubeBg: '/ui/cube-bg.png', vendorBg: '/ui/vendor-bg.png', + vendorTabs: '/ui/vendor-tabs.png', popbelt: '/ui/popbelt.png', minipanel: '/ui/minipanel.png', minipanelBtns: '/ui/minipanel-btns.png', diff --git a/scripts/verify-vendor-ui.ts b/scripts/verify-vendor-ui.ts index e4df10e..c7ea822 100644 --- a/scripts/verify-vendor-ui.ts +++ b/scripts/verify-vendor-ui.ts @@ -20,9 +20,10 @@ function resolveChromeBinary(): string { } async function main() { - const outputDir = - process.env.VENDOR_AUDIT_OUT ?? - '/usr/local/google/home/taodao/.gemini/jetski/brain/ba5784fe-f165-4cff-aa7e-c3cfdf22c420' + const outputDir = process.env.VENDOR_AUDIT_OUT + if (!outputDir) { + throw new Error('VENDOR_AUDIT_OUT environment variable is required') + } mkdirSync(outputDir, { recursive: true }) ensurePacksAvailable() @@ -128,9 +129,64 @@ async function main() { await sleep(200) } - // 1. Open Charsi NPC Menu + // 1. Exhaustive audit of all 16 trade NPCs and 6 gamble NPCs + const fullAudit = await evalJs(`(() => { + const hud = window.__d2webHudInstance; + const wp = hud.worldPanels; + hud.charSheet.attrs.level = 30; + const tradeNpcs = [ + 'Akara', 'Charsi', 'Gheed', + 'Fara', 'Drognan', 'Elzix', 'Lysander', + 'Ormus', 'Hratli', 'Asheara', 'Alkor', + 'Halbu', 'Jamella', + 'Larzuk', 'Malah', 'Anya', + ]; + const gambleNpcs = ['Gheed', 'Elzix', 'Alkor', 'Jamella', 'Anya', 'Nihlathak']; + const tradeResults = []; + for (const name of tradeNpcs) { + const ok = hud.openVendorSession(name, 'trade', 0); + if (!ok) throw new Error('Failed to open trade vendor: ' + name); + hud.render(); + const frames = [0, 1, 2, 3].map(slot => wp.getVendorButtonFrame(slot)); + tradeResults.push({ + name, + hcIdx: wp.activeVendorDescriptor.hcIdx, + canRepair: wp.activeVendorDescriptor.canRepair, + layout: wp.getActiveVendorButtonLayout(), + frames, + activeTab: wp.activeVendorTab, + itemsCount: wp.getActiveVendorPlacements().length, + }); + } + const gambleResults = []; + for (const name of gambleNpcs) { + const ok = hud.openVendorSession(name, 'gamble', 1); + if (!ok) throw new Error('Failed to open gamble vendor: ' + name); + hud.render(); + const frames = [0, 1, 2, 3].map(slot => wp.getVendorButtonFrame(slot)); + gambleResults.push({ + name, + hcIdx: wp.activeVendorDescriptor.hcIdx, + layout: wp.getActiveVendorButtonLayout(), + frames, + itemsCount: wp.getActiveVendorPlacements().length, + }); + } + return { tradeCount: tradeResults.length, gambleCount: gambleResults.length, tradeResults, gambleResults }; + })()`) + console.log( + `Audited ${fullAudit.tradeCount} trade NPCs and ${fullAudit.gambleCount} gamble NPCs:`, + JSON.stringify({ + blacksmiths: fullAudit.tradeResults.filter((r: any) => r.canRepair).map((r: any) => `${r.name}:${r.frames.join(',')}`), + traders: fullAudit.tradeResults.filter((r: any) => !r.canRepair).map((r: any) => `${r.name}:${r.frames.join(',')}`), + gamblers: fullAudit.gambleResults.map((r: any) => `${r.name}:${r.frames.join(',')}`), + }), + ) + + // 2. Open Charsi NPC Menu const menuInfo = await evalJs(`(() => { const hud = window.__d2webHudInstance; + hud.closeAllPanels(); hud.openNpcMenuByName('Charsi', 400, 220); hud.render(); return { @@ -139,16 +195,17 @@ async function main() { }; })()`) console.log('NPC Menu:', JSON.stringify(menuInfo)) - await sleep(250) + await sleep(200) await takeScreenshot('vendor_npc_menu_charsi') - // 2. Open Charsi Trade/Repair 4-tab 10x10 Vendor UI + // 3. Open Charsi Trade/Repair 4-tab 10x10 Vendor UI (buysell.dc6 + buyselltabs.dc6 + [2,4,6,18] buttons) const charsiInfo = await evalJs(`(() => { const hud = window.__d2webHudInstance; hud.charSheet.attrs.level = 24; hud.openVendorSession('Charsi', 'trade', 0); const wp = hud.worldPanels; - wp.activeVendorTab = 'armor'; + wp.selectVendorTab('armor'); + wp.hoveredVendorButtonSlot = null; const armorItems = wp.vendorTabPlacements.armor; const docking = hud.getDockingLayout(); const deltaLeft = -docking.marginW; @@ -156,8 +213,8 @@ async function main() { const first = armorItems[0]; wp.hoveredVendorItem = { item: first.item, - x: 80 + 17 + first.col * 29 + 14, - y: 60 + 64 + first.row * 29 + 14, + x: 80 + 16 + first.col * 29 + 14, + y: 60 + 63 + first.row * 29 + 14, col: first.col, row: first.row, }; @@ -171,22 +228,67 @@ async function main() { return { leftPanel: hud.leftPanel, rightPanel: hud.rightPanel, + buttonLayout: wp.getActiveVendorButtonLayout(), armorCount: wp.vendorTabPlacements.armor.length, weapons1Count: wp.vendorTabPlacements.weapons1.length, + weapons2Count: wp.vendorTabPlacements.weapons2.length, miscCount: wp.vendorTabPlacements.misc.length, firstItem: armorItems[0] ? { nameZh: armorItems[0].item.nameZh, buyCost: armorItems[0].item.buyCost } : null, }; })()`) console.log('Charsi Trade UI:', JSON.stringify(charsiInfo)) - await sleep(250) + await sleep(200) await takeScreenshot('vendor_trade_charsi') - // 3. Open Gheed Gamble UI + // 4. Charsi Repair Latched + Repair All Hover Tooltip + const charsiRepairInfo = await evalJs(`(() => { + const hud = window.__d2webHudInstance; + const wp = hud.worldPanels; + wp.hoveredVendorItem = null; + hud.inventory.hoveredItem = null; + // Damage an equipped item to produce a non-zero Repair All cost + const equippedList = Object.values(hud.inventory.equipped).filter(Boolean); + if (equippedList.length > 0 && equippedList[0].durability) { + equippedList[0].durability.current = Math.max(1, Math.floor(equippedList[0].durability.max / 2)); + } + wp.vendorTradeState = 'repair'; + wp.hoveredVendorButtonSlot = 3; + hud.render(); + return { + vendorTradeState: wp.vendorTradeState, + repairFrame: wp.getVendorButtonFrame(2), + repairAllCost: wp.getRepairAllCost(hud.inventory), + }; + })()`) + console.log('Charsi Repair UI:', JSON.stringify(charsiRepairInfo)) + await sleep(200) + await takeScreenshot('vendor_repair_charsi') + + // 5. Akara Trader UI ([Buy, Sell, Blank, Close] + Close hover) + const akaraInfo = await evalJs(`(() => { + const hud = window.__d2webHudInstance; + hud.openVendorSession('Akara', 'trade', 0); + const wp = hud.worldPanels; + wp.hoveredVendorItem = null; + hud.inventory.hoveredItem = null; + wp.hoveredVendorButtonSlot = 3; + hud.render(); + return { + buttonLayout: wp.getActiveVendorButtonLayout(), + activeTab: wp.activeVendorTab, + }; + })()`) + console.log('Akara Trade UI:', JSON.stringify(akaraInfo)) + await sleep(200) + await takeScreenshot('vendor_trade_akara') + + // 6. Open Gheed Gamble UI const gheedInfo = await evalJs(`(() => { const hud = window.__d2webHudInstance; hud.charSheet.attrs.level = 60; hud.openVendorSession('Gheed', 'gamble', 1); const wp = hud.worldPanels; + wp.hoveredVendorButtonSlot = null; const gambleItems = wp.gamblePlacements; const docking = hud.getDockingLayout(); const deltaLeft = -docking.marginW; @@ -194,8 +296,8 @@ async function main() { const first = gambleItems[0]; wp.hoveredVendorItem = { item: first.item, - x: 80 + 17 + first.col * 29 + 14, - y: 60 + 64 + first.row * 29 + 14, + x: 80 + 16 + first.col * 29 + 14, + y: 60 + 63 + first.row * 29 + 14, col: first.col, row: first.row, }; @@ -207,12 +309,13 @@ async function main() { } hud.render(); return { + buttonLayout: wp.getActiveVendorButtonLayout(), gambleCount: gambleItems.length, firstGamble: gambleItems[0] ? { nameZh: gambleItems[0].item.nameZh, buyCost: gambleItems[0].item.buyCost } : null, }; })()`) console.log('Gheed Gamble UI:', JSON.stringify(gheedInfo)) - await sleep(250) + await sleep(200) await takeScreenshot('vendor_gamble_gheed') } finally { ws?.close() diff --git a/src/ui/baked-ui-meta.ts b/src/ui/baked-ui-meta.ts index d4148e7..ddc13b9 100644 --- a/src/ui/baked-ui-meta.ts +++ b/src/ui/baked-ui-meta.ts @@ -83,7 +83,7 @@ export const ACT_QUEST_ASSET_MAP: Readonly { this.cursor.handleMouseUp() + this.worldPanels.releaseMomentaryVendorButton() }) this.hudCanvas.addEventListener('contextmenu', (e) => { @@ -875,7 +880,7 @@ export class HudManager { } } - // Vendor Repair or Quick-Sell from Player Inventory + // Vendor Repair or Sell/Quick-Sell from Player Inventory if (this.leftPanel === 'vendor') { if ( rightX >= INV_GRID_ORIGIN.x && @@ -890,12 +895,12 @@ export class HudManager { ) if (hitIdx !== -1) { const hit = this.inventory.gridItems[hitIdx]! - if (this.worldPanels.repairCursorMode) { + if (this.worldPanels.vendorTradeState === 'repair') { this.worldPanels.repairSingleUiItem(hit.item, this.inventory) this.syncPublishedState() return } - if (e.shiftKey || e.button === 2) { + if (this.worldPanels.vendorTradeState === 'sell' || e.shiftKey || e.button === 2) { const res = this.worldPanels.sellToActiveVendor(hit.item, this.inventory) if (res.ok) { this.inventory.gridItems.splice(hitIdx, 1) @@ -905,7 +910,7 @@ export class HudManager { return } } - } else if (this.worldPanels.repairCursorMode && this.inventory.hoveredItem) { + } else if (this.worldPanels.vendorTradeState === 'repair' && this.inventory.hoveredItem) { this.worldPanels.repairSingleUiItem(this.inventory.hoveredItem.item, this.inventory) this.syncPublishedState() return @@ -1110,12 +1115,14 @@ export class HudManager { stashBgImg: this.images.get('stashBg') ?? null, cubeBgImg: this.images.get('cubeBg') ?? null, vendorBgImg: this.images.get('vendorBg') ?? null, + vendorTabsImg: this.images.get('vendorTabs') ?? null, buySellBtnImg: this.images.get('buySellBtn') ?? null, questsAtlasImg: this.images.get('questsAtlas') ?? null, itemsAtlasImg: this.images.get('itemsAtlas') ?? null, waypointTabsImg: this.images.get('waypointTabs') ?? null, waypointIconsImg: this.images.get('waypointIcons') ?? null, questTabsImg: this.images.get('questTabs') ?? null, + inventory: this.inventory, }, this.font, ) diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 02bf6fa..6e9bd7d 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -3,7 +3,7 @@ * 1. Quest Log Panel (`Menu\questbackground.dc6`, hotkey `Q`) * 2. Waypoint Teleport Panel (`Menu\waygatebackground.dc6`, hotkey `V` or clicking scene Waypoint) * 3. Private Stash (`Panel\TradeStash.dc6`, 6x8 grid) & Horadric Cube (`Panel\supertransmogrifier.dc6`, 3x4 grid) - * 4. Town NPC Stone Interaction Menu (`npcmenu.cpp`) & Vendor Panel (`Panel\NPCInv.dc6`) + * 4. Town NPC Stone Interaction Menu (`npcmenu.cpp`) & Vendor Panel (`Panel\buysell.dc6`, `Panel\buyselltabs.dc6`, `Panel\buysellbtn.dc6`) * 5. Monster Target Top Plaque (`text.cpp`), Item Hover Tooltip (`showitems.cpp`), * Ground `Alt` Item Labels, Area Entry Gothic Banner (`Font30`), and Escape Menu (`menupanel.dc6`). */ @@ -34,16 +34,129 @@ export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const export const VENDOR_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const -export const VENDOR_GRID_ORIGIN = { x: 80 + 17, y: 60 + 64, cols: 10, rows: 10, cellPx: 29 } as const -export const VENDOR_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } as const -export const VENDOR_REPAIR_BTN_BOUNDS = { x: 80 + 18, y: 60 + 386, w: 76, h: 26 } as const -export const VENDOR_REPAIR_ALL_BTN_BOUNDS = { x: 80 + 102, y: 60 + 386, w: 96, h: 26 } as const +/** `Inventory.txt` row `Monster2`: gridLeft=96 (`80 + 16`), gridTop=123 (`60 + 63`), 10x10 cells of 29x29px */ +export const VENDOR_GRID_ORIGIN = { x: 80 + 16, y: 60 + 63, cols: 10, rows: 10, cellPx: 29 } as const -export const VENDOR_TABS: readonly { readonly id: VendorTabId; readonly labelZh: string; readonly labelEn: string }[] = [ - { id: 'armor', labelZh: '护甲', labelEn: 'Armor' }, - { id: 'weapons1', labelZh: '武器 I', labelEn: 'Weapons I' }, - { id: 'weapons2', labelZh: '武器 II', labelEn: 'Weapons II' }, - { id: 'misc', labelZh: '杂物', labelEn: 'Misc' }, +/** + * D2Client.dll vendor button geometry (`0x6fb45df1`, `0x6fb4c27a`, `0x6fb3f260`): + * - Mode-3 button table X offsets (relative to panel X0=80): `{116, 169, 221, 273}` + * - Sprite draw position: `x = X0 + tableX - 1` (`115, 168, 220, 272` relative), `y = panelTop + 385` (`445` absolute) + * - Hit-test open intervals: `relX ∈ (tableX, tableX + 45)`, `relY ∈ (371, 415)` + */ +export const VENDOR_BUTTON_TABLE_X = [116, 169, 221, 273] as const +export const VENDOR_BUTTON_DRAW_Y = 385 +export const VENDOR_BUTTON_HIT_Y_MIN = 371 +export const VENDOR_BUTTON_HIT_Y_MAX = 415 +export const VENDOR_BUTTON_HIT_W = 45 + +export type VendorButtonKind = 'blank' | 'buy' | 'sell' | 'repair' | 'repairAll' | 'close' +export type VendorTradeState = 'idle' | 'buy' | 'sell' | 'repair' + +export interface VendorButtonSpec { + readonly kind: VendorButtonKind + readonly enabled: boolean + readonly baseFrame: number + readonly pressedFrame: number + readonly strIdx: number + readonly labelZh: string + readonly labelEn: string + readonly paramA: number +} + +export const VENDOR_BUTTON_SPECS: Readonly> = { + blank: { + kind: 'blank', + enabled: false, + baseFrame: 0, + pressedFrame: 1, + strIdx: 0, + labelZh: '', + labelEn: '', + paramA: 1, + }, + buy: { + kind: 'buy', + enabled: true, + baseFrame: 2, + pressedFrame: 3, + strIdx: 3335, + labelZh: '買', + labelEn: 'Buy', + paramA: 2, + }, + sell: { + kind: 'sell', + enabled: true, + baseFrame: 4, + pressedFrame: 5, + strIdx: 3336, + labelZh: '賣', + labelEn: 'Sell', + paramA: 3, + }, + repair: { + kind: 'repair', + enabled: true, + baseFrame: 6, + pressedFrame: 7, + strIdx: 3338, + labelZh: '修復', + labelEn: 'Repair', + paramA: 4, + }, + repairAll: { + kind: 'repairAll', + enabled: true, + baseFrame: 18, + pressedFrame: 19, + strIdx: 10095, + labelZh: '修復所有裝備', + labelEn: 'Repair all equipment', + paramA: 1, + }, + close: { + kind: 'close', + enabled: true, + baseFrame: 10, + pressedFrame: 11, + strIdx: 4144, + labelZh: '關閉', + labelEn: 'Close', + paramA: 9, + }, +} + +/** D2Client.dll `0x6fb3f430` jump tables (`0x6fb3f5d4`, `0x6fb3f5e0`, `0x6fb3f618`) */ +export const BLACKSMITH_CLASS_IDS: ReadonlySet = new Set([154, 178, 253, 257, 511]) +export const TRADER_CLASS_IDS: ReadonlySet = new Set([147, 148, 177, 199, 202, 252, 254, 255, 405, 512, 513]) + +export function resolveVendorButtonLayout( + hcIdx: number, + mode: 'trade' | 'gamble', +): readonly [VendorButtonKind, VendorButtonKind, VendorButtonKind, VendorButtonKind] { + if (mode === 'gamble') return ['buy', 'sell', 'blank', 'close'] + if (BLACKSMITH_CLASS_IDS.has(hcIdx)) return ['buy', 'sell', 'repair', 'repairAll'] + if (TRADER_CLASS_IDS.has(hcIdx)) return ['buy', 'sell', 'blank', 'close'] + throw new Error(`D2Client 0x6fb3f430 assigns no trade buttons to MonStats class ${hcIdx}`) +} + +/** D2Client.dll tab table at `0x6fb91178` (stride 18) & `buyselltabs.dc6` (`0x6fb45e5e–0x6fb45f5d`) */ +export const VENDOR_TABS: readonly { + readonly id: VendorTabId + readonly index: 0 | 1 | 2 | 3 + readonly xOffset: number + readonly width: number + readonly height: number + readonly centerX: number + readonly textY: number + readonly strIdx: number + readonly labelZh: string + readonly labelEn: string +}[] = [ + { id: 'armor', index: 0, xOffset: 0, width: 79, height: 31, centerX: 42, textY: 20, strIdx: 4036, labelZh: '裝甲', labelEn: 'Armor' }, + { id: 'weapons1', index: 1, xOffset: 80, width: 79, height: 31, centerX: 121, textY: 20, strIdx: 4037, labelZh: '武器', labelEn: 'Weapons' }, + { id: 'weapons2', index: 2, xOffset: 160, width: 79, height: 31, centerX: 201, textY: 20, strIdx: 4037, labelZh: '武器', labelEn: 'Weapons' }, + { id: 'misc', index: 3, xOffset: 240, width: 79, height: 31, centerX: 281, textY: 20, strIdx: 4039, labelZh: '其他', labelEn: 'Misc' }, ] as const export type NpcMenuActionId = 'talk' | 'trade' | 'gamble' | 'identify' | 'cancel' @@ -226,7 +339,10 @@ export class WorldPanelsHud { activeVendorTab: VendorTabId = 'armor' vendorDifficulty: 0 | 1 | 2 = 0 vendorCharLevel = 85 - repairCursorMode = false + vendorTradeState: VendorTradeState = 'idle' + hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null + pressedMomentarySlot: 0 | 1 | 2 | 3 | null = null + activePlayerInventory: InventoryPanel | null = null vendorStatusMsg: { text: string; color: D2ColorCode; untilMs: number } | null = null hoveredVendorItem: { item: UiInventoryItem; x: number; y: number; col: number; row: number } | null = null vendorTabPlacements: Record = { @@ -237,6 +353,97 @@ export class WorldPanelsHud { } gamblePlacements: GridPlacement[] = [] + get repairCursorMode(): boolean { + return this.vendorTradeState === 'repair' + } + + set repairCursorMode(val: boolean) { + this.vendorTradeState = val ? 'repair' : 'idle' + } + + releaseMomentaryVendorButton(): void { + this.pressedMomentarySlot = null + } + + isVendorTabPresent(tabId: VendorTabId): boolean { + if (this.vendorMode === 'gamble') { + return tabId === 'armor' + } + return this.vendorTabPlacements[tabId].length > 0 + } + + /** + * D2Client.dll `0x6fb3f710` (`0x6fb3f7a1–0x6fb3f873`): + * If requested tab is empty, cycle forward `(idx + 1) % 4` up to 4 tries to find a non-empty tab. + */ + selectVendorTab(requestedTab: VendorTabId): void { + if (this.vendorMode === 'gamble') { + this.activeVendorTab = 'armor' + return + } + const startIdx = VENDOR_TABS.findIndex(t => t.id === requestedTab) + if (startIdx === -1) { + throw new Error(`Invalid vendor tab: ${requestedTab}`) + } + for (let step = 0; step < 4; step++) { + const idx = (startIdx + step) % 4 + const candidate = VENDOR_TABS[idx]! + if (this.vendorTabPlacements[candidate.id].length > 0) { + this.activeVendorTab = candidate.id + return + } + } + this.activeVendorTab = VENDOR_TABS[startIdx]!.id + } + + /** + * D2Client.dll `0x6fb4c27a` (mousedown) & `0x6fb3f260` (hover): + * Open intervals `relX ∈ (tableX, tableX + 45)`, `relY ∈ (371, 415)`, enabled slots only. + */ + hitTestVendorButtonSlot(logicalX: number, logicalY: number): 0 | 1 | 2 | 3 | null { + if (!this.activeVendorDescriptor) return null + const relX = logicalX - VENDOR_PANEL_ORIGIN.x + const relY = logicalY - VENDOR_PANEL_ORIGIN.y + if (relY <= VENDOR_BUTTON_HIT_Y_MIN || relY >= VENDOR_BUTTON_HIT_Y_MAX) return null + const layout = resolveVendorButtonLayout(this.activeVendorDescriptor.hcIdx, this.vendorMode) + for (let slot = 0 as 0 | 1 | 2 | 3; slot < 4; slot = (slot + 1) as 0 | 1 | 2 | 3) { + const kind = layout[slot]! + const spec = VENDOR_BUTTON_SPECS[kind] + if (!spec.enabled) continue + const tableX = VENDOR_BUTTON_TABLE_X[slot]! + if (relX > tableX && relX < tableX + VENDOR_BUTTON_HIT_W) { + return slot + } + } + return null + } + + getActiveVendorButtonLayout(): readonly [VendorButtonKind, VendorButtonKind, VendorButtonKind, VendorButtonKind] { + if (!this.activeVendorDescriptor) { + throw new Error('Cannot resolve vendor button layout without active vendor descriptor') + } + return resolveVendorButtonLayout(this.activeVendorDescriptor.hcIdx, this.vendorMode) + } + + getVendorButtonFrame( + slot: 0 | 1 | 2 | 3, + layout?: readonly [VendorButtonKind, VendorButtonKind, VendorButtonKind, VendorButtonKind], + ): number { + if (!layout && !this.activeVendorDescriptor) { + throw new Error('Cannot resolve vendor button frame without active vendor descriptor') + } + const resolvedLayout = + layout ?? resolveVendorButtonLayout(this.activeVendorDescriptor!.hcIdx, this.vendorMode) + const kind = resolvedLayout[slot]! + const spec = VENDOR_BUTTON_SPECS[kind] + if (!spec.enabled) return spec.baseFrame + const isPressed = + kind === 'buy' || kind === 'sell' || kind === 'repair' + ? this.vendorTradeState === kind + : this.pressedMomentarySlot === slot + return isPressed ? spec.pressedFrame : spec.baseFrame + } + showVendorStatus(text: string, color: D2ColorCode = 'gold', nowMs = performance.now()): void { this.vendorStatusMsg = { text, color, untilMs: nowMs + 2800 } } @@ -301,11 +508,16 @@ export class WorldPanelsHud { : getTownNpcDescriptorByName(descriptorOrName) if (!descriptor || descriptor.vendorId === null) return false + // Fail-fast validation against D2Client.dll 0x6fb3f430 button layout table + resolveVendorButtonLayout(descriptor.hcIdx, mode) + this.activeVendorDescriptor = descriptor this.vendorMode = mode this.vendorCharLevel = Math.max(1, cLvl) this.vendorDifficulty = difficulty - this.repairCursorMode = false + this.vendorTradeState = 'idle' + this.hoveredVendorButtonSlot = null + this.pressedMomentarySlot = null this.hoveredVendorItem = null const tables = getEmbeddedDropTables() @@ -334,9 +546,8 @@ export class WorldPanelsHud { }) } - // Select first non-empty tab (defaulting to armor, or misc if armor is empty) - const firstNonEmpty = tabIds.find(t => this.vendorTabPlacements[t].length > 0) ?? 'misc' - this.activeVendorTab = firstNonEmpty + // D2Client.dll 0x6fb411a0 -> 0x6fb3f710(0): start at tab 0 ('armor') and cycle to first non-empty tab + this.selectVendorTab('armor') return true } @@ -477,6 +688,9 @@ export class WorldPanelsHud { if (!hit.item.permStore) { placements.splice(hitIdx, 1) + if (this.vendorMode === 'trade' && placements.length === 0) { + this.selectVendorTab(this.activeVendorTab) + } break } } @@ -536,6 +750,9 @@ export class WorldPanelsHud { permStore: false, } tabPlacements.push({ item: buybackUi, col: freeSlot.col, row: freeSlot.row }) + if (this.vendorTabPlacements[this.activeVendorTab].length === 0) { + this.selectVendorTab(targetTab) + } } } @@ -576,30 +793,55 @@ export class WorldPanelsHud { return { ok: true, cost } } + private collectRepairablePlayerItems(playerInventory: InventoryPanel): { item: UiInventoryItem; cost: number }[] { + const allItems: UiInventoryItem[] = [ + ...(Object.values(playerInventory.equipped).filter(Boolean) as UiInventoryItem[]), + ...playerInventory.gridItems.map(p => p.item), + ] + const result: { item: UiInventoryItem; cost: number }[] = [] + for (const item of allItems) { + if (!item.durability || item.durability.current >= item.durability.max || item.ethereal) continue + const cost = this.getPlayerItemRepairPrice(item) + if (cost <= 0) continue + result.push({ item, cost }) + } + return result + } + + /** + * D2Client.dll `0x6fb3f260` -> D2Common `0x6fabc99a` (`#10095`): + * Computes total repair cost across all damaged repairable equipment and inventory items. + */ + getRepairAllCost(playerInventory?: InventoryPanel | null): number { + if (!this.activeVendorDescriptor?.canRepair) return 0 + const inv = playerInventory ?? this.activePlayerInventory + if (!inv) return 0 + let total = 0 + for (const entry of this.collectRepairablePlayerItems(inv)) { + total += entry.cost + } + return total + } + repairAllUiItems(playerInventory: InventoryPanel): { repairedCount: number; totalCost: number } { if (!this.activeVendorDescriptor?.canRepair) { return { repairedCount: 0, totalCost: 0 } } let repairedCount = 0 let totalCost = 0 - const allItems: UiInventoryItem[] = [ - ...(Object.values(playerInventory.equipped).filter(Boolean) as UiInventoryItem[]), - ...playerInventory.gridItems.map(p => p.item), - ] - for (const item of allItems) { - if (!item.durability || item.durability.current >= item.durability.max || item.ethereal) continue - const cost = this.getPlayerItemRepairPrice(item) - if (cost <= 0) continue + for (const { item, cost } of this.collectRepairablePlayerItems(playerInventory)) { + if (!item.durability) continue if (playerInventory.gold < cost) break + const maxDur = item.durability.max playerInventory.gold -= cost totalCost += cost repairedCount++ ;(item as { durability: { current: number; max: number } }).durability = { - current: item.durability.max, - max: item.durability.max, + current: maxDur, + max: maxDur, } if (item.rawItem) { - item.rawItem.durability = item.durability.max + item.rawItem.durability = maxDur } } if (repairedCount > 0) { @@ -878,6 +1120,9 @@ export class WorldPanelsHud { handleMouseMove(logicalX: number, logicalY: number): void { this.hoveredStashItem = null this.hoveredVendorItem = null + this.hoveredVendorButtonSlot = this.activeVendorDescriptor + ? this.hitTestVendorButtonSlot(logicalX, logicalY) + : null this.hoveredWaypointIdx = null const ox = 80 @@ -1028,13 +1273,16 @@ export class WorldPanelsHud { const ox = 80 const oy = 60 - // Close button (`x = ox + 272, y = oy + 388` or `ox + 18, oy + 385` for non-vendor panels) + // Generic Close button (`x = ox + 272, y = oy + 388` or `ox + 18, oy + 385`) for non-vendor panels only. + // Vendor panels manage their 4 button slots via D2Client.dll 0x6fb3f430 / 0x6fb4c27a (where slot 3 is Repair All on blacksmiths). if ( + kind !== 'vendor' && logicalY >= oy + 380 && logicalY <= oy + 422 && - (logicalX >= ox + 260 || (kind !== 'vendor' && logicalX <= ox + 58)) + (logicalX >= ox + 260 || logicalX <= ox + 58) ) { - this.repairCursorMode = false + this.vendorTradeState = 'idle' + this.pressedMomentarySlot = null callbacks.onClose() return true } @@ -1148,44 +1396,48 @@ export class WorldPanelsHud { return true } } else if (kind === 'vendor') { - // 1. Vendor 4 Tabs (`armor`, `weapons1`, `weapons2`, `misc`) at `oy + 34 .. oy + 62` - if (this.vendorMode === 'trade' && logicalY >= oy + 32 && logicalY <= oy + 62 && logicalX >= ox + 16 && logicalX <= ox + 306) { - const tabIdx = Math.min(3, Math.max(0, Math.floor((logicalX - (ox + 16)) / 72))) - const chosen = VENDOR_TABS[tabIdx] - if (chosen) { + if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { + throw new Error('Cannot handle vendor click without an active vendor descriptor') + } + const relX = logicalX - ox + const relY = logicalY - oy + + // 1. D2Client.dll `0x6fb4c0e5–0x6fb4c233`: Tab click (`relY ∈ [1, 31]`, 80px bands `<=80, <=160, <=240, <=320`) + if (this.vendorMode === 'trade' && relY >= 1 && relY <= 31 && relX >= 0 && relX <= 320) { + const tabIdx = relX <= 80 ? 0 : relX <= 160 ? 1 : relX <= 240 ? 2 : 3 + const chosen = VENDOR_TABS[tabIdx]! + if (this.isVendorTabPresent(chosen.id) && chosen.id !== this.activeVendorTab) { this.activeVendorTab = chosen.id this.hoveredVendorItem = null } return true } - // 2. Repair & Repair All buttons (if active vendor canRepair) - if (this.activeVendorDescriptor?.canRepair && this.vendorMode === 'trade') { - if ( - logicalX >= VENDOR_REPAIR_BTN_BOUNDS.x && - logicalX <= VENDOR_REPAIR_BTN_BOUNDS.x + VENDOR_REPAIR_BTN_BOUNDS.w && - logicalY >= VENDOR_REPAIR_BTN_BOUNDS.y && - logicalY <= VENDOR_REPAIR_BTN_BOUNDS.y + VENDOR_REPAIR_BTN_BOUNDS.h - ) { - this.repairCursorMode = !this.repairCursorMode - this.showVendorStatus( - this.repairCursorMode ? '修理模式:点击右侧装备进行修理' : '已退出修理模式', - this.repairCursorMode ? 'gold' : 'tan', - ) - return true - } - - if ( - logicalX >= VENDOR_REPAIR_ALL_BTN_BOUNDS.x && - logicalX <= VENDOR_REPAIR_ALL_BTN_BOUNDS.x + VENDOR_REPAIR_ALL_BTN_BOUNDS.w && - logicalY >= VENDOR_REPAIR_ALL_BTN_BOUNDS.y && - logicalY <= VENDOR_REPAIR_ALL_BTN_BOUNDS.y + VENDOR_REPAIR_ALL_BTN_BOUNDS.h - ) { - if (callbacks.inventory) { - this.repairAllUiItems(callbacks.inventory) + // 2. D2Client.dll `0x6fb4c27a–0x6fb4c3ee`: Button mousedown hit-test & mutual-exclusion state machine + const hitSlot = this.hitTestVendorButtonSlot(logicalX, logicalY) + if (hitSlot !== null) { + const layout = resolveVendorButtonLayout(this.activeVendorDescriptor.hcIdx, this.vendorMode) + const btnKind = layout[hitSlot]! + if (btnKind === 'buy' || btnKind === 'sell' || btnKind === 'repair') { + this.pressedMomentarySlot = null + this.vendorTradeState = this.vendorTradeState === btnKind ? 'idle' : btnKind + } else if (btnKind === 'repairAll') { + this.vendorTradeState = 'idle' + this.pressedMomentarySlot = hitSlot + const inv = callbacks.inventory ?? this.activePlayerInventory + if (inv) { + this.repairAllUiItems(inv) } - return true + } else if (btnKind === 'close') { + this.vendorTradeState = 'idle' + this.pressedMomentarySlot = hitSlot + callbacks.onClose() } + return true + } + if (relY > VENDOR_BUTTON_HIT_Y_MIN && relY < VENDOR_BUTTON_HIT_Y_MAX && relX > VENDOR_BUTTON_TABLE_X[0] && relX < VENDOR_BUTTON_TABLE_X[3] + VENDOR_BUTTON_HIT_W) { + // Clicked disabled blank slot (`0x6fb3d300` no-op) + return true } // 3. Vendor 10x10 Grid (`VENDOR_GRID_ORIGIN`) @@ -1198,20 +1450,23 @@ export class WorldPanelsHud { const col = Math.floor((logicalX - VENDOR_GRID_ORIGIN.x) / VENDOR_GRID_ORIGIN.cellPx) const row = Math.floor((logicalY - VENDOR_GRID_ORIGIN.y) / VENDOR_GRID_ORIGIN.cellPx) - if (callbacks.inventory) { + const inv = callbacks.inventory ?? this.activePlayerInventory + if (inv) { // If player is holding an item on cursor and clicks inside Vendor grid -> Sell it! - if (callbacks.inventory.cursorItem) { - const held = callbacks.inventory.cursorItem - const res = this.sellToActiveVendor(held, callbacks.inventory) + if (inv.cursorItem) { + const held = inv.cursorItem + const res = this.sellToActiveVendor(held, inv) if (res.ok) { - callbacks.inventory.cursorItem = null + inv.cursorItem = null } return true } - // Otherwise buy the clicked item from the vendor! - this.buyFromActiveVendor(col, row, callbacks.inventory, Boolean(callbacks.isShiftClick)) - this.handleMouseMove(logicalX, logicalY) + // Left-click buy works in 'idle' or 'buy' state; do not accidentally buy when in 'sell' or 'repair' state + if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') { + this.buyFromActiveVendor(col, row, inv, Boolean(callbacks.isShiftClick)) + this.handleMouseMove(logicalX, logicalY) + } } return true } @@ -1229,12 +1484,14 @@ export class WorldPanelsHud { stashBgImg: HTMLImageElement | null cubeBgImg: HTMLImageElement | null vendorBgImg: HTMLImageElement | null + vendorTabsImg?: HTMLImageElement | null buySellBtnImg: HTMLImageElement | null questsAtlasImg?: HTMLImageElement | null itemsAtlasImg?: HTMLImageElement | null waypointTabsImg?: HTMLImageElement | null waypointIconsImg?: HTMLImageElement | null questTabsImg?: HTMLImageElement | null + inventory?: InventoryPanel | null }, font: D2FontRenderer, ): void { @@ -1470,68 +1727,17 @@ export class WorldPanelsHud { align: 'center', }) } else if (kind === 'vendor') { - if (!this.activeVendorDescriptor) { - this.openVendorForNpc('Akara', 'trade', this.vendorCharLevel, this.vendorDifficulty) + if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { + throw new Error('Cannot draw vendor panel without an active vendor descriptor') } - if (assets.vendorBgImg) { - ctx.drawImage(assets.vendorBgImg, ox, oy) - } else { - ctx.fillStyle = 'rgba(12, 10, 8, 0.96)' - ctx.fillRect(ox, oy, 320, 432) - ctx.strokeStyle = '#6c5838' - ctx.strokeRect(ox + 0.5, oy + 0.5, 319, 431) + if (!assets.vendorBgImg || !assets.vendorTabsImg || !assets.buySellBtnImg) { + throw new Error('Missing required baked UI assets for vendor panel (vendorBgImg, vendorTabsImg, buySellBtnImg)') } - const npcTitle = this.activeVendorDescriptor?.displayName ?? 'Akara' - const modeTitle = this.vendorMode === 'gamble' ? '赌博 (Gamble)' : '商店 (Trade)' - font.drawText(ctx, `${npcTitle} · ${modeTitle}`, ox + 160, oy + 22, { - font: 'fontexocet10', - color: 'gold', - align: 'center', - }) + // 1. D2Client.dll `0x6fb45c8a–0x6fb45d63`: `buysell.dc6` 320x432 stitched background + ctx.drawImage(assets.vendorBgImg, ox, oy) - // 4 Vendor Tabs (`护甲`, `武器 I`, `武器 II`, `杂物`) or Gamble Banner - if (this.vendorMode === 'trade') { - for (let i = 0; i < VENDOR_TABS.length; i++) { - const tab = VENDOR_TABS[i]! - const active = this.activeVendorTab === tab.id - const tx = ox + 17 + i * 72 - const ty = oy + 34 - ctx.fillStyle = active ? 'rgba(56, 44, 28, 0.95)' : 'rgba(20, 16, 12, 0.88)' - ctx.fillRect(tx, ty, 70, 26) - ctx.strokeStyle = active ? '#e8c26b' : '#584a34' - ctx.strokeRect(tx + 0.5, ty + 0.5, 69, 25) - font.drawText(ctx, tab.labelZh, tx + 35, ty + 17, { - font: 'font8', - color: active ? 'gold' : 'tan', - align: 'center', - }) - } - } else { - ctx.fillStyle = 'rgba(42, 32, 18, 0.92)' - ctx.fillRect(ox + 17, oy + 34, 289, 26) - ctx.strokeStyle = '#e8c26b' - ctx.strokeRect(ox + 17.5, oy + 34.5, 288, 25) - font.drawText(ctx, '未鉴定神秘赌博物品 · 点击直接购买鉴定', ox + 160, oy + 51, { - font: 'font8', - color: 'gold', - align: 'center', - }) - } - - // 10x10 Vendor Grid cell borders (`VENDOR_GRID_ORIGIN`) - for (let r = 0; r < VENDOR_GRID_ORIGIN.rows; r++) { - for (let c = 0; c < VENDOR_GRID_ORIGIN.cols; c++) { - const cx = VENDOR_GRID_ORIGIN.x + c * VENDOR_GRID_ORIGIN.cellPx - const cy = VENDOR_GRID_ORIGIN.y + r * VENDOR_GRID_ORIGIN.cellPx - ctx.fillStyle = 'rgba(10, 8, 6, 0.65)' - ctx.fillRect(cx, cy, VENDOR_GRID_ORIGIN.cellPx, VENDOR_GRID_ORIGIN.cellPx) - ctx.strokeStyle = 'rgba(68, 56, 40, 0.45)' - ctx.strokeRect(cx + 0.5, cy + 0.5, VENDOR_GRID_ORIGIN.cellPx - 1, VENDOR_GRID_ORIGIN.cellPx - 1) - } - } - - // Draw items in the active vendor 10x10 tab + // 2. D2Client.dll `0x6fb458a0`: Items on the active store page (`Inventory.txt` Monster2 grid at `ox+16, oy+63`) const atlas = assets.itemsAtlasImg ?? null const placements = this.getActiveVendorPlacements() for (const placed of placements) { @@ -1562,74 +1768,66 @@ export class WorldPanelsHud { } } - // Repair & Repair All buttons for blacksmiths - if (this.activeVendorDescriptor?.canRepair && this.vendorMode === 'trade') { - ctx.fillStyle = this.repairCursorMode ? 'rgba(64, 48, 20, 0.95)' : 'rgba(20, 16, 12, 0.9)' - ctx.fillRect( - VENDOR_REPAIR_BTN_BOUNDS.x, - VENDOR_REPAIR_BTN_BOUNDS.y, - VENDOR_REPAIR_BTN_BOUNDS.w, - VENDOR_REPAIR_BTN_BOUNDS.h, - ) - ctx.strokeStyle = this.repairCursorMode ? '#e8c26b' : '#8a7248' - ctx.strokeRect( - VENDOR_REPAIR_BTN_BOUNDS.x + 0.5, - VENDOR_REPAIR_BTN_BOUNDS.y + 0.5, - VENDOR_REPAIR_BTN_BOUNDS.w - 1, - VENDOR_REPAIR_BTN_BOUNDS.h - 1, - ) - font.drawText( - ctx, - '修理 (Repair)', - VENDOR_REPAIR_BTN_BOUNDS.x + VENDOR_REPAIR_BTN_BOUNDS.w / 2, - VENDOR_REPAIR_BTN_BOUNDS.y + 16, - { font: 'font8', color: this.repairCursorMode ? 'white' : 'gold', align: 'center' }, - ) - - ctx.fillStyle = 'rgba(20, 16, 12, 0.9)' - ctx.fillRect( - VENDOR_REPAIR_ALL_BTN_BOUNDS.x, - VENDOR_REPAIR_ALL_BTN_BOUNDS.y, - VENDOR_REPAIR_ALL_BTN_BOUNDS.w, - VENDOR_REPAIR_ALL_BTN_BOUNDS.h, - ) - ctx.strokeStyle = '#8a7248' - ctx.strokeRect( - VENDOR_REPAIR_ALL_BTN_BOUNDS.x + 0.5, - VENDOR_REPAIR_ALL_BTN_BOUNDS.y + 0.5, - VENDOR_REPAIR_ALL_BTN_BOUNDS.w - 1, - VENDOR_REPAIR_ALL_BTN_BOUNDS.h - 1, - ) - font.drawText( - ctx, - '全部修理 (All)', - VENDOR_REPAIR_ALL_BTN_BOUNDS.x + VENDOR_REPAIR_ALL_BTN_BOUNDS.w / 2, - VENDOR_REPAIR_ALL_BTN_BOUNDS.y + 16, - { font: 'font8', color: 'gold', align: 'center' }, - ) - } else { - font.drawText(ctx, '左键/右键点击购买 · 右键背包物品出售', ox + 130, oy + 403, { - font: 'font8', - color: 'tan', - align: 'center', - }) + // 3. D2Client.dll `0x6fb45df1–0x6fb45e51`: 4 button slots (`buysellbtn.dc6` at `ox + tableX - 1`, `oy + 385`) + const btnLayout = resolveVendorButtonLayout(this.activeVendorDescriptor.hcIdx, this.vendorMode) + for (let slot = 0 as 0 | 1 | 2 | 3; slot < 4; slot = (slot + 1) as 0 | 1 | 2 | 3) { + const frameIdx = this.getVendorButtonFrame(slot, btnLayout) + const drawX = ox + VENDOR_BUTTON_TABLE_X[slot]! - 1 + const drawY = oy + VENDOR_BUTTON_DRAW_Y + ctx.drawImage(assets.buySellBtnImg, frameIdx * 32, 0, 32, 32, drawX, drawY, 32, 32) } - // Status toast message at bottom of vendor panel + // 4. D2Client.dll `0x6fb45e5e–0x6fb45f5d`: `buyselltabs.dc6` present tabs + Font16 CHI `.tbl` labels (skipped in gamble mode) + for (const tab of VENDOR_TABS) { + if (!this.isVendorTabPresent(tab.id)) continue + const selected = this.activeVendorTab === tab.id + const frameIdx = selected ? tab.index : tab.index + 4 + ctx.drawImage(assets.vendorTabsImg, frameIdx * 79, 0, 79, 31, ox + tab.xOffset, oy, 79, 31) + if (this.vendorMode === 'trade') { + font.drawText(ctx, tab.labelZh, ox + tab.centerX, oy + tab.textY, { + font: 'font16', + color: selected ? 'gold' : 'white', + align: 'center', + }) + } + } + + // 5. Status toast text inside `buysell.dc6` bottom-left long box (`relX 17..200, relY 359..375`) if (this.vendorStatusMsg && performance.now() < this.vendorStatusMsg.untilMs) { - ctx.fillStyle = 'rgba(8, 6, 4, 0.92)' - ctx.fillRect(ox + 17, oy + 356, 289, 24) - ctx.strokeStyle = '#8a7248' - ctx.strokeRect(ox + 17.5, oy + 356.5, 288, 23) - font.drawText(ctx, this.vendorStatusMsg.text, ox + 160, oy + 372, { + font.drawText(ctx, this.vendorStatusMsg.text, ox + 109, oy + 371, { font: 'font8', color: this.vendorStatusMsg.color, align: 'center', }) } + + // 6. D2Client.dll `0x6fb3f260` -> D2Win #10085 (`0x6f8f33a0`): Left-anchored framed button hover text + if (this.hoveredVendorButtonSlot !== null) { + const slot = this.hoveredVendorButtonSlot + const btnKind = btnLayout[slot]! + const spec = VENDOR_BUTTON_SPECS[btnKind] + if (spec.enabled) { + const hoverText = + btnKind === 'repairAll' + ? `${spec.labelZh}:${this.getRepairAllCost(assets.inventory)}` + : spec.labelZh + const padX = 6 + const boxW = Math.max(24, font.measureText(hoverText, 'font16') + padX * 2) + const boxH = 22 + const boxX = ox + VENDOR_BUTTON_TABLE_X[slot]! + const boxY = oy + 380 - boxH + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)' + ctx.fillRect(boxX, boxY, boxW, boxH) + font.drawText(ctx, hoverText, boxX + padX, boxY + 17, { + font: 'font16', + color: 'white', + align: 'left', + }) + } + } } - if (assets.buySellBtnImg) { + if (kind !== 'vendor' && assets.buySellBtnImg) { ctx.drawImage(assets.buySellBtnImg, 10 * 32, 0, 32, 32, ox + 272, oy + 388, 32, 32) } } diff --git a/tests/vendor-panel-ui.test.ts b/tests/vendor-panel-ui.test.ts new file mode 100644 index 0000000..9be4363 --- /dev/null +++ b/tests/vendor-panel-ui.test.ts @@ -0,0 +1,474 @@ +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('關閉') + }) +})