import { GameController } from '../game/GameController.js'; import { ScreenReader } from '../game/ScreenReader.js'; import { TradeMonitor } from '../trade/TradeMonitor.js'; import { InventoryManager } from '../inventory/InventoryManager.js'; import { sleep } 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 tradeMonitor: TradeMonitor; private inventoryManager: InventoryManager; private config: Config; private _onStateChange?: (state: string) => void; constructor( gameController: GameController, screenReader: ScreenReader, tradeMonitor: TradeMonitor, inventoryManager: InventoryManager, config: Config, ) { this.gameController = gameController; this.screenReader = screenReader; this.tradeMonitor = tradeMonitor; this.inventoryManager = inventoryManager; 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.setState('TRAVELING'); logger.info({ searchId: trade.searchId }, 'Clicking Travel to Hideout...'); // Register listener BEFORE clicking, then click inside the callback const arrived = await this.inventoryManager.waitForAreaTransition( this.config.travelTimeoutMs, async () => { const travelClicked = await this.tradeMonitor.clickTravelToHideout( page, trade.itemIds[0], ); if (!travelClicked) { throw new Error('Failed to click Travel to Hideout'); } }, ); if (!arrived) { logger.error('Timed out waiting for hideout arrival'); this.setState('FAILED'); return false; } this.setState('IN_SELLERS_HIDEOUT'); this.inventoryManager.setLocation(false); 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.inventoryManager.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.inventoryManager.findAndClickNameplate('Stash'); if (!stashPos) { logger.error('Could not find Stash nameplate in seller hideout'); this.setState('FAILED'); return false; } await sleep(1000); // Wait for stash to open // Step 4: Scan stash and buy items this.setState('SCANNING_STASH'); logger.info('Scanning stash for items...'); await this.scanAndBuyItems(); // Step 5: Wait for more items this.setState('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.setState('GOING_HOME'); logger.info('Traveling to own hideout...'); await this.gameController.focusGame(); await sleep(300); const home = await this.inventoryManager.waitForAreaTransition( this.config.travelTimeoutMs, () => this.gameController.goToHideout(), ); if (!home) { logger.warn('Timed out going home, continuing anyway...'); } this.inventoryManager.setLocation(true); // Step 7: Store items in stash this.setState('IN_HIDEOUT'); await sleep(1000); await this.storeItems(); this.setState('IDLE'); return true; } catch (err) { logger.error({ err }, 'Trade execution failed'); this.setState('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.setState('IDLE'); return false; } } private async scanAndBuyItems(): Promise { // 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.setState('BUYING'); // Check for price warning dialog after each buy await this.checkPriceWarning(); } private async checkPriceWarning(): Promise { // 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 { logger.info('Storing purchased items...'); await this.inventoryManager.processInventory(); logger.info('Item storage complete'); } }