poe2-bot/src-old/bot/Bot.ts
2026-02-13 01:12:11 -05:00

406 lines
13 KiB
TypeScript

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<string, ScrapExecutor>();
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<string, unknown>): 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<void> {
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<string>([
...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<void> {
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<void> {
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<void> {
const scrapExec = this.scrapExecutors.get(id);
if (scrapExec) {
await scrapExec.stop();
this.scrapExecutors.delete(id);
}
await this.tradeMonitor.pauseSearch(id);
}
}