import express from 'express'; import http from 'http'; import { WebSocketServer, WebSocket } from 'ws'; import path from 'path'; import { fileURLToPath } from 'url'; import { logger } from '../util/logger.js'; import { sleep } from '../util/sleep.js'; import type { BotController } from './BotController.js'; import type { ScreenReader } from '../game/ScreenReader.js'; import { GRID_LAYOUTS } from '../game/GridReader.js'; import type { GameController } from '../game/GameController.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export interface DebugDeps { screenReader: ScreenReader; gameController: GameController; } export class DashboardServer { private app = express(); private server: http.Server; private wss: WebSocketServer; private clients: Set = new Set(); private bot: BotController; private debug: DebugDeps | null = null; constructor(bot: BotController, private port: number = 3000) { this.bot = bot; this.app.use(express.json()); this.app.get('/', (_req, res) => { res.sendFile(path.join(__dirname, '..', '..', 'src', 'dashboard', 'index.html')); }); // Status this.app.get('/api/status', (_req, res) => { res.json(this.bot.getStatus()); }); // Pause / Resume this.app.post('/api/pause', (_req, res) => { this.bot.pause(); this.broadcastStatus(); res.json({ ok: true }); }); this.app.post('/api/resume', (_req, res) => { this.bot.resume(); this.broadcastStatus(); res.json({ ok: true }); }); // Links CRUD this.app.post('/api/links', (req, res) => { const { url, name } = req.body as { url: string; name?: string }; if (!url || !url.includes('pathofexile.com/trade')) { res.status(400).json({ error: 'Invalid trade URL' }); return; } this.bot.addLink(url, name || ''); this.broadcastStatus(); res.json({ ok: true }); }); this.app.delete('/api/links/:id', (req, res) => { this.bot.removeLink(req.params.id); this.broadcastStatus(); res.json({ ok: true }); }); // Toggle link active/inactive this.app.post('/api/links/:id/toggle', (req, res) => { const { active } = req.body as { active: boolean }; this.bot.toggleLink(req.params.id, active); this.broadcastStatus(); res.json({ ok: true }); }); // Rename link this.app.post('/api/links/:id/name', (req, res) => { const { name } = req.body as { name: string }; this.bot.updateLinkName(req.params.id, name); this.broadcastStatus(); res.json({ ok: true }); }); // Settings this.app.post('/api/settings', (req, res) => { const updates = req.body as Record; const store = this.bot.getStore(); store.updateSettings(updates); this.broadcastStatus(); res.json({ ok: true }); }); // Debug endpoints this.app.post('/api/debug/screenshot', async (_req, res) => { if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } try { const files = await this.debug.screenReader.saveDebugScreenshots('debug-screenshots'); this.broadcastLog('info', `Debug screenshots saved: ${files.map(f => f.split(/[\\/]/).pop()).join(', ')}`); res.json({ ok: true, files }); } catch (err) { logger.error({ err }, 'Debug screenshot failed'); res.status(500).json({ error: 'Screenshot failed' }); } }); this.app.post('/api/debug/ocr', async (_req, res) => { if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } try { const text = await this.debug.screenReader.readFullScreen(); this.broadcastLog('info', `OCR result (${text.length} chars): ${text.substring(0, 200)}`); res.json({ ok: true, text }); } catch (err) { logger.error({ err }, 'Debug OCR failed'); res.status(500).json({ error: 'OCR failed' }); } }); this.app.post('/api/debug/find-text', async (req, res) => { if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } const { text } = req.body as { text: string }; if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; } try { const pos = await this.debug.screenReader.findTextOnScreen(text); if (pos) { this.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y})`); } else { this.broadcastLog('warn', `"${text}" not found on screen`); } res.json({ ok: true, found: !!pos, position: pos }); } catch (err) { logger.error({ err }, 'Debug find-text failed'); res.status(500).json({ error: 'Find text failed' }); } }); this.app.post('/api/debug/click', async (req, res) => { if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } const { x, y } = req.body as { x: number; y: number }; if (x == null || y == null) { res.status(400).json({ error: 'Missing x/y' }); return; } try { await this.debug.gameController.focusGame(); await this.debug.gameController.leftClickAt(x, y); this.broadcastLog('info', `Clicked at (${x}, ${y})`); res.json({ ok: true }); } catch (err) { logger.error({ err }, 'Debug click failed'); res.status(500).json({ error: 'Click failed' }); } }); this.app.post('/api/debug/hideout', async (_req, res) => { if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } try { await this.debug.gameController.focusGame(); await this.debug.gameController.goToHideout(); this.broadcastLog('info', 'Sent /hideout command'); res.json({ ok: true }); } catch (err) { logger.error({ err }, 'Debug hideout failed'); res.status(500).json({ error: 'Hideout command failed' }); } }); // Click first text, then wait for second text to appear and click it this.app.post('/api/debug/click-then-click', async (req, res) => { if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } const { first, second, timeout = 5000 } = req.body as { first: string; second: string; timeout?: number }; if (!first || !second) { res.status(400).json({ error: 'Missing first/second' }); return; } try { // Click the first target const pos1 = await this.debug.screenReader.findTextOnScreen(first); if (!pos1) { this.broadcastLog('warn', `"${first}" not found on screen`); res.json({ ok: true, found: false, step: 'first' }); return; } await this.debug.gameController.focusGame(); await this.debug.gameController.leftClickAt(pos1.x, pos1.y); this.broadcastLog('info', `Clicked "${first}" at (${pos1.x}, ${pos1.y}), waiting for "${second}"...`); // Poll OCR until second text appears const deadline = Date.now() + timeout; while (Date.now() < deadline) { const pos2 = await this.debug.screenReader.findTextOnScreen(second); if (pos2) { await this.debug.gameController.leftClickAt(pos2.x, pos2.y); this.broadcastLog('info', `Clicked "${second}" at (${pos2.x}, ${pos2.y})`); res.json({ ok: true, found: true, position: pos2 }); return; } } this.broadcastLog('warn', `"${second}" not found after clicking "${first}" (timed out)`); res.json({ ok: true, found: false, step: 'second' }); } catch (err) { logger.error({ err }, 'Debug click-then-click failed'); res.status(500).json({ error: 'Click-then-click failed' }); } }); // Grid scan with calibrated positions this.app.post('/api/debug/grid-scan', async (req, res) => { if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } const { layout: layoutName, targetRow, targetCol } = req.body as { layout: string; targetRow?: number; targetCol?: number }; if (!GRID_LAYOUTS[layoutName]) { res.status(400).json({ error: `Unknown grid layout: ${layoutName}` }); return; } try { const result = await this.debug.screenReader.grid.scan(layoutName, undefined, targetRow, targetCol); const imageBuffer = await this.debug.screenReader.captureRegion(result.layout.region); const imageBase64 = imageBuffer.toString('base64'); const r = result.layout.region; const matchInfo = result.matches ? `, ${result.matches.length} matches` : ''; this.broadcastLog('info', `Grid scan (${layoutName}): ${result.layout.cols}x${result.layout.rows} at (${r.x},${r.y}) ${r.width}x${r.height} — ${result.occupied.length} occupied cells${matchInfo}`); res.json({ ok: true, occupied: result.occupied, items: result.items, matches: result.matches, cols: result.layout.cols, rows: result.layout.rows, image: imageBase64, region: result.layout.region, }); } catch (err) { logger.error({ err }, 'Debug grid-scan failed'); res.status(500).json({ error: 'Grid scan failed' }); } }); this.app.post('/api/debug/find-and-click', async (req, res) => { if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } const { text } = req.body as { text: string }; if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; } try { const pos = await this.debug.screenReader.findTextOnScreen(text); if (pos) { await this.debug.gameController.focusGame(); await this.debug.gameController.leftClickAt(pos.x, pos.y); this.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) and clicked`); res.json({ ok: true, found: true, position: pos }); } else { this.broadcastLog('warn', `"${text}" not found on screen`); res.json({ ok: true, found: false, position: null }); } } catch (err) { logger.error({ err }, 'Debug find-and-click failed'); res.status(500).json({ error: 'Find and click failed' }); } }); // Test: scan grid, find matches for target cell, hover over each for 1s this.app.post('/api/debug/test-match-hover', async (req, res) => { if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } const { layout: layoutName, targetRow, targetCol } = req.body as { layout: string; targetRow: number; targetCol: number }; if (!GRID_LAYOUTS[layoutName]) { res.status(400).json({ error: `Unknown layout: ${layoutName}` }); return; } if (targetRow == null || targetCol == null) { res.status(400).json({ error: 'Missing targetRow/targetCol' }); return; } try { // Scan with match target this.broadcastLog('info', `Scanning ${layoutName} with target (${targetRow},${targetCol})...`); const result = await this.debug.screenReader.grid.scan(layoutName, undefined, targetRow, targetCol); const matches = result.matches ?? []; const items = result.items ?? []; // Find the item dimensions at target cell const targetItem = items.find(i => targetRow >= i.row && targetRow < i.row + i.h && targetCol >= i.col && targetCol < i.col + i.w); const itemSize = targetItem ? `${targetItem.w}x${targetItem.h}` : '1x1'; this.broadcastLog('info', `Target (${targetRow},${targetCol}) is ${itemSize}, found ${matches.length} matches`); // Build list of cells to hover: target first, then matches const hoverCells = [ { row: targetRow, col: targetCol, label: 'TARGET' }, ...matches.map(m => ({ row: m.row, col: m.col, label: `MATCH ${(m.similarity * 100).toFixed(0)}%` })), ]; // Focus game and hover each cell await this.debug.gameController.focusGame(); for (const cell of hoverCells) { const center = result.layout.region; const cellW = center.width / result.layout.cols; const cellH = center.height / result.layout.rows; const x = Math.round(center.x + cell.col * cellW + cellW / 2); const y = Math.round(center.y + cell.row * cellH + cellH / 2); this.broadcastLog('info', `Hovering ${cell.label} (${cell.row},${cell.col}) at (${x},${y})...`); await this.debug.gameController.moveMouseTo(x, y); await sleep(1000); } this.broadcastLog('info', `Done — hovered ${hoverCells.length} cells`); res.json({ ok: true, itemSize, matchCount: matches.length, hoveredCount: hoverCells.length }); } catch (err) { logger.error({ err }, 'Debug test-match-hover failed'); res.status(500).json({ error: 'Test match hover failed' }); } }); this.server = http.createServer(this.app); this.wss = new WebSocketServer({ server: this.server }); this.wss.on('connection', (ws) => { this.clients.add(ws); ws.send(JSON.stringify({ type: 'status', data: this.bot.getStatus() })); ws.on('close', () => this.clients.delete(ws)); }); } setDebugDeps(deps: DebugDeps): void { this.debug = deps; logger.info('Debug tools available on dashboard'); } broadcastStatus(): void { const msg = JSON.stringify({ type: 'status', data: this.bot.getStatus() }); for (const client of this.clients) { if (client.readyState === WebSocket.OPEN) { client.send(msg); } } } broadcastLog(level: string, message: string): void { const msg = JSON.stringify({ type: 'log', data: { level, message, time: new Date().toISOString() }, }); for (const client of this.clients) { if (client.readyState === WebSocket.OPEN) { client.send(msg); } } } async start(): Promise { return new Promise((resolve) => { this.server.listen(this.port, () => { logger.info({ port: this.port }, `Dashboard running at http://localhost:${this.port}`); resolve(); }); }); } async stop(): Promise { for (const client of this.clients) { client.close(); } return new Promise((resolve) => { this.server.close(() => resolve()); }); } }