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, ); // Pre-warm OCR daemon + EasyOCR model in background (don't await yet) const ocrWarmup = this.screenReader.warmup().catch(err => { logger.warn({ err }, 'OCR warmup failed (will retry on first use)'); }); // Check if already in hideout from log tail const alreadyInHideout = this.logWatcher.currentArea.toLowerCase().includes('hideout'); if (alreadyInHideout) { logger.info({ area: this.logWatcher.currentArea }, 'Already in hideout, skipping /hideout command'); this.inventoryManager.setLocation(true); } else { 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'); // Ensure OCR warmup finished before proceeding to inventory scan await ocrWarmup; // 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); } }