fix(ui): restore 1.13c buysell.dc6 vendor panel, tabs, and repair buttons

This commit is contained in:
troytt 2026-09-26 11:45:37 +00:00
parent 3b993cf04a
commit bdb3b4145a
10 changed files with 1044 additions and 215 deletions

View File

@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 40 KiB

BIN
public/ui/vendor-tabs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -280,7 +280,21 @@ async function main(): Promise<void> {
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<void> {
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)

View File

@ -323,7 +323,21 @@ async function main(): Promise<void> {
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<void> {
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<void> {
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',

View File

@ -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()

View File

@ -83,7 +83,7 @@ export const ACT_QUEST_ASSET_MAP: Readonly<Record<number, readonly QuestAssetInf
export const BAKED_UI_MANIFEST: BakedUiManifest = {
version: '1.13c',
decodedDc6Count: 680,
decodedDc6Count: 681,
dc6DecodeFailures: 0,
atlasWidth: 1024,
atlasHeight: 3573,
@ -13217,6 +13217,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
"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",

View File

@ -424,6 +424,7 @@ export class HudManager {
stashBg: `${baseUrl}/stash-bg.png`,
cubeBg: `${baseUrl}/cube-bg.png`,
vendorBg: `${baseUrl}/vendor-bg.png`,
vendorTabs: `${baseUrl}/vendor-tabs.png`,
popbelt: `${baseUrl}/popbelt.png`,
miniPanel: `${baseUrl}/minipanel.png`,
miniPanelBtns: `${baseUrl}/minipanel-btns.png`,
@ -480,6 +481,7 @@ export class HudManager {
mode: 'trade' | 'gamble' = 'trade',
difficulty: 0 | 1 | 2 = 0,
): boolean {
this.worldPanels.activePlayerInventory = this.inventory
const ok = this.worldPanels.openVendorForNpc(
descriptorOrName,
mode,
@ -505,13 +507,14 @@ export class HudManager {
if (this.leftPanel === kind) {
this.leftPanel = 'none'
this.charSheet.visible = false
this.worldPanels.repairCursorMode = false
this.worldPanels.vendorTradeState = 'idle'
this.worldPanels.releaseMomentaryVendorButton()
} else {
if (kind === 'vendor' && !this.worldPanels.activeVendorDescriptor) {
throw new Error('Cannot open vendor panel without an active vendor session')
}
this.leftPanel = kind
this.charSheet.visible = kind === 'char'
if (kind === 'vendor' && !this.worldPanels.activeVendorDescriptor) {
this.worldPanels.openVendorForNpc('Akara', 'trade', this.charSheet.attrs.level, 0)
}
if ((kind === 'stash' || kind === 'vendor') && this.rightPanel === 'none') {
this.toggleRightPanel('inv')
}
@ -551,7 +554,8 @@ export class HudManager {
this.belt.lockedOpen = false
this.hotkeys.openPopup = null
this.worldPanels.npcMenu = null
this.worldPanels.repairCursorMode = false
this.worldPanels.vendorTradeState = 'idle'
this.worldPanels.releaseMomentaryVendorButton()
this.syncPublishedState()
return anyOpen
}
@ -731,6 +735,7 @@ export class HudManager {
window.addEventListener('mouseup', () => {
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,
)

View File

@ -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<Record<VendorButtonKind, VendorButtonSpec>> = {
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<number> = new Set([154, 178, 253, 257, 511])
export const TRADER_CLASS_IDS: ReadonlySet<number> = 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<VendorTabId, GridPlacement[]> = {
@ -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)
}
}

View File

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