diff --git a/src/bot/Bot.ts b/src/bot/Bot.ts new file mode 100644 index 0000000..ff012f7 --- /dev/null +++ b/src/bot/Bot.ts @@ -0,0 +1,391 @@ +import { EventEmitter } from 'events'; +import { logger } from '../util/logger.js'; +import { LinkManager } from './LinkManager.js'; +import { GameController } from '../game/GameController.js'; +import { ScreenReader } from '../game/ScreenReader.js'; +import { ClientLogWatcher } from '../log/ClientLogWatcher.js'; +import { TradeMonitor } from '../trade/TradeMonitor.js'; +import { InventoryManager } from '../inventory/InventoryManager.js'; +import { TradeExecutor } from '../executor/TradeExecutor.js'; +import { TradeQueue } from '../executor/TradeQueue.js'; +import { ScrapExecutor } from '../executor/ScrapExecutor.js'; +import type { TradeLink } from './LinkManager.js'; +import type { ConfigStore } from './ConfigStore.js'; +import type { Config, LinkMode, PostAction } from '../types.js'; +import type { Page } from 'playwright'; + +export interface BotStatus { + paused: boolean; + state: string; + links: TradeLink[]; + tradesCompleted: number; + tradesFailed: number; + uptime: number; + settings: { + poe2LogPath: string; + poe2WindowTitle: string; + travelTimeoutMs: number; + waitForMoreItemsMs: number; + betweenTradesDelayMs: number; + }; + inventory?: { + grid: boolean[][]; + items: { row: number; col: number; w: number; h: number }[]; + free: number; + }; +} + +export class Bot extends EventEmitter { + private paused: boolean; + private _state = 'IDLE'; + private tradesCompleted = 0; + private tradesFailed = 0; + private startTime = Date.now(); + private _inventory: BotStatus['inventory'] = undefined; + private _started = false; + + readonly links: LinkManager; + readonly store: ConfigStore; + readonly config: Config; + + gameController!: GameController; + screenReader!: ScreenReader; + logWatcher!: ClientLogWatcher; + tradeMonitor!: TradeMonitor; + inventoryManager!: InventoryManager; + tradeExecutor!: TradeExecutor; + tradeQueue!: TradeQueue; + scrapExecutors = new Map(); + + constructor(store: ConfigStore, config: Config) { + super(); + this.store = store; + this.config = config; + this.paused = store.settings.paused; + this.links = new LinkManager(store); + } + + get isReady(): boolean { + return this._started; + } + + get isPaused(): boolean { + return this.paused; + } + + get state(): string { + return this._state; + } + + set state(s: string) { + if (this._state !== s) { + this._state = s; + this.emit('status-update'); + } + } + + pause(): void { + this.paused = true; + this.store.setPaused(true); + logger.info('Bot paused'); + this.emit('status-update'); + } + + resume(): void { + this.paused = false; + this.store.setPaused(false); + logger.info('Bot resumed'); + this.emit('status-update'); + } + + // --- Link operations (delegate to LinkManager + emit events) --- + + addLink(url: string, name?: string, mode?: LinkMode, postAction?: PostAction): TradeLink { + const link = this.links.addLink(url, name, mode, postAction); + this.emit('link-added', link); + this.emit('status-update'); + return link; + } + + removeLink(id: string): void { + this.links.removeLink(id); + this.emit('link-removed', id); + this.emit('status-update'); + } + + toggleLink(id: string, active: boolean): void { + const link = this.links.toggleLink(id, active); + if (link) { + this.emit('link-toggled', { id, active, link }); + this.emit('status-update'); + } + } + + updateLinkName(id: string, name: string): void { + this.links.updateName(id, name); + this.emit('status-update'); + } + + updateLinkMode(id: string, mode: LinkMode): void { + const link = this.links.updateMode(id, mode); + if (link) { + this.emit('link-mode-changed', { id, mode, link }); + this.emit('status-update'); + } + } + + updateLinkPostAction(id: string, postAction: PostAction): void { + const link = this.links.updatePostAction(id, postAction); + if (link) { + this.emit('link-postaction-changed', { id, postAction, link }); + this.emit('status-update'); + } + } + + isLinkActive(searchId: string): boolean { + return this.links.isActive(searchId); + } + + updateSettings(updates: Record): void { + this.store.updateSettings(updates); + this.emit('status-update'); + } + + setInventory(inv: BotStatus['inventory']): void { + this._inventory = inv; + } + + getStatus(): BotStatus { + const s = this.store.settings; + return { + paused: this.paused, + state: this._state, + links: this.links.getLinks(), + tradesCompleted: this.tradesCompleted, + tradesFailed: this.tradesFailed, + uptime: Date.now() - this.startTime, + settings: { + poe2LogPath: s.poe2LogPath, + poe2WindowTitle: s.poe2WindowTitle, + travelTimeoutMs: s.travelTimeoutMs, + waitForMoreItemsMs: s.waitForMoreItemsMs, + betweenTradesDelayMs: s.betweenTradesDelayMs, + }, + inventory: this._inventory, + }; + } + + /** Called by executor state callbacks to update bot state */ + updateExecutorState(): void { + this._inventory = this.inventoryManager.getInventoryState(); + + const execState = this.tradeExecutor.getState(); + if (execState !== 'IDLE') { + this.state = execState; + return; + } + + for (const [, scrapExec] of this.scrapExecutors) { + const scrapState = scrapExec.getState(); + if (scrapState !== 'IDLE') { + this.state = scrapState; + return; + } + } + + this.state = 'IDLE'; + } + + // --- Startup / Shutdown --- + + async start(cliUrls: string[], port: number): Promise { + this.screenReader = new ScreenReader(); + this.gameController = new GameController(this.config); + + this.logWatcher = new ClientLogWatcher(this.config.poe2LogPath); + await this.logWatcher.start(); + this.emit('log', 'info', 'Watching Client.txt for game events'); + + this.tradeMonitor = new TradeMonitor(this.config); + await this.tradeMonitor.start(`http://localhost:${port}`); + this.emit('log', 'info', 'Browser launched'); + + this.inventoryManager = new InventoryManager( + this.gameController, this.screenReader, this.logWatcher, this.config, + ); + + // /hideout + waitForAreaTransition + this.emit('log', 'info', 'Sending /hideout command...'); + await this.gameController.focusGame(); + const arrivedHome = await this.inventoryManager.waitForAreaTransition( + this.config.travelTimeoutMs, + () => this.gameController.goToHideout(), + ); + if (arrivedHome) { + this.inventoryManager.setLocation(true); + this.logWatcher.currentArea = 'Hideout'; + } else { + this.inventoryManager.setLocation(true); + this.logWatcher.currentArea = 'Hideout'; + logger.warn('Timed out waiting for hideout transition on startup (may already be in hideout)'); + } + this.state = 'IN_HIDEOUT'; + this.emit('log', 'info', 'In hideout, ready to trade'); + + // Clear leftover inventory + this.emit('log', 'info', 'Checking inventory for leftover items...'); + await this.inventoryManager.clearToStash(); + this.emit('log', 'info', 'Inventory cleared'); + + // Create executors + this.tradeExecutor = new TradeExecutor( + this.gameController, this.screenReader, this.tradeMonitor, + this.inventoryManager, this.config, + ); + this.tradeExecutor.onStateChange = () => this.updateExecutorState(); + + this.tradeQueue = new TradeQueue(this.tradeExecutor, this.config); + + // Collect all URLs: CLI args + saved links (deduped) + const allUrls = new Set([ + ...cliUrls, + ...this.store.settings.links.map(l => l.url), + ]); + + // Load links (direct, before wiring events) + for (const url of allUrls) { + const link = this.links.addLink(url); + if (link.active) { + await this.activateLink(link); + } else { + this.emit('log', 'info', `Loaded (inactive): ${link.name || link.label}`); + } + } + + // Wire events for subsequent UI-triggered changes + this.wireEvents(); + + this._started = true; + this.emit('log', 'info', `Loaded ${allUrls.size} trade link(s) from config`); + logger.info('Bot started'); + } + + async stop(): Promise { + logger.info('Shutting down bot...'); + for (const [, scrapExec] of this.scrapExecutors) { + await scrapExec.stop(); + } + await this.screenReader.dispose(); + await this.tradeMonitor.stop(); + await this.logWatcher.stop(); + } + + // --- Internal --- + + private wireEvents(): void { + this.on('link-added', (link: TradeLink) => { + if (link.active) { + this.activateLink(link).catch(err => { + logger.error({ err }, 'Failed to activate link from event'); + }); + } + }); + + this.on('link-removed', (id: string) => { + this.deactivateLink(id).catch(err => { + logger.error({ err }, 'Failed to deactivate link from event'); + }); + this.emit('log', 'info', `Removed search: ${id}`); + }); + + this.on('link-toggled', (data: { id: string; active: boolean; link: TradeLink }) => { + if (data.active) { + this.activateLink(data.link).catch(err => { + logger.error({ err }, 'Failed to activate link from toggle'); + }); + this.emit('log', 'info', `Activated: ${data.link.name || data.id}`); + } else { + this.deactivateLink(data.id).catch(err => { + logger.error({ err }, 'Failed to deactivate link from toggle'); + }); + this.emit('log', 'info', `Deactivated: ${data.link.name || data.id}`); + } + }); + + this.on('link-mode-changed', (data: { id: string; mode: string; link: TradeLink }) => { + if (data.link.active) { + this.deactivateLink(data.id).then(() => this.activateLink(data.link)).catch(err => { + logger.error({ err }, 'Failed to restart link after mode change'); + }); + this.emit('log', 'info', `Mode changed to ${data.mode}: ${data.link.name || data.id}`); + } + }); + + this.on('link-postaction-changed', (data: { id: string; postAction: string; link: TradeLink }) => { + if (data.link.active) { + this.deactivateLink(data.id).then(() => this.activateLink(data.link)).catch(err => { + logger.error({ err }, 'Failed to restart link after postAction change'); + }); + this.emit('log', 'info', `Post-action changed to ${data.postAction}: ${data.link.name || data.id}`); + } + }); + + // Trade monitor → trade queue + this.tradeMonitor.on('new-listings', (data: { searchId: string; itemIds: string[]; page: Page }) => { + if (this.isPaused) { + this.emit('log', 'warn', `New listings (${data.itemIds.length}) skipped - bot paused`); + return; + } + if (!this.isLinkActive(data.searchId)) return; + + logger.info({ searchId: data.searchId, itemCount: data.itemIds.length }, 'New listings received, queuing trade...'); + this.emit('log', 'info', `New listings: ${data.itemIds.length} items from ${data.searchId}`); + + this.tradeQueue.enqueue({ + searchId: data.searchId, + itemIds: data.itemIds, + whisperText: '', + timestamp: Date.now(), + tradeUrl: '', + page: data.page, + }); + }); + } + + private async activateLink(link: TradeLink): Promise { + try { + if (link.mode === 'scrap') { + const scrapExec = new ScrapExecutor( + this.gameController, this.screenReader, this.tradeMonitor, + this.inventoryManager, this.config, + ); + scrapExec.onStateChange = () => this.updateExecutorState(); + this.scrapExecutors.set(link.id, scrapExec); + this.emit('log', 'info', `Scrap loop started: ${link.name || link.label}`); + this.emit('status-update'); + + scrapExec.runScrapLoop(link.url, link.postAction).catch((err) => { + logger.error({ err, linkId: link.id }, 'Scrap loop error'); + this.emit('log', 'error', `Scrap loop failed: ${link.name || link.label}`); + this.scrapExecutors.delete(link.id); + }); + } else { + await this.tradeMonitor.addSearch(link.url); + this.emit('log', 'info', `Monitoring: ${link.name || link.label}`); + this.emit('status-update'); + } + } catch (err) { + logger.error({ err, url: link.url }, 'Failed to activate link'); + this.emit('log', 'error', `Failed to activate: ${link.name || link.label}`); + } + } + + private async deactivateLink(id: string): Promise { + const scrapExec = this.scrapExecutors.get(id); + if (scrapExec) { + await scrapExec.stop(); + this.scrapExecutors.delete(id); + } + await this.tradeMonitor.pauseSearch(id); + } +} diff --git a/src/dashboard/ConfigStore.ts b/src/bot/ConfigStore.ts similarity index 98% rename from src/dashboard/ConfigStore.ts rename to src/bot/ConfigStore.ts index e99c01f..7999e99 100644 --- a/src/dashboard/ConfigStore.ts +++ b/src/bot/ConfigStore.ts @@ -139,7 +139,7 @@ export class ConfigStore { this.save(); } - updateSettings(partial: Partial): void { + updateSettings(partial: Record): void { Object.assign(this.data, partial); this.save(); } diff --git a/src/dashboard/BotController.ts b/src/bot/LinkManager.ts similarity index 52% rename from src/dashboard/BotController.ts rename to src/bot/LinkManager.ts index ddf4dd9..c8ef55f 100644 --- a/src/dashboard/BotController.ts +++ b/src/bot/LinkManager.ts @@ -1,7 +1,6 @@ -import { EventEmitter } from 'events'; import { logger } from '../util/logger.js'; import type { LinkMode, PostAction } from '../types.js'; -import type { ConfigStore, SavedLink } from './ConfigStore.js'; +import type { ConfigStore } from './ConfigStore.js'; export interface TradeLink { id: string; @@ -14,75 +13,18 @@ export interface TradeLink { addedAt: string; } -export interface BotStatus { - paused: boolean; - state: string; - links: TradeLink[]; - tradesCompleted: number; - tradesFailed: number; - uptime: number; - settings: { - poe2LogPath: string; - poe2WindowTitle: string; - travelTimeoutMs: number; - waitForMoreItemsMs: number; - betweenTradesDelayMs: number; - }; - inventory?: { - grid: boolean[][]; - items: { row: number; col: number; w: number; h: number }[]; - free: number; - }; -} - -export class BotController extends EventEmitter { - private paused = false; +export class LinkManager { private links: Map = new Map(); - private _state = 'IDLE'; - private tradesCompleted = 0; - private tradesFailed = 0; - private startTime = Date.now(); private store: ConfigStore; - private _inventory: BotStatus['inventory'] = undefined; constructor(store: ConfigStore) { - super(); this.store = store; - this.paused = store.settings.paused; - } - - get isPaused(): boolean { - return this.paused; - } - - get state(): string { - return this._state; - } - - set state(s: string) { - this._state = s; - this.emit('state-change', s); - } - - pause(): void { - this.paused = true; - this.store.setPaused(true); - logger.info('Bot paused'); - this.emit('paused'); - } - - resume(): void { - this.paused = false; - this.store.setPaused(false); - logger.info('Bot resumed'); - this.emit('resumed'); } addLink(url: string, name: string = '', mode?: LinkMode, postAction?: PostAction): TradeLink { url = this.stripLive(url); const id = this.extractId(url); const label = this.extractLabel(url); - // Check if we have saved state for this link const savedLink = this.store.links.find((l) => l.url === url); const resolvedMode = mode || savedLink?.mode || 'live'; const link: TradeLink = { @@ -98,7 +40,6 @@ export class BotController extends EventEmitter { this.links.set(id, link); this.store.addLink(url, link.name, link.mode, link.postAction); logger.info({ id, url, name: link.name, active: link.active, mode: link.mode, postAction: link.postAction }, 'Trade link added'); - this.emit('link-added', link); return link; } @@ -111,45 +52,44 @@ export class BotController extends EventEmitter { this.store.removeLinkById(id); } logger.info({ id }, 'Trade link removed'); - this.emit('link-removed', id); } - toggleLink(id: string, active: boolean): void { + toggleLink(id: string, active: boolean): TradeLink | undefined { const link = this.links.get(id); - if (!link) return; + if (!link) return undefined; link.active = active; this.store.updateLinkById(id, { active }); logger.info({ id, active }, `Trade link ${active ? 'activated' : 'deactivated'}`); - this.emit('link-toggled', { id, active, link }); + return link; } - updateLinkName(id: string, name: string): void { + updateName(id: string, name: string): void { const link = this.links.get(id); if (!link) return; link.name = name; this.store.updateLinkById(id, { name }); } - updateLinkMode(id: string, mode: LinkMode): void { + updateMode(id: string, mode: LinkMode): TradeLink | undefined { const link = this.links.get(id); - if (!link) return; + if (!link) return undefined; link.mode = mode; this.store.updateLinkById(id, { mode }); logger.info({ id, mode }, 'Trade link mode updated'); - this.emit('link-mode-changed', { id, mode, link }); + return link; } - updateLinkPostAction(id: string, postAction: PostAction): void { + updatePostAction(id: string, postAction: PostAction): TradeLink | undefined { const link = this.links.get(id); - if (!link) return; + if (!link) return undefined; link.postAction = postAction; this.store.updateLinkById(id, { postAction }); logger.info({ id, postAction }, 'Trade link postAction updated'); - this.emit('link-postaction-changed', { id, postAction, link }); + return link; } - isLinkActive(searchId: string): boolean { - const link = this.links.get(searchId); + isActive(id: string): boolean { + const link = this.links.get(id); return link ? link.active : false; } @@ -157,42 +97,8 @@ export class BotController extends EventEmitter { return Array.from(this.links.values()); } - recordTradeSuccess(): void { - this.tradesCompleted++; - this.emit('trade-completed'); - } - - recordTradeFailure(): void { - this.tradesFailed++; - this.emit('trade-failed'); - } - - getStatus(): BotStatus { - const s = this.store.settings; - return { - paused: this.paused, - state: this._state, - links: this.getLinks(), - tradesCompleted: this.tradesCompleted, - tradesFailed: this.tradesFailed, - uptime: Date.now() - this.startTime, - settings: { - poe2LogPath: s.poe2LogPath, - poe2WindowTitle: s.poe2WindowTitle, - travelTimeoutMs: s.travelTimeoutMs, - waitForMoreItemsMs: s.waitForMoreItemsMs, - betweenTradesDelayMs: s.betweenTradesDelayMs, - }, - inventory: this._inventory, - }; - } - - setInventory(inv: BotStatus['inventory']): void { - this._inventory = inv; - } - - getStore(): ConfigStore { - return this.store; + getLink(id: string): TradeLink | undefined { + return this.links.get(id); } private stripLive(url: string): string { diff --git a/src/dashboard/DashboardServer.ts b/src/dashboard/DashboardServer.ts deleted file mode 100644 index 7a6d728..0000000 --- a/src/dashboard/DashboardServer.ts +++ /dev/null @@ -1,450 +0,0 @@ -import express from 'express'; -import http from 'http'; -import { WebSocketServer, WebSocket } from 'ws'; -import path from 'path'; -import { mkdir } from 'fs/promises'; -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 type { OcrEngine, OcrPreprocess } from '../game/OcrDaemon.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, mode } = req.body as { url: string; name?: string; mode?: string }; - if (!url || !url.includes('pathofexile.com/trade')) { - res.status(400).json({ error: 'Invalid trade URL' }); - return; - } - const linkMode = mode === 'scrap' ? 'scrap' : 'live'; - this.bot.addLink(url, name || '', linkMode); - 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 }); - }); - - // Change link mode - this.app.post('/api/links/:id/mode', (req, res) => { - const { mode } = req.body as { mode: string }; - if (mode !== 'live' && mode !== 'scrap') { - res.status(400).json({ error: 'Invalid mode. Must be "live" or "scrap".' }); - return; - } - this.bot.updateLinkMode(req.params.id, mode); - this.broadcastStatus(); - res.json({ ok: true }); - }); - - // Change link post-action - this.app.post('/api/links/:id/post-action', (req, res) => { - const { postAction } = req.body as { postAction: string }; - if (postAction !== 'stash' && postAction !== 'salvage') { - res.status(400).json({ error: 'Invalid postAction. Must be "stash" or "salvage".' }); - return; - } - this.bot.updateLinkPostAction(req.params.id, postAction); - 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' }); - } - }); - - // OCR engine selection - this.app.get('/api/debug/ocr-engine', (_req, res) => { - if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } - res.json({ ok: true, engine: this.debug.screenReader.debugOcrEngine }); - }); - - this.app.post('/api/debug/ocr-engine', (req, res) => { - if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } - const { engine } = req.body as { engine: string }; - if (!['tesseract', 'easyocr', 'paddleocr'].includes(engine)) { - res.status(400).json({ error: 'Invalid engine. Must be tesseract, easyocr, or paddleocr.' }); - return; - } - this.debug.screenReader.debugOcrEngine = engine as OcrEngine; - this.broadcastLog('info', `OCR engine set to: ${engine}`); - res.json({ ok: true }); - }); - - // OCR preprocess selection - this.app.get('/api/debug/ocr-preprocess', (_req, res) => { - if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } - res.json({ ok: true, preprocess: this.debug.screenReader.debugPreprocess }); - }); - - this.app.post('/api/debug/ocr-preprocess', (req, res) => { - if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } - const { preprocess } = req.body as { preprocess: string }; - if (!['none', 'bgsub', 'tophat'].includes(preprocess)) { - res.status(400).json({ error: 'Invalid preprocess. Must be none, bgsub, or tophat.' }); - return; - } - this.debug.screenReader.debugPreprocess = preprocess as OcrPreprocess; - this.broadcastLog('info', `OCR preprocess set to: ${preprocess}`); - res.json({ ok: true }); - }); - - 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.debugReadFullScreen(); - this.broadcastLog('info', `OCR [${this.debug.screenReader.debugOcrEngine}] (${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.debugFindTextOnScreen(text); - if (pos) { - this.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) [${this.debug.screenReader.debugOcrEngine}]`); - } else { - this.broadcastLog('warn', `"${text}" not found on screen [${this.debug.screenReader.debugOcrEngine}]`); - } - 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, fuzzy } = req.body as { text: string; fuzzy?: boolean }; - if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; } - try { - const pos = await this.debug.screenReader.debugFindTextOnScreen(text, !!fuzzy); - 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 [${this.debug.screenReader.debugOcrEngine}]`); - res.json({ ok: true, found: true, position: pos }); - } else { - this.broadcastLog('warn', `"${text}" not found on screen [${this.debug.screenReader.debugOcrEngine}]`); - 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, take one snapshot with mouse on empty space - await this.debug.gameController.focusGame(); - await mkdir('items', { recursive: true }); - const tooltips: Array<{ row: number; col: number; label: string; text: string }> = []; - const ts = Date.now(); - const reg = result.layout.region; - const cellW = reg.width / result.layout.cols; - const cellH = reg.height / result.layout.rows; - - // Move mouse to empty space and take a single reference snapshot - this.debug.gameController.moveMouseInstant(reg.x + reg.width + 50, reg.y + reg.height / 2); - await sleep(50); - await this.debug.screenReader.snapshot(); - await this.debug.screenReader.saveScreenshot(`items/${ts}_snapshot.png`); - await sleep(200); // Let game settle before first hover - - for (const cell of hoverCells) { - const cellStart = performance.now(); - const x = Math.round(reg.x + cell.col * cellW + cellW / 2); - const y = Math.round(reg.y + cell.row * cellH + cellH / 2); - - // Quick Bézier move to the cell — tooltip appears on hover - await this.debug.gameController.moveMouseFast(x, y); - await sleep(50); - const afterMove = performance.now(); - - // Diff-OCR: finds tooltip by row/column density of darkened pixels - const imgPath = `items/${ts}_${cell.row}-${cell.col}.png`; - const diff = await this.debug.screenReader.diffOcr(imgPath); - const afterOcr = performance.now(); - const text = diff.text.trim(); - - const regionInfo = diff.region ? ` at (${diff.region.x},${diff.region.y}) ${diff.region.width}x${diff.region.height}` : ''; - tooltips.push({ row: cell.row, col: cell.col, label: cell.label, text }); - - this.broadcastLog('info', - `${cell.label} (${cell.row},${cell.col}) [move: ${(afterMove - cellStart).toFixed(0)}ms, ocr: ${(afterOcr - afterMove).toFixed(0)}ms, total: ${(afterOcr - cellStart).toFixed(0)}ms]${regionInfo}:`); - if (diff.lines.length > 0) { - for (const line of diff.lines) { - this.broadcastLog('info', ` ${line.text}`); - } - } else if (text) { - for (const line of text.split('\n')) { - if (line.trim()) this.broadcastLog('info', ` ${line.trim()}`); - } - } - } - - this.broadcastLog('info', `Done — hovered ${hoverCells.length} cells, read ${tooltips.filter(t => t.text).length} tooltips`); - res.json({ ok: true, itemSize, matchCount: matches.length, hoveredCount: hoverCells.length, tooltips }); - } 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()); - }); - } -} diff --git a/src/executor/ScrapExecutor.ts b/src/executor/ScrapExecutor.ts index f7cb354..c72747f 100644 --- a/src/executor/ScrapExecutor.ts +++ b/src/executor/ScrapExecutor.ts @@ -18,6 +18,7 @@ export class ScrapExecutor { private tradeMonitor: TradeMonitor; private inventoryManager: InventoryManager; private config: Config; + private _onStateChange?: (state: string) => void; constructor( gameController: GameController, @@ -33,10 +34,19 @@ export class ScrapExecutor { this.config = config; } + set onStateChange(cb: (state: string) => void) { + this._onStateChange = cb; + } + getState(): ScrapState { return this.state; } + private setState(s: ScrapState): void { + this.state = s; + this._onStateChange?.(s); + } + /** Stop the scrap loop gracefully. */ async stop(): Promise { this.stopped = true; @@ -44,7 +54,7 @@ export class ScrapExecutor { try { await this.activePage.close(); } catch { /* best-effort */ } this.activePage = null; } - this.state = 'IDLE'; + this.setState('IDLE'); logger.info('Scrap executor stopped'); } @@ -81,7 +91,7 @@ export class ScrapExecutor { // Check if process succeeded (state is IDLE on success, FAILED otherwise) if (this.state === 'FAILED') { salvageFailed = true; - this.state = 'IDLE'; + this.setState('IDLE'); logger.warn('Process cycle failed, skipping remaining items that do not fit'); continue; } @@ -121,7 +131,7 @@ export class ScrapExecutor { } this.activePage = null; - this.state = 'IDLE'; + this.setState('IDLE'); logger.info('Scrap loop ended'); } @@ -135,7 +145,7 @@ export class ScrapExecutor { if (alreadyAtSeller) { logger.info({ itemId: item.id, account: item.account }, 'Already at seller hideout, skipping travel'); } else { - this.state = 'TRAVELING'; + this.setState('TRAVELING'); // Register listener BEFORE clicking, then click inside the callback const arrived = await this.inventoryManager.waitForAreaTransition( @@ -149,7 +159,7 @@ export class ScrapExecutor { ); if (!arrived) { logger.error({ itemId: item.id }, 'Timed out waiting for hideout arrival'); - this.state = 'FAILED'; + this.setState('FAILED'); return false; } @@ -158,7 +168,7 @@ export class ScrapExecutor { await sleep(1500); // Wait for hideout to render } - this.state = 'BUYING'; + this.setState('BUYING'); // CTRL+Click at seller stash position const sellerLayout = GRID_LAYOUTS.seller; @@ -175,11 +185,11 @@ export class ScrapExecutor { } logger.info({ itemId: item.id, free: this.inventoryManager.tracker.freeCells }, 'Item bought successfully'); - this.state = 'IDLE'; + this.setState('IDLE'); return true; } catch (err) { logger.error({ err, itemId: item.id }, 'Error buying item'); - this.state = 'FAILED'; + this.setState('FAILED'); return false; } } @@ -187,13 +197,13 @@ export class ScrapExecutor { /** Process inventory: salvage/stash cycle via InventoryManager. */ private async processItems(): Promise { try { - this.state = 'SALVAGING'; + this.setState('SALVAGING'); await this.inventoryManager.processInventory(); - this.state = 'IDLE'; + this.setState('IDLE'); logger.info('Process cycle complete'); } catch (err) { logger.error({ err }, 'Process cycle failed'); - this.state = 'FAILED'; + this.setState('FAILED'); } } diff --git a/src/executor/TradeExecutor.ts b/src/executor/TradeExecutor.ts index 4311416..a3f0d1c 100644 --- a/src/executor/TradeExecutor.ts +++ b/src/executor/TradeExecutor.ts @@ -23,6 +23,7 @@ export class TradeExecutor { private tradeMonitor: TradeMonitor; private inventoryManager: InventoryManager; private config: Config; + private _onStateChange?: (state: string) => void; constructor( gameController: GameController, @@ -38,16 +39,25 @@ export class TradeExecutor { this.config = config; } + set onStateChange(cb: (state: string) => void) { + this._onStateChange = cb; + } + getState(): TradeState { return this.state; } + private setState(s: TradeState): void { + this.state = s; + this._onStateChange?.(s); + } + async executeTrade(trade: TradeInfo): Promise { const page = trade.page as Page; try { // Step 1: Click "Travel to Hideout" on the trade website - this.state = 'TRAVELING'; + this.setState('TRAVELING'); logger.info({ searchId: trade.searchId }, 'Clicking Travel to Hideout...'); // Register listener BEFORE clicking, then click inside the callback @@ -65,11 +75,11 @@ export class TradeExecutor { ); if (!arrived) { logger.error('Timed out waiting for hideout arrival'); - this.state = 'FAILED'; + this.setState('FAILED'); return false; } - this.state = 'IN_SELLERS_HIDEOUT'; + this.setState('IN_SELLERS_HIDEOUT'); this.inventoryManager.setLocation(false); logger.info('Arrived at seller hideout'); @@ -89,19 +99,19 @@ export class TradeExecutor { const stashPos = await this.inventoryManager.findAndClickNameplate('Stash'); if (!stashPos) { logger.error('Could not find Stash nameplate in seller hideout'); - this.state = 'FAILED'; + this.setState('FAILED'); return false; } await sleep(1000); // Wait for stash to open // Step 4: Scan stash and buy items - this.state = 'SCANNING_STASH'; + this.setState('SCANNING_STASH'); logger.info('Scanning stash for items...'); await this.scanAndBuyItems(); // Step 5: Wait for more items - this.state = 'WAITING_FOR_MORE'; + this.setState('WAITING_FOR_MORE'); logger.info( { waitMs: this.config.waitForMoreItemsMs }, 'Waiting for seller to add more items...', @@ -112,7 +122,7 @@ export class TradeExecutor { await this.scanAndBuyItems(); // Step 6: Go back to own hideout - this.state = 'GOING_HOME'; + this.setState('GOING_HOME'); logger.info('Traveling to own hideout...'); await this.gameController.focusGame(); await sleep(300); @@ -128,15 +138,15 @@ export class TradeExecutor { this.inventoryManager.setLocation(true); // Step 7: Store items in stash - this.state = 'IN_HIDEOUT'; + this.setState('IN_HIDEOUT'); await sleep(1000); await this.storeItems(); - this.state = 'IDLE'; + this.setState('IDLE'); return true; } catch (err) { logger.error({ err }, 'Trade execution failed'); - this.state = 'FAILED'; + this.setState('FAILED'); // Try to recover by going home try { @@ -148,7 +158,7 @@ export class TradeExecutor { // Best-effort recovery } - this.state = 'IDLE'; + this.setState('IDLE'); return false; } } @@ -165,7 +175,7 @@ export class TradeExecutor { // TODO: Implement item matching logic based on OCR text // For now, we'll Ctrl+right-click at known grid positions - this.state = 'BUYING'; + this.setState('BUYING'); // Check for price warning dialog after each buy await this.checkPriceWarning(); diff --git a/src/index.ts b/src/index.ts index fe18b49..bcc34ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,9 @@ import { Command } from 'commander'; import { loadConfig } from './config.js'; -import { TradeMonitor } from './trade/TradeMonitor.js'; -import { GameController } from './game/GameController.js'; -import { ScreenReader } from './game/ScreenReader.js'; -import { ClientLogWatcher } from './log/ClientLogWatcher.js'; -import { TradeExecutor } from './executor/TradeExecutor.js'; -import { ScrapExecutor } from './executor/ScrapExecutor.js'; -import { TradeQueue } from './executor/TradeQueue.js'; -import { InventoryManager } from './inventory/InventoryManager.js'; -import { BotController } from './dashboard/BotController.js'; -import { DashboardServer } from './dashboard/DashboardServer.js'; -import { ConfigStore } from './dashboard/ConfigStore.js'; +import { Bot } from './bot/Bot.js'; +import { Server } from './server/Server.js'; +import { ConfigStore } from './bot/ConfigStore.js'; import { logger } from './util/logger.js'; -import type { TradeLink } from './dashboard/BotController.js'; -import type { Page } from 'playwright'; const program = new Command(); @@ -25,15 +15,12 @@ program .option('-p, --port ', 'Dashboard port') .option('-c, --config ', 'Path to config.json', 'config.json') .action(async (options) => { - // Load persisted config const store = new ConfigStore(options.config); const saved = store.settings; - // CLI/env overrides persisted values const envConfig = loadConfig(options.url); if (options.logPath) envConfig.poe2LogPath = options.logPath; - // Merge: CLI args > .env > config.json defaults const config = { ...envConfig, poe2LogPath: options.logPath || saved.poe2LogPath, @@ -47,250 +34,15 @@ program const port = parseInt(options.port, 10) || saved.dashboardPort; - // Collect all URLs: CLI args + saved links (deduped) - const allUrls = new Set([ - ...config.tradeUrls, - ...saved.links.map((l) => l.url), - ]); + const bot = new Bot(store, config); + const server = new Server(bot, port); + await server.start(); + await bot.start(config.tradeUrls, port); - // Initialize bot controller with config store - const bot = new BotController(store); - - // 1. Start dashboard - const dashboard = new DashboardServer(bot, port); - await dashboard.start(); - - // 2. Create game components - const screenReader = new ScreenReader(); - const gameController = new GameController(config); - dashboard.setDebugDeps({ screenReader, gameController }); - - // 3. Start logWatcher BEFORE /hideout so we can wait for area transition - const logWatcher = new ClientLogWatcher(config.poe2LogPath); - await logWatcher.start(); - dashboard.broadcastLog('info', 'Watching Client.txt for game events'); - - // 4. Start tradeMonitor - const tradeMonitor = new TradeMonitor(config); - await tradeMonitor.start(`http://localhost:${port}`); - dashboard.broadcastLog('info', 'Browser launched'); - - // 5. Create InventoryManager - const inventoryManager = new InventoryManager(gameController, screenReader, logWatcher, config); - - // 6. /hideout + waitForAreaTransition - dashboard.broadcastLog('info', 'Sending /hideout command...'); - await gameController.focusGame(); - const arrivedHome = await inventoryManager.waitForAreaTransition( - config.travelTimeoutMs, - () => gameController.goToHideout(), - ); - if (arrivedHome) { - inventoryManager.setLocation(true); - logWatcher.currentArea = 'Hideout'; - } else { - // Assume we're already in hideout if timeout (e.g. already there) - inventoryManager.setLocation(true); - logWatcher.currentArea = 'Hideout'; - logger.warn('Timed out waiting for hideout transition on startup (may already be in hideout)'); - } - bot.state = 'IN_HIDEOUT'; - dashboard.broadcastStatus(); - dashboard.broadcastLog('info', 'In hideout, ready to trade'); - - // 7. Clear leftover inventory items to stash - dashboard.broadcastLog('info', 'Checking inventory for leftover items...'); - await inventoryManager.clearToStash(); - dashboard.broadcastLog('info', 'Inventory cleared'); - - // 8. Create executors with shared InventoryManager - const executor = new TradeExecutor( - gameController, - screenReader, - tradeMonitor, - inventoryManager, - config, - ); - - // 9. Create tradeQueue - const tradeQueue = new TradeQueue(executor, config); - - // Track running scrap executors per link ID - const scrapExecutors = new Map(); - - // 10. Activate a link based on its mode - const activateLink = async (link: TradeLink) => { - try { - if (link.mode === 'scrap') { - // Start scrap loop for this link - const scrapExec = new ScrapExecutor( - gameController, - screenReader, - tradeMonitor, - inventoryManager, - config, - ); - scrapExecutors.set(link.id, scrapExec); - dashboard.broadcastLog('info', `Scrap loop started: ${link.name || link.label}`); - dashboard.broadcastStatus(); - // Run in background (don't await — it's an infinite loop) - scrapExec.runScrapLoop(link.url, link.postAction).catch((err) => { - logger.error({ err, linkId: link.id }, 'Scrap loop error'); - dashboard.broadcastLog('error', `Scrap loop failed: ${link.name || link.label}`); - scrapExecutors.delete(link.id); - }); - } else { - // Live search mode - await tradeMonitor.addSearch(link.url); - dashboard.broadcastLog('info', `Monitoring: ${link.name || link.label}`); - dashboard.broadcastStatus(); - } - } catch (err) { - logger.error({ err, url: link.url }, 'Failed to activate link'); - dashboard.broadcastLog('error', `Failed to activate: ${link.name || link.label}`); - } - }; - - // Deactivate a link based on its mode - const deactivateLink = async (id: string) => { - // Stop scrap executor if running - const scrapExec = scrapExecutors.get(id); - if (scrapExec) { - await scrapExec.stop(); - scrapExecutors.delete(id); - } - // Pause live search if active - await tradeMonitor.pauseSearch(id); - }; - - // 11. Load all saved + CLI links (only activate ones marked active) - for (const url of allUrls) { - const link = bot.addLink(url); - if (link.active) { - await activateLink(link); - } else { - dashboard.broadcastLog('info', `Loaded (inactive): ${link.name || link.label}`); - } - } - - dashboard.broadcastLog('info', `Loaded ${allUrls.size} trade link(s) from config`); - - // When dashboard adds a link, activate it - bot.on('link-added', async (link: TradeLink) => { - if (link.active) { - await activateLink(link); - } - }); - - // When dashboard removes a link, deactivate it - bot.on('link-removed', async (id: string) => { - await deactivateLink(id); - dashboard.broadcastLog('info', `Removed search: ${id}`); - dashboard.broadcastStatus(); - }); - - // When dashboard toggles a link active/inactive - bot.on('link-toggled', async (data: { id: string; active: boolean; link: TradeLink }) => { - if (data.active) { - await activateLink(data.link); - dashboard.broadcastLog('info', `Activated: ${data.link.name || data.id}`); - } else { - await deactivateLink(data.id); - dashboard.broadcastLog('info', `Deactivated: ${data.link.name || data.id}`); - } - }); - - // When link mode changes, restart with new mode if active - bot.on('link-mode-changed', async (data: { id: string; mode: string; link: TradeLink }) => { - if (data.link.active) { - await deactivateLink(data.id); - await activateLink(data.link); - dashboard.broadcastLog('info', `Mode changed to ${data.mode}: ${data.link.name || data.id}`); - } - }); - - // When link postAction changes, restart executor if active - bot.on('link-postaction-changed', async (data: { id: string; postAction: string; link: TradeLink }) => { - if (data.link.active) { - await deactivateLink(data.id); - await activateLink(data.link); - dashboard.broadcastLog('info', `Post-action changed to ${data.postAction}: ${data.link.name || data.id}`); - } - }); - - // Wire up events: when new listings appear, queue them for trading - tradeMonitor.on('new-listings', (data: { searchId: string; itemIds: string[]; page: Page }) => { - if (bot.isPaused) { - dashboard.broadcastLog('warn', `New listings (${data.itemIds.length}) skipped - bot paused`); - return; - } - - // Check if this specific link is active - if (!bot.isLinkActive(data.searchId)) { - return; - } - - logger.info( - { searchId: data.searchId, itemCount: data.itemIds.length }, - 'New listings received, queuing trade...', - ); - dashboard.broadcastLog('info', `New listings: ${data.itemIds.length} items from ${data.searchId}`); - - tradeQueue.enqueue({ - searchId: data.searchId, - itemIds: data.itemIds, - whisperText: '', - timestamp: Date.now(), - tradeUrl: '', - page: data.page, - }); - }); - - // Forward executor state changes to dashboard - const stateInterval = setInterval(() => { - // Feed inventory state from shared InventoryManager - bot.setInventory(inventoryManager.getInventoryState()); - - // Check live trade executor state - const execState = executor.getState(); - if (execState !== 'IDLE') { - if (bot.state !== execState) { - bot.state = execState; - dashboard.broadcastStatus(); - } - return; - } - - // Check scrap executor states - for (const [, scrapExec] of scrapExecutors) { - const scrapState = scrapExec.getState(); - if (scrapState !== 'IDLE') { - if (bot.state !== scrapState) { - bot.state = scrapState; - dashboard.broadcastStatus(); - } - return; - } - } - - // All idle - if (bot.state !== 'IDLE') { - bot.state = 'IDLE'; - dashboard.broadcastStatus(); - } - }, 500); - - // Graceful shutdown const shutdown = async () => { logger.info('Shutting down...'); - clearInterval(stateInterval); - for (const [, scrapExec] of scrapExecutors) { - await scrapExec.stop(); - } - await screenReader.dispose(); - await dashboard.stop(); - await tradeMonitor.stop(); - await logWatcher.stop(); + await bot.stop(); + await server.stop(); process.exit(0); }; @@ -298,9 +50,6 @@ program process.on('SIGTERM', shutdown); logger.info(`Dashboard: http://localhost:${port}`); - logger.info( - `Monitoring ${allUrls.size} trade search(es). Press Ctrl+C to stop.`, - ); }); program.parse(); diff --git a/src/server/Server.ts b/src/server/Server.ts new file mode 100644 index 0000000..1b8cc68 --- /dev/null +++ b/src/server/Server.ts @@ -0,0 +1,97 @@ +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 { statusRoutes } from './routes/status.js'; +import { controlRoutes } from './routes/control.js'; +import { linkRoutes } from './routes/links.js'; +import { debugRoutes } from './routes/debug.js'; +import type { Bot } from '../bot/Bot.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export class Server { + private app = express(); + private server: http.Server; + private wss: WebSocketServer; + private clients: Set = new Set(); + private bot: Bot; + + constructor(bot: Bot, private port: number = 3000) { + this.bot = bot; + this.app.use(express.json()); + + this.app.get('/', (_req, res) => { + res.sendFile(path.join(__dirname, '..', '..', 'src', 'server', 'index.html')); + }); + + // Mount routes + this.app.use('/api', statusRoutes(bot)); + this.app.use('/api', controlRoutes(bot)); + this.app.use('/api/links', linkRoutes(bot)); + this.app.use('/api/debug', debugRoutes(bot, this)); + + 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: bot.getStatus() })); + ws.on('close', () => this.clients.delete(ws)); + }); + + // Subscribe to bot events + bot.on('status-update', () => this.broadcastStatus()); + bot.on('log', (level: string, message: string) => this.broadcastLog(level, message)); + } + + 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); + } + } + } + + broadcastDebug(action: string, data: Record): void { + const msg = JSON.stringify({ type: 'debug', data: { action, ...data } }); + 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()); + }); + } +} diff --git a/src/dashboard/index.html b/src/server/index.html similarity index 88% rename from src/dashboard/index.html rename to src/server/index.html index 5babde6..7dfe859 100644 --- a/src/dashboard/index.html +++ b/src/server/index.html @@ -479,8 +479,8 @@
- - + + @@ -489,8 +489,8 @@ @@ -570,6 +570,8 @@ render(); } else if (msg.type === 'log') { addLog(msg.data); + } else if (msg.type === 'debug') { + handleDebugResult(msg.data); } }; ws.onclose = () => { @@ -680,6 +682,125 @@ panel.scrollTop = panel.scrollHeight; } + // --- Handle debug results arriving via WebSocket --- + + function handleDebugResult(data) { + switch (data.action) { + case 'screenshot': + if (data.error) { showDebugResult(`Error: ${data.error}`); break; } + showDebugResult(`Screenshots saved: ${(data.files || []).map(f => f.split(/[\\/]/).pop()).join(', ')}`); + break; + case 'ocr': + showDebugResult(data.error ? `Error: ${data.error}` : data.text); + break; + case 'find-text': + if (data.error) { showDebugResult(`Error: ${data.error}`); break; } + if (data.found) { + showDebugResult(`Found "${data.searchText}" at (${data.position.x}, ${data.position.y})`); + document.getElementById('debugClickX').value = data.position.x; + document.getElementById('debugClickY').value = data.position.y; + } else { + showDebugResult(`"${data.searchText}" not found on screen`); + } + break; + case 'find-and-click': + if (data.error) { showDebugResult(`Error: ${data.error}`); break; } + showDebugResult(data.found + ? `Clicked "${data.searchText}" at (${data.position.x}, ${data.position.y})` + : `"${data.searchText}" not found on screen`); + break; + case 'click': + showDebugResult(data.error ? `Error: ${data.error}` : `Clicked at (${data.x}, ${data.y})`); + break; + case 'hideout': + showDebugResult(data.error ? `Error: ${data.error}` : 'Sent /hideout command'); + break; + case 'click-then-click': + if (data.error) { showDebugResult(`Error: ${data.error}`); break; } + if (!data.found && data.step === 'first') showDebugResult(`"${data.first}" not found on screen`); + else if (!data.found) showDebugResult(`"${data.second}" not found after clicking "${data.first}" (timed out)`); + else showDebugResult(`Clicked "${data.second}" at (${data.position.x}, ${data.position.y})`); + break; + case 'grid-scan': + if (data.error) { showDebugResult(`Error: ${data.error}`); break; } + renderGridScanResult(data); + break; + case 'test-match-hover': + if (data.error) { showDebugResult(`Error: ${data.error}`); break; } + showDebugResult(`Done: ${data.itemSize} item, ${data.matchCount} matches, hovered ${data.hoveredCount} cells`); + break; + } + } + + function renderGridScanResult(data) { + const layout = data.layout; + const hasTarget = data.targetRow != null && data.targetCol != null; + const targetRow = data.targetRow; + const targetCol = data.targetCol; + const el = document.getElementById('debugResult'); + const count = data.occupied.length; + const items = data.items || []; + const matches = data.matches || []; + const r = data.region; + let html = `${layout} ${data.cols}x${data.rows}`; + html += ` — ${count} occupied, ${items.length} items`; + if (matches.length > 0) html += `, ${matches.length} matches`; + if (r) html += `
Region: (${r.x}, ${r.y}) ${r.width}x${r.height}`; + if (items.length > 0) { + const sizes = {}; + items.forEach(i => { const k = i.w + 'x' + i.h; sizes[k] = (sizes[k]||0) + 1; }); + html += `
` + Object.entries(sizes).map(([k,v]) => `${v}x ${k}`).join(', ') + ``; + } + if (hasTarget) { + html += `
Target: (${targetRow},${targetCol})`; + if (matches.length > 0) html += ` → ${matches.map(m => `(${m.row},${m.col}) ${(m.similarity*100).toFixed(0)}%`).join(', ')}`; + html += ``; + } else { + html += `
Click a cell to find matching items`; + } + html += '
'; + if (data.image) { + html += `Grid capture`; + } + const matchSet = new Set(matches.map(m => m.row + ',' + m.col)); + const targetKey = hasTarget ? targetRow + ',' + targetCol : null; + const itemMap = {}; + const colors = ['#238636','#1f6feb','#8957e5','#da3633','#d29922','#3fb950','#388bfd','#a371f7','#f85149','#e3b341']; + items.forEach((item, idx) => { + const color = colors[idx % colors.length]; + for (let dr = 0; dr < item.h; dr++) + for (let dc = 0; dc < item.w; dc++) + itemMap[(item.row+dr)+','+(item.col+dc)] = { item, color, isOrigin: dr===0 && dc===0 }; + }); + html += `
`; + const set = new Set(data.occupied.map(c => c.row + ',' + c.col)); + for (let gr = 0; gr < data.rows; gr++) { + for (let gc = 0; gc < data.cols; gc++) { + const key = gr+','+gc; + const isTarget = key === targetKey; + const isMatch = matchSet.has(key); + const info = itemMap[key]; + let bg; + if (isTarget) bg = '#f0883e'; + else if (isMatch) bg = '#d29922'; + else if (info) bg = info.color; + else if (set.has(key)) bg = '#238636'; + else bg = ''; + const outline = (isTarget || isMatch) ? 'outline:2px solid #f0883e;z-index:1;' : ''; + const cursor = set.has(key) ? 'cursor:pointer;' : ''; + const bgStyle = bg ? `background:${bg};` : ''; + const style = (bgStyle || outline || cursor) ? ` style="${bgStyle}${outline}${cursor}"` : ''; + let title = info ? `(${gr},${gc}) ${info.item.w}x${info.item.h}` : `(${gr},${gc})`; + if (isTarget) title += ' [TARGET]'; + if (isMatch) { const m = matches.find(m => m.row===gr && m.col===gc); title += ` [MATCH ${(m.similarity*100).toFixed(0)}%]`; } + const onclick = set.has(key) ? ` onclick="debugGridScan('${layout}',${gr},${gc})"` : ''; + html += `
`; + } + } + html += '
'; + el.innerHTML = html; + } + async function togglePause() { const endpoint = status.paused ? '/api/resume' : '/api/pause'; await fetch(endpoint, { method: 'POST' }); @@ -750,7 +871,6 @@ } function openSettings() { - // Re-populate from latest status in case it changed if (status.settings) { const s = status.settings; document.getElementById('settLogPath').value = s.poe2LogPath || ''; @@ -766,12 +886,10 @@ document.getElementById('settingsModal').classList.remove('open'); } - // Close modal on overlay click document.getElementById('settingsModal').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeSettings(); }); - // Close modal on Escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSettings(); }); @@ -794,204 +912,105 @@ setTimeout(() => badge.classList.remove('show'), 2000); } - // Debug functions - let lastGridLayout = null; + // --- Debug functions (fire-and-forget: POST returns instantly, results arrive via WS) --- - async function debugGridScan(layout, targetRow, targetCol) { - lastGridLayout = layout; - const hasTarget = targetRow != null && targetCol != null; - showDebugResult(hasTarget ? `Matching (${targetRow},${targetCol}) in ${layout}...` : `Scanning ${layout}...`); - const body = { layout }; - if (hasTarget) { body.targetRow = targetRow; body.targetCol = targetCol; } - const res = await fetch('/api/debug/grid-scan', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await res.json(); - if (!data.ok) { showDebugResult(`Error: ${data.error}`); return; } - const el = document.getElementById('debugResult'); - const count = data.occupied.length; - const items = data.items || []; - const matches = data.matches || []; - const r = data.region; - let html = `${layout} ${data.cols}x${data.rows}`; - html += ` — ${count} occupied, ${items.length} items`; - if (matches.length > 0) html += `, ${matches.length} matches`; - if (r) html += `
Region: (${r.x}, ${r.y}) ${r.width}x${r.height}`; - if (items.length > 0) { - const sizes = {}; - items.forEach(i => { const k = i.w + 'x' + i.h; sizes[k] = (sizes[k]||0) + 1; }); - html += `
` + Object.entries(sizes).map(([k,v]) => `${v}x ${k}`).join(', ') + ``; - } - if (hasTarget) { - html += `
Target: (${targetRow},${targetCol})`; - if (matches.length > 0) html += ` → ${matches.map(m => `(${m.row},${m.col}) ${(m.similarity*100).toFixed(0)}%`).join(', ')}`; - html += ``; - } else { - html += `
Click a cell to find matching items`; - } - html += '
'; - if (data.image) { - html += `Grid capture`; - } - // Build match set for highlighting - const matchSet = new Set(matches.map(m => m.row + ',' + m.col)); - const targetKey = hasTarget ? targetRow + ',' + targetCol : null; - // Build item map: cell → item info - const itemMap = {}; - const colors = ['#238636','#1f6feb','#8957e5','#da3633','#d29922','#3fb950','#388bfd','#a371f7','#f85149','#e3b341']; - items.forEach((item, idx) => { - const color = colors[idx % colors.length]; - for (let dr = 0; dr < item.h; dr++) - for (let dc = 0; dc < item.w; dc++) - itemMap[(item.row+dr)+','+(item.col+dc)] = { item, color, isOrigin: dr===0 && dc===0 }; - }); - html += `
`; - const set = new Set(data.occupied.map(c => c.row + ',' + c.col)); - for (let r = 0; r < data.rows; r++) { - for (let c = 0; c < data.cols; c++) { - const key = r+','+c; - const isTarget = key === targetKey; - const isMatch = matchSet.has(key); - const info = itemMap[key]; - let bg; - if (isTarget) bg = '#f0883e'; - else if (isMatch) bg = '#d29922'; - else if (info) bg = info.color; - else if (set.has(key)) bg = '#238636'; - else bg = ''; - const outline = (isTarget || isMatch) ? 'outline:2px solid #f0883e;z-index:1;' : ''; - const cursor = set.has(key) ? 'cursor:pointer;' : ''; - const bgStyle = bg ? `background:${bg};` : ''; - const style = (bgStyle || outline || cursor) ? ` style="${bgStyle}${outline}${cursor}"` : ''; - let title = info ? `(${r},${c}) ${info.item.w}x${info.item.h}` : `(${r},${c})`; - if (isTarget) title += ' [TARGET]'; - if (isMatch) { const m = matches.find(m => m.row===r && m.col===c); title += ` [MATCH ${(m.similarity*100).toFixed(0)}%]`; } - const onclick = set.has(key) ? ` onclick="debugGridScan('${layout}',${r},${c})"` : ''; - html += `
`; - } - } - html += '
'; - el.innerHTML = html; + function debugScreenshot() { + showDebugResult('Taking screenshots...'); + fetch('/api/debug/screenshot', { method: 'POST' }); } - async function debugAngeOption(option) { - showDebugResult(`Clicking ANGE → ${option}...`); - const res = await fetch('/api/debug/click-then-click', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ first: 'ANGE', second: option }), - }); - const data = await res.json(); - if (!data.found && data.step === 'first') { - showDebugResult('ANGE not found on screen'); - } else if (!data.found) { - showDebugResult(`"${option}" not found in ANGE menu (timed out)`); - } else { - showDebugResult(`Clicked "${option}" at (${data.position.x}, ${data.position.y})`); - } - } - - async function debugHideout() { - showDebugResult('Sending /hideout...'); - const res = await fetch('/api/debug/hideout', { method: 'POST' }); - const data = await res.json(); - showDebugResult(data.ok ? 'Sent /hideout command' : `Error: ${data.error}`); - } - - async function debugScreenshot() { - const res = await fetch('/api/debug/screenshot', { method: 'POST' }); - const data = await res.json(); - showDebugResult(data.ok ? `Screenshot saved: ${data.filename}` : `Error: ${data.error}`); - } - - async function debugOcr() { + function debugOcr() { showDebugResult('Running OCR...'); - const res = await fetch('/api/debug/ocr', { method: 'POST' }); - const data = await res.json(); - showDebugResult(data.ok ? data.text : `Error: ${data.error}`); + fetch('/api/debug/ocr', { method: 'POST' }); } - async function debugFindText() { + function debugHideout() { + showDebugResult('Sending /hideout...'); + fetch('/api/debug/hideout', { method: 'POST' }); + } + + function debugFindText() { const text = document.getElementById('debugTextInput').value.trim(); if (!text) return; showDebugResult(`Searching for "${text}"...`); - const res = await fetch('/api/debug/find-text', { + fetch('/api/debug/find-text', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), }); - const data = await res.json(); - if (data.found) { - showDebugResult(`Found "${text}" at (${data.position.x}, ${data.position.y})`); - document.getElementById('debugClickX').value = data.position.x; - document.getElementById('debugClickY').value = data.position.y; - } else { - showDebugResult(`"${text}" not found on screen`); - } } - async function debugFindAndClick(directText, fuzzy) { + function debugFindAndClick(directText, fuzzy) { const text = directText || document.getElementById('debugTextInput').value.trim(); if (!text) return; showDebugResult(`Finding and clicking "${text}"${fuzzy ? ' (fuzzy)' : ''}...`); - const res = await fetch('/api/debug/find-and-click', { + fetch('/api/debug/find-and-click', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, fuzzy: !!fuzzy }), }); - const data = await res.json(); - if (data.found) { - showDebugResult(`Clicked "${text}" at (${data.position.x}, ${data.position.y})`); - } else { - showDebugResult(`"${text}" not found on screen`); - } } - async function debugClick() { + function debugClick() { const x = parseInt(document.getElementById('debugClickX').value); const y = parseInt(document.getElementById('debugClickY').value); if (isNaN(x) || isNaN(y)) return; - const res = await fetch('/api/debug/click', { + showDebugResult(`Clicking at (${x}, ${y})...`); + fetch('/api/debug/click', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ x, y }), }); - const data = await res.json(); - showDebugResult(data.ok ? `Clicked at (${x}, ${y})` : `Error: ${data.error}`); } - async function debugTestMatchHover() { + function debugAngeOption(option) { + showDebugResult(`Clicking ANGE → ${option}...`); + fetch('/api/debug/click-then-click', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ first: 'ANGE', second: option }), + }); + } + + function debugGridScan(layout, targetRow, targetCol) { + const hasTarget = targetRow != null && targetCol != null; + showDebugResult(hasTarget ? `Matching (${targetRow},${targetCol}) in ${layout}...` : `Scanning ${layout}...`); + const body = { layout }; + if (hasTarget) { body.targetRow = targetRow; body.targetCol = targetCol; } + fetch('/api/debug/grid-scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + function debugTestMatchHover() { const layout = document.getElementById('matchLayout').value; const targetRow = parseInt(document.getElementById('matchRow').value); const targetCol = parseInt(document.getElementById('matchCol').value); if (isNaN(targetRow) || isNaN(targetCol)) { showDebugResult('Invalid row/col'); return; } showDebugResult(`Scanning ${layout} and matching (${targetRow},${targetCol})...`); - const res = await fetch('/api/debug/test-match-hover', { + fetch('/api/debug/test-match-hover', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ layout, targetRow, targetCol }), }); - const data = await res.json(); - if (!data.ok) { showDebugResult(`Error: ${data.error}`); return; } - showDebugResult(`Done: ${data.itemSize} item, ${data.matchCount} matches, hovered ${data.hoveredCount} cells`); } function showDebugResult(text) { document.getElementById('debugResult').textContent = text; } - // Enter key in debug text input + // Enter key handlers document.getElementById('debugTextInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') debugFindText(); }); - // Enter key in URL input document.getElementById('urlInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') addLink(); }); + // OCR engine/preprocess (sync — these are instant) async function setOcrEngine(engine) { await fetch('/api/debug/ocr-engine', { method: 'POST', diff --git a/src/server/routes/control.ts b/src/server/routes/control.ts new file mode 100644 index 0000000..ca87866 --- /dev/null +++ b/src/server/routes/control.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import type { Bot } from '../../bot/Bot.js'; + +export function controlRoutes(bot: Bot): Router { + const router = Router(); + + router.post('/pause', (_req, res) => { + bot.pause(); + res.json({ ok: true }); + }); + + router.post('/resume', (_req, res) => { + bot.resume(); + res.json({ ok: true }); + }); + + router.post('/settings', (req, res) => { + bot.updateSettings(req.body); + res.json({ ok: true }); + }); + + return router; +} diff --git a/src/server/routes/debug.ts b/src/server/routes/debug.ts new file mode 100644 index 0000000..190976c --- /dev/null +++ b/src/server/routes/debug.ts @@ -0,0 +1,295 @@ +import { Router } from 'express'; +import { mkdir } from 'fs/promises'; +import { logger } from '../../util/logger.js'; +import { sleep } from '../../util/sleep.js'; +import { GRID_LAYOUTS } from '../../game/GridReader.js'; +import type { Bot } from '../../bot/Bot.js'; +import type { Server } from '../Server.js'; +import type { OcrEngine, OcrPreprocess } from '../../game/OcrDaemon.js'; + +export function debugRoutes(bot: Bot, server: Server): Router { + const router = Router(); + + const notReady = (_req: any, res: any): boolean => { + if (!bot.isReady) { res.status(503).json({ error: 'Not ready' }); return true; } + return false; + }; + + // --- Sync: OCR engine/preprocess selection --- + + router.get('/ocr-engine', (req, res) => { + if (notReady(req, res)) return; + res.json({ ok: true, engine: bot.screenReader.debugOcrEngine }); + }); + + router.post('/ocr-engine', (req, res) => { + if (notReady(req, res)) return; + const { engine } = req.body as { engine: string }; + if (!['tesseract', 'easyocr', 'paddleocr'].includes(engine)) { + res.status(400).json({ error: 'Invalid engine. Must be tesseract, easyocr, or paddleocr.' }); + return; + } + bot.screenReader.debugOcrEngine = engine as OcrEngine; + server.broadcastLog('info', `OCR engine set to: ${engine}`); + res.json({ ok: true }); + }); + + router.get('/ocr-preprocess', (req, res) => { + if (notReady(req, res)) return; + res.json({ ok: true, preprocess: bot.screenReader.debugPreprocess }); + }); + + router.post('/ocr-preprocess', (req, res) => { + if (notReady(req, res)) return; + const { preprocess } = req.body as { preprocess: string }; + if (!['none', 'bgsub', 'tophat'].includes(preprocess)) { + res.status(400).json({ error: 'Invalid preprocess. Must be none, bgsub, or tophat.' }); + return; + } + bot.screenReader.debugPreprocess = preprocess as OcrPreprocess; + server.broadcastLog('info', `OCR preprocess set to: ${preprocess}`); + res.json({ ok: true }); + }); + + // --- Fire-and-forget: slow debug operations --- + + router.post('/screenshot', (req, res) => { + if (notReady(req, res)) return; + res.json({ ok: true }); + bot.screenReader.saveDebugScreenshots('debug-screenshots').then(files => { + server.broadcastLog('info', `Debug screenshots saved: ${files.map(f => f.split(/[\\/]/).pop()).join(', ')}`); + server.broadcastDebug('screenshot', { files }); + }).catch(err => { + logger.error({ err }, 'Debug screenshot failed'); + server.broadcastDebug('screenshot', { error: 'Screenshot failed' }); + }); + }); + + router.post('/ocr', (req, res) => { + if (notReady(req, res)) return; + res.json({ ok: true }); + bot.screenReader.debugReadFullScreen().then(text => { + server.broadcastLog('info', `OCR [${bot.screenReader.debugOcrEngine}] (${text.length} chars): ${text.substring(0, 200)}`); + server.broadcastDebug('ocr', { text }); + }).catch(err => { + logger.error({ err }, 'Debug OCR failed'); + server.broadcastDebug('ocr', { error: 'OCR failed' }); + }); + }); + + router.post('/find-text', (req, res) => { + if (notReady(req, res)) return; + const { text } = req.body as { text: string }; + if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; } + res.json({ ok: true }); + bot.screenReader.debugFindTextOnScreen(text).then(pos => { + if (pos) { + server.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) [${bot.screenReader.debugOcrEngine}]`); + } else { + server.broadcastLog('warn', `"${text}" not found on screen [${bot.screenReader.debugOcrEngine}]`); + } + server.broadcastDebug('find-text', { searchText: text, found: !!pos, position: pos }); + }).catch(err => { + logger.error({ err }, 'Debug find-text failed'); + server.broadcastDebug('find-text', { searchText: text, error: 'Find text failed' }); + }); + }); + + router.post('/find-and-click', (req, res) => { + if (notReady(req, res)) return; + const { text, fuzzy } = req.body as { text: string; fuzzy?: boolean }; + if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; } + res.json({ ok: true }); + (async () => { + const pos = await bot.screenReader.debugFindTextOnScreen(text, !!fuzzy); + if (pos) { + await bot.gameController.focusGame(); + await bot.gameController.leftClickAt(pos.x, pos.y); + server.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) and clicked [${bot.screenReader.debugOcrEngine}]`); + server.broadcastDebug('find-and-click', { searchText: text, found: true, position: pos }); + } else { + server.broadcastLog('warn', `"${text}" not found on screen [${bot.screenReader.debugOcrEngine}]`); + server.broadcastDebug('find-and-click', { searchText: text, found: false, position: null }); + } + })().catch(err => { + logger.error({ err }, 'Debug find-and-click failed'); + server.broadcastDebug('find-and-click', { searchText: text, error: 'Find and click failed' }); + }); + }); + + router.post('/click', (req, res) => { + if (notReady(req, res)) 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; } + res.json({ ok: true }); + (async () => { + await bot.gameController.focusGame(); + await bot.gameController.leftClickAt(x, y); + server.broadcastLog('info', `Clicked at (${x}, ${y})`); + server.broadcastDebug('click', { x, y }); + })().catch(err => { + logger.error({ err }, 'Debug click failed'); + server.broadcastDebug('click', { x, y, error: 'Click failed' }); + }); + }); + + router.post('/hideout', (req, res) => { + if (notReady(req, res)) return; + res.json({ ok: true }); + (async () => { + await bot.gameController.focusGame(); + await bot.gameController.goToHideout(); + server.broadcastLog('info', 'Sent /hideout command'); + server.broadcastDebug('hideout', {}); + })().catch(err => { + logger.error({ err }, 'Debug hideout failed'); + server.broadcastDebug('hideout', { error: 'Hideout command failed' }); + }); + }); + + router.post('/click-then-click', (req, res) => { + if (notReady(req, res)) 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; } + res.json({ ok: true }); + (async () => { + const pos1 = await bot.screenReader.findTextOnScreen(first); + if (!pos1) { + server.broadcastLog('warn', `"${first}" not found on screen`); + server.broadcastDebug('click-then-click', { first, second, found: false, step: 'first' }); + return; + } + await bot.gameController.focusGame(); + await bot.gameController.leftClickAt(pos1.x, pos1.y); + server.broadcastLog('info', `Clicked "${first}" at (${pos1.x}, ${pos1.y}), waiting for "${second}"...`); + + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const pos2 = await bot.screenReader.findTextOnScreen(second); + if (pos2) { + await bot.gameController.leftClickAt(pos2.x, pos2.y); + server.broadcastLog('info', `Clicked "${second}" at (${pos2.x}, ${pos2.y})`); + server.broadcastDebug('click-then-click', { first, second, found: true, position: pos2 }); + return; + } + } + server.broadcastLog('warn', `"${second}" not found after clicking "${first}" (timed out)`); + server.broadcastDebug('click-then-click', { first, second, found: false, step: 'second' }); + })().catch(err => { + logger.error({ err }, 'Debug click-then-click failed'); + server.broadcastDebug('click-then-click', { first, second, error: 'Click-then-click failed' }); + }); + }); + + router.post('/grid-scan', (req, res) => { + if (notReady(req, res)) 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; } + res.json({ ok: true }); + (async () => { + const result = await bot.screenReader.grid.scan(layoutName, undefined, targetRow, targetCol); + const imageBuffer = await bot.screenReader.captureRegion(result.layout.region); + const imageBase64 = imageBuffer.toString('base64'); + const r = result.layout.region; + const matchInfo = result.matches ? `, ${result.matches.length} matches` : ''; + server.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}`); + server.broadcastDebug('grid-scan', { + layout: layoutName, + occupied: result.occupied, + items: result.items, + matches: result.matches, + cols: result.layout.cols, + rows: result.layout.rows, + image: imageBase64, + region: result.layout.region, + targetRow, + targetCol, + }); + })().catch(err => { + logger.error({ err }, 'Debug grid-scan failed'); + server.broadcastDebug('grid-scan', { layout: layoutName, error: 'Grid scan failed' }); + }); + }); + + router.post('/test-match-hover', (req, res) => { + if (notReady(req, res)) 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; } + res.json({ ok: true }); + (async () => { + server.broadcastLog('info', `Scanning ${layoutName} with target (${targetRow},${targetCol})...`); + const result = await bot.screenReader.grid.scan(layoutName, undefined, targetRow, targetCol); + const matches = result.matches ?? []; + const items = result.items ?? []; + + 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'; + server.broadcastLog('info', `Target (${targetRow},${targetCol}) is ${itemSize}, found ${matches.length} 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)}%` })), + ]; + + await bot.gameController.focusGame(); + await mkdir('items', { recursive: true }); + const tooltips: Array<{ row: number; col: number; label: string; text: string }> = []; + const ts = Date.now(); + const reg = result.layout.region; + const cellW = reg.width / result.layout.cols; + const cellH = reg.height / result.layout.rows; + + // Move mouse to empty space and take reference snapshot + bot.gameController.moveMouseInstant(reg.x + reg.width + 50, reg.y + reg.height / 2); + await sleep(50); + await bot.screenReader.snapshot(); + await bot.screenReader.saveScreenshot(`items/${ts}_snapshot.png`); + await sleep(200); + + for (const cell of hoverCells) { + const cellStart = performance.now(); + const x = Math.round(reg.x + cell.col * cellW + cellW / 2); + const y = Math.round(reg.y + cell.row * cellH + cellH / 2); + + await bot.gameController.moveMouseFast(x, y); + await sleep(50); + const afterMove = performance.now(); + + const imgPath = `items/${ts}_${cell.row}-${cell.col}.png`; + const diff = await bot.screenReader.diffOcr(imgPath); + const afterOcr = performance.now(); + const text = diff.text.trim(); + + const regionInfo = diff.region ? ` at (${diff.region.x},${diff.region.y}) ${diff.region.width}x${diff.region.height}` : ''; + tooltips.push({ row: cell.row, col: cell.col, label: cell.label, text }); + + server.broadcastLog('info', + `${cell.label} (${cell.row},${cell.col}) [move: ${(afterMove - cellStart).toFixed(0)}ms, ocr: ${(afterOcr - afterMove).toFixed(0)}ms, total: ${(afterOcr - cellStart).toFixed(0)}ms]${regionInfo}:`); + if (diff.lines.length > 0) { + for (const line of diff.lines) { + server.broadcastLog('info', ` ${line.text}`); + } + } else if (text) { + for (const line of text.split('\n')) { + if (line.trim()) server.broadcastLog('info', ` ${line.trim()}`); + } + } + } + + server.broadcastLog('info', `Done — hovered ${hoverCells.length} cells, read ${tooltips.filter(t => t.text).length} tooltips`); + server.broadcastDebug('test-match-hover', { + itemSize, + matchCount: matches.length, + hoveredCount: hoverCells.length, + tooltips, + }); + })().catch(err => { + logger.error({ err }, 'Debug test-match-hover failed'); + server.broadcastDebug('test-match-hover', { error: 'Test match hover failed' }); + }); + }); + + return router; +} diff --git a/src/server/routes/links.ts b/src/server/routes/links.ts new file mode 100644 index 0000000..317e1e9 --- /dev/null +++ b/src/server/routes/links.ts @@ -0,0 +1,56 @@ +import { Router } from 'express'; +import type { Bot } from '../../bot/Bot.js'; + +export function linkRoutes(bot: Bot): Router { + const router = Router(); + + router.post('/', (req, res) => { + const { url, name, mode } = req.body as { url: string; name?: string; mode?: string }; + if (!url || !url.includes('pathofexile.com/trade')) { + res.status(400).json({ error: 'Invalid trade URL' }); + return; + } + const linkMode = mode === 'scrap' ? 'scrap' : 'live'; + bot.addLink(url, name || '', linkMode); + res.json({ ok: true }); + }); + + router.delete('/:id', (req, res) => { + bot.removeLink(req.params.id); + res.json({ ok: true }); + }); + + router.post('/:id/toggle', (req, res) => { + const { active } = req.body as { active: boolean }; + bot.toggleLink(req.params.id, active); + res.json({ ok: true }); + }); + + router.post('/:id/name', (req, res) => { + const { name } = req.body as { name: string }; + bot.updateLinkName(req.params.id, name); + res.json({ ok: true }); + }); + + router.post('/:id/mode', (req, res) => { + const { mode } = req.body as { mode: string }; + if (mode !== 'live' && mode !== 'scrap') { + res.status(400).json({ error: 'Invalid mode. Must be "live" or "scrap".' }); + return; + } + bot.updateLinkMode(req.params.id, mode); + res.json({ ok: true }); + }); + + router.post('/:id/post-action', (req, res) => { + const { postAction } = req.body as { postAction: string }; + if (postAction !== 'stash' && postAction !== 'salvage') { + res.status(400).json({ error: 'Invalid postAction. Must be "stash" or "salvage".' }); + return; + } + bot.updateLinkPostAction(req.params.id, postAction); + res.json({ ok: true }); + }); + + return router; +} diff --git a/src/server/routes/status.ts b/src/server/routes/status.ts new file mode 100644 index 0000000..9e95c0e --- /dev/null +++ b/src/server/routes/status.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import type { Bot } from '../../bot/Bot.js'; + +export function statusRoutes(bot: Bot): Router { + const router = Router(); + + router.get('/status', (_req, res) => { + res.json(bot.getStatus()); + }); + + return router; +}