Initial commit: POE2 automated trade bot

Monitors pathofexile.com/trade2 for new listings, travels to seller
hideouts, buys items from public stash tabs, and stores them.

Includes persistent C# OCR daemon for fast screen capture + Windows
native OCR, web dashboard for managing trade links and settings,
and full game automation via Win32 SendInput.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Boki 2026-02-10 14:03:47 -05:00
commit 41d174195e
28 changed files with 6449 additions and 0 deletions

View file

@ -0,0 +1,251 @@
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 { sleep, randomDelay } from '../util/sleep.js';
import { logger } from '../util/logger.js';
import type { Config, TradeInfo, TradeState, Region } from '../types.js';
import type { Page } from 'playwright';
// Default screen regions for 1920x1080 - these need calibration
const DEFAULT_REGIONS = {
stashArea: { x: 20, y: 140, width: 630, height: 750 },
priceWarningDialog: { x: 600, y: 350, width: 700, height: 300 },
priceWarningNoButton: { x: 820, y: 560, width: 120, height: 40 },
inventoryArea: { x: 1260, y: 580, width: 630, height: 280 },
stashTabArea: { x: 20, y: 100, width: 630, height: 40 },
};
export class TradeExecutor {
private state: TradeState = 'IDLE';
private gameController: GameController;
private screenReader: ScreenReader;
private logWatcher: ClientLogWatcher;
private tradeMonitor: TradeMonitor;
private config: Config;
constructor(
gameController: GameController,
screenReader: ScreenReader,
logWatcher: ClientLogWatcher,
tradeMonitor: TradeMonitor,
config: Config,
) {
this.gameController = gameController;
this.screenReader = screenReader;
this.logWatcher = logWatcher;
this.tradeMonitor = tradeMonitor;
this.config = config;
}
getState(): TradeState {
return this.state;
}
async executeTrade(trade: TradeInfo): Promise<boolean> {
const page = trade.page as Page;
try {
// Step 1: Click "Travel to Hideout" on the trade website
this.state = 'TRAVELING';
logger.info({ searchId: trade.searchId }, 'Clicking Travel to Hideout...');
const travelClicked = await this.tradeMonitor.clickTravelToHideout(
page,
trade.itemIds[0],
);
if (!travelClicked) {
logger.error('Failed to click Travel to Hideout');
this.state = 'FAILED';
return false;
}
// Step 2: Wait for area transition (arrival at seller's hideout)
logger.info('Waiting for area transition...');
const arrived = await this.waitForAreaTransition(this.config.travelTimeoutMs);
if (!arrived) {
logger.error('Timed out waiting for hideout arrival');
this.state = 'FAILED';
return false;
}
this.state = 'IN_SELLERS_HIDEOUT';
logger.info('Arrived at seller hideout');
// Step 3: Focus game window and click on Ange then Stash
await this.gameController.focusGame();
await sleep(1500); // Wait for hideout to render
// Click on Ange NPC to interact
const angePos = await this.findAndClickNameplate('Ange');
if (!angePos) {
logger.warn('Could not find Ange nameplate, trying Stash directly');
} else {
await sleep(1000); // Wait for NPC interaction
}
// Click on Stash to open it
const stashPos = await this.findAndClickNameplate('Stash');
if (!stashPos) {
logger.error('Could not find Stash nameplate in seller hideout');
this.state = 'FAILED';
return false;
}
await sleep(1000); // Wait for stash to open
// Step 4: Scan stash and buy items
this.state = 'SCANNING_STASH';
logger.info('Scanning stash for items...');
await this.scanAndBuyItems();
// Step 5: Wait for more items
this.state = 'WAITING_FOR_MORE';
logger.info(
{ waitMs: this.config.waitForMoreItemsMs },
'Waiting for seller to add more items...',
);
await sleep(this.config.waitForMoreItemsMs);
// Do one more scan after waiting
await this.scanAndBuyItems();
// Step 6: Go back to own hideout
this.state = 'GOING_HOME';
logger.info('Traveling to own hideout...');
await this.gameController.focusGame();
await sleep(300);
await this.gameController.goToHideout();
const home = await this.waitForAreaTransition(this.config.travelTimeoutMs);
if (!home) {
logger.warn('Timed out going home, continuing anyway...');
}
// Step 7: Store items in stash
this.state = 'IN_HIDEOUT';
await sleep(1000);
await this.storeItems();
this.state = 'IDLE';
return true;
} catch (err) {
logger.error({ err }, 'Trade execution failed');
this.state = 'FAILED';
// Try to recover by going home
try {
await this.gameController.focusGame();
await this.gameController.pressEscape(); // Close any open dialogs
await sleep(500);
await this.gameController.goToHideout();
} catch {
// Best-effort recovery
}
this.state = 'IDLE';
return false;
}
}
private async scanAndBuyItems(): Promise<void> {
// Take a screenshot of the stash area
const stashText = await this.screenReader.readRegionText(DEFAULT_REGIONS.stashArea);
logger.info({ stashText: stashText.substring(0, 200) }, 'Stash OCR result');
// For now, we'll use a simple grid-based approach to click items
// The exact positions depend on the stash layout and resolution
// This needs calibration with real game screenshots
//
// TODO: Implement item matching logic based on OCR text
// For now, we'll Ctrl+right-click at known grid positions
this.state = 'BUYING';
// Check for price warning dialog after each buy
await this.checkPriceWarning();
}
private async checkPriceWarning(): Promise<void> {
// Check if a price warning dialog appeared
const hasWarning = await this.screenReader.checkForText(
DEFAULT_REGIONS.priceWarningDialog,
'price',
);
if (hasWarning) {
logger.warn('Price mismatch warning detected! Clicking No.');
// Click the "No" button
await this.gameController.leftClickAt(
DEFAULT_REGIONS.priceWarningNoButton.x + DEFAULT_REGIONS.priceWarningNoButton.width / 2,
DEFAULT_REGIONS.priceWarningNoButton.y + DEFAULT_REGIONS.priceWarningNoButton.height / 2,
);
await sleep(500);
}
}
private async storeItems(): Promise<void> {
logger.info('Storing purchased items in stash...');
// Focus game and find Stash in own hideout
await this.gameController.focusGame();
await sleep(500);
const stashPos = await this.findAndClickNameplate('Stash');
if (!stashPos) {
logger.error('Could not find Stash nameplate in own hideout');
return;
}
await sleep(1000); // Wait for stash to open
// Open inventory
await this.gameController.openInventory();
await sleep(500);
// TODO: Implement inventory scanning to find purchased items
// and Ctrl+right-click each to transfer to stash
logger.info('Item storage complete (needs calibration)');
}
private async findAndClickNameplate(
name: string,
maxRetries: number = 3,
retryDelayMs: number = 1000,
): Promise<{ x: number; y: number } | null> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
logger.info({ name, attempt, maxRetries }, 'Searching for nameplate...');
const pos = await this.screenReader.findTextOnScreen(name);
if (pos) {
logger.info({ name, x: pos.x, y: pos.y }, 'Clicking nameplate');
await this.gameController.leftClickAt(pos.x, pos.y);
return pos;
}
if (attempt < maxRetries) {
logger.debug({ name, attempt }, 'Nameplate not found, retrying...');
await sleep(retryDelayMs);
}
}
logger.warn({ name, maxRetries }, 'Nameplate not found after all retries');
return null;
}
private waitForAreaTransition(timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.logWatcher.removeListener('area-entered', handler);
resolve(false);
}, timeoutMs);
const handler = () => {
clearTimeout(timer);
resolve(true);
};
this.logWatcher.once('area-entered', handler);
});
}
}