352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
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<WebSocket> = 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<string, unknown>;
|
|
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<void> {
|
|
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<void> {
|
|
for (const client of this.clients) {
|
|
client.close();
|
|
}
|
|
return new Promise((resolve) => {
|
|
this.server.close(() => resolve());
|
|
});
|
|
}
|
|
}
|