import { GameController } from '../game/GameController.js'; import { GRID_LAYOUTS } from '../game/GridReader.js'; import { TradeMonitor } from '../trade/TradeMonitor.js'; import { InventoryManager } from '../inventory/InventoryManager.js'; import { sleep, randomDelay } from '../util/sleep.js'; import { logger } from '../util/logger.js'; import type { Config, ScrapState, TradeItem, PostAction } from '../types.js'; import type { ScreenReader } from '../game/ScreenReader.js'; import type { Page } from 'playwright'; export class ScrapExecutor { private state: ScrapState = 'IDLE'; private stopped = false; private activePage: Page | null = null; private postAction: PostAction = 'salvage'; 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(): 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; if (this.activePage) { try { await this.activePage.close(); } catch { /* best-effort */ } this.activePage = null; } this.setState('IDLE'); logger.info('Scrap executor stopped'); } /** Main entry point — runs the full scrap loop. */ async runScrapLoop(tradeUrl: string, postAction: PostAction = 'salvage'): Promise { this.stopped = false; this.postAction = postAction; logger.info({ tradeUrl, postAction }, 'Starting scrap loop'); // Scan real inventory to know current state await this.inventoryManager.scanInventory(this.postAction); let { page, items } = await this.tradeMonitor.openScrapPage(tradeUrl); this.activePage = page; logger.info({ itemCount: items.length }, 'Trade page opened, items fetched'); while (!this.stopped) { let salvageFailed = false; for (const item of items) { if (this.stopped) break; // Check if this item fits before traveling if (!this.inventoryManager.tracker.canFit(item.w, item.h)) { // If salvage already failed this page, don't retry — skip remaining items if (salvageFailed) { logger.info({ w: item.w, h: item.h }, 'Skipping item (salvage already failed this page)'); continue; } logger.info({ w: item.w, h: item.h, free: this.inventoryManager.tracker.freeCells }, 'No room for item, running process cycle'); await this.processItems(); // Check if process succeeded (state is IDLE on success, FAILED otherwise) if (this.state === 'FAILED') { salvageFailed = true; this.setState('IDLE'); logger.warn('Process cycle failed, skipping remaining items that do not fit'); continue; } // Re-scan inventory after processing to get accurate state await this.inventoryManager.scanInventory(this.postAction); } // Still no room after processing — skip this item if (!this.inventoryManager.tracker.canFit(item.w, item.h)) { logger.warn({ w: item.w, h: item.h, free: this.inventoryManager.tracker.freeCells }, 'Item still cannot fit after processing, skipping'); continue; } const success = await this.buyItem(page, item); if (!success) { logger.warn({ itemId: item.id }, 'Failed to buy item, continuing'); continue; } await randomDelay(500, 1000); } if (this.stopped) break; // Page exhausted — refresh and get new items logger.info('Page exhausted, refreshing...'); items = await this.refreshPage(page); logger.info({ itemCount: items.length }, 'Page refreshed'); if (items.length === 0) { logger.info('No items after refresh, waiting before retry...'); await sleep(5000); if (this.stopped) break; items = await this.refreshPage(page); } } this.activePage = null; this.setState('IDLE'); logger.info('Scrap loop ended'); } /** Buy one item from a seller. */ private async buyItem(page: Page, item: TradeItem): Promise { try { const alreadyAtSeller = !this.inventoryManager.isAtOwnHideout && item.account && item.account === this.inventoryManager.sellerAccount; if (alreadyAtSeller) { logger.info({ itemId: item.id, account: item.account }, 'Already at seller hideout, skipping travel'); } else { this.setState('TRAVELING'); // Register listener BEFORE clicking, then click inside the callback const arrived = await this.inventoryManager.waitForAreaTransition( this.config.travelTimeoutMs, async () => { const clicked = await this.tradeMonitor.clickTravelToHideout(page, item.id); if (!clicked) { throw new Error('Failed to click Travel to Hideout'); } }, ); if (!arrived) { logger.error({ itemId: item.id }, 'Timed out waiting for hideout arrival'); this.setState('FAILED'); return false; } this.inventoryManager.setLocation(false, item.account); await this.gameController.focusGame(); await sleep(1500); // Wait for hideout to render } this.setState('BUYING'); // CTRL+Click at seller stash position const sellerLayout = GRID_LAYOUTS.seller; const cellCenter = this.screenReader.grid.getCellCenter(sellerLayout, item.stashY, item.stashX); logger.info({ itemId: item.id, stashX: item.stashX, stashY: item.stashY, screenX: cellCenter.x, screenY: cellCenter.y }, 'CTRL+clicking seller stash item'); await this.gameController.ctrlLeftClickAt(cellCenter.x, cellCenter.y); await randomDelay(200, 400); // Track in inventory with this link's postAction const placed = this.inventoryManager.tracker.tryPlace(item.w, item.h, this.postAction); if (!placed) { logger.warn({ itemId: item.id, w: item.w, h: item.h }, 'Item bought but could not track in inventory'); } logger.info({ itemId: item.id, free: this.inventoryManager.tracker.freeCells }, 'Item bought successfully'); this.setState('IDLE'); return true; } catch (err) { logger.error({ err, itemId: item.id }, 'Error buying item'); this.setState('FAILED'); return false; } } /** Process inventory: salvage/stash cycle via InventoryManager. */ private async processItems(): Promise { try { this.setState('SALVAGING'); await this.inventoryManager.processInventory(); this.setState('IDLE'); logger.info('Process cycle complete'); } catch (err) { logger.error({ err }, 'Process cycle failed'); this.setState('FAILED'); } } /** Refresh the trade page and return new items. */ private async refreshPage(page: Page): Promise { const items: TradeItem[] = []; // Set up response listener before reloading const responseHandler = async (response: { url(): string; json(): Promise }) => { if (response.url().includes('/api/trade2/fetch/')) { try { const json = await response.json(); if (json.result && Array.isArray(json.result)) { for (const r of json.result) { items.push({ id: r.id, w: r.item?.w ?? 1, h: r.item?.h ?? 1, stashX: r.listing?.stash?.x ?? 0, stashY: r.listing?.stash?.y ?? 0, account: r.listing?.account?.name ?? '', }); } } } catch { // Response may not be JSON } } }; page.on('response', responseHandler); await page.reload({ waitUntil: 'networkidle' }); await sleep(2000); page.off('response', responseHandler); return items; } }