331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
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<void>(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<number, (res: any) => 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 = <T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> =>
|
|
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 <T = any>(expression: string): Promise<T> => {
|
|
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<string> => {
|
|
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>(`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
|
|
const menuInfo = await evalJs(`(() => {
|
|
const hud = window.__d2webHudInstance;
|
|
hud.closeAllPanels();
|
|
hud.openNpcMenuByName('Charsi', 400, 220);
|
|
hud.render();
|
|
return {
|
|
npcName: hud.worldPanels.npcMenu?.npcName,
|
|
options: hud.worldPanels.npcMenu?.options?.map(o => o.labelZh),
|
|
};
|
|
})()`)
|
|
console.log('NPC Menu:', JSON.stringify(menuInfo))
|
|
await sleep(200)
|
|
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)
|
|
})
|