import { spawn, type ChildProcess } from 'node:child_process' import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { createServer, type ViteDevServer } from 'vite' import { ensurePacksAvailable } from './verify-ground-combat-browser.ts' const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) const CHROME_CANDIDATES = [ '/usr/bin/google-chrome', '/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome', '/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome', ] function resolveChromeBinary(): string { for (const bin of CHROME_CANDIDATES) { if (existsSync(bin)) return bin } throw new Error('No accessible Chromium binary found') } async function main() { const outputDir = process.env.VENDOR_AUDIT_OUT if (!outputDir) { throw new Error('VENDOR_AUDIT_OUT environment variable is required') } mkdirSync(outputDir, { recursive: true }) ensurePacksAvailable() const port = 5419 const viteServer: ViteDevServer = await createServer({ root: resolve('.'), logLevel: 'silent', server: { host: '127.0.0.1', port, strictPort: false }, }) await viteServer.listen() const actualPort = (viteServer.httpServer?.address() as any)?.port ?? port const targetUrl = `http://127.0.0.1:${actualPort}/acts.html?act=1&level=1-act-1-town-1` const chromeBin = resolveChromeBinary() const debugPort = 9519 const chromeProc: ChildProcess = spawn( chromeBin, [ '--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--window-size=1280,840', '--enable-webgl', '--ignore-gpu-blocklist', '--use-gl=angle', '--use-angle=swiftshader', '--no-proxy-server', `--remote-debugging-port=${debugPort}`, targetUrl, ], { stdio: ['ignore', 'ignore', 'pipe'] }, ) let ws: WebSocket | null = null try { let wsDebuggerUrl: string | null = null for (let i = 0; i < 80; i++) { await sleep(150) try { const res = await fetch(`http://127.0.0.1:${debugPort}/json`) const pages = (await res.json()) as any[] const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html')) if (page?.webSocketDebuggerUrl) { wsDebuggerUrl = page.webSocketDebuggerUrl break } } catch {} } if (!wsDebuggerUrl) throw new Error('Failed to attach to Chrome CDP') ws = new WebSocket(wsDebuggerUrl) await new Promise((res, rej) => { ws!.onopen = () => res(true) ws!.onerror = rej }) let cmdId = 1 const pending = new Map void>() ws.onmessage = (event: MessageEvent) => { const msg = JSON.parse(String(event.data)) if (msg.id && pending.has(msg.id)) { pending.get(msg.id)!(msg) pending.delete(msg.id) } } const cdp = (method: string, params: Record = {}): Promise => new Promise((resolve, reject) => { const id = cmdId++ pending.set(id, (msg: any) => { if (msg.error) reject(new Error(JSON.stringify(msg.error))) else resolve(msg.result as T) }) ws!.send(JSON.stringify({ id, method, params })) }) const evalJs = async (expression: string): Promise => { const res = await cdp<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, }) if (res.exceptionDetails) { throw new Error(`Eval error: ${JSON.stringify(res.exceptionDetails)}`) } return res.result.value } const takeScreenshot = async (name: string): Promise => { const shot = await cdp<{ data: string }>('Page.captureScreenshot', { format: 'png' }) const filePath = join(outputDir, `${name}.png`) writeFileSync(filePath, Buffer.from(shot.data, 'base64')) console.log(`Saved screenshot: ${filePath}`) return filePath } await cdp('Runtime.enable') await cdp('Page.enable') for (let i = 0; i < 100; i++) { const ready = await evalJs(`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready)`) if (ready) break await sleep(200) } // 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 (move player next to Charsi in town and trigger interactWithNpc) const menuInfo = await evalJs(`(() => { const hud = window.__d2webHudInstance; const engine = window.__d2webEngine; const mc = window.__d2webMouseController; hud.closeAllPanels(); const charsi = engine?.npcEntities?.find(n => n.def?.name?.toLowerCase() === 'charsi'); if (charsi && mc) { engine.world.player.x = charsi.x + 28; engine.world.player.y = charsi.y + 18; mc.interactWithNpc(charsi); } else { hud.openNpcMenuByName('Charsi', 400, 220); } hud.render(); const dialogEl = document.querySelector('#dialog'); return { npcName: hud.worldPanels.npcMenu?.npcName, options: hud.worldPanels.npcMenu?.options?.map(o => o.labelZh), hoveredOptionIdx: hud.worldPanels.npcMenu?.hoveredOptionIdx, layout: hud.worldPanels.getNpcMenuLayout(hud.font), domDialogHidden: dialogEl ? dialogEl.hidden : true, }; })()`) console.log('NPC Menu:', JSON.stringify(menuInfo)) if (!menuInfo.domDialogHidden) { throw new Error('Legacy #dialog DOM element should be hidden when HudManager is active') } await sleep(250) await takeScreenshot('vendor_npc_menu_charsi') // 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.selectVendorTab('armor'); wp.hoveredVendorButtonSlot = null; const armorItems = wp.vendorTabPlacements.armor; const docking = hud.getDockingLayout(); const deltaLeft = -docking.marginW; if (armorItems.length > 0) { const first = armorItems[0]; wp.hoveredVendorItem = { item: first.item, x: 80 + 16 + first.col * 29 + 14, y: 60 + 63 + first.row * 29 + 14, col: first.col, row: first.row, }; hud.inventory.hoveredItem = { item: first.item, x: wp.hoveredVendorItem.x + deltaLeft, y: wp.hoveredVendorItem.y, }; } hud.render(); 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(200) await takeScreenshot('vendor_trade_charsi') // 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; if (gambleItems.length > 0) { const first = gambleItems[0]; wp.hoveredVendorItem = { item: first.item, x: 80 + 16 + first.col * 29 + 14, y: 60 + 63 + first.row * 29 + 14, col: first.col, row: first.row, }; hud.inventory.hoveredItem = { item: first.item, x: wp.hoveredVendorItem.x + deltaLeft, y: wp.hoveredVendorItem.y, }; } 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(200) await takeScreenshot('vendor_gamble_gheed') } finally { ws?.close() chromeProc.kill('SIGKILL') await viteServer.close() } } main().catch(err => { console.error(err) process.exit(1) })