fix(ui): restore 1.13c buysell.dc6 vendor panel, tabs, and repair buttons
This commit is contained in:
parent
3b993cf04a
commit
bdb3b4145a
|
|
@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('關閉')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue