import { join } from 'path'; import { InventoryTracker } from './InventoryTracker.js'; import type { PlacedItem } from './InventoryTracker.js'; import { GRID_LAYOUTS } from '../game/GridReader.js'; import { sleep } from '../util/sleep.js'; import { logger } from '../util/logger.js'; import type { Config, PostAction } from '../types.js'; import type { GameController } from '../game/GameController.js'; import type { ScreenReader } from '../game/ScreenReader.js'; import type { ClientLogWatcher } from '../log/ClientLogWatcher.js'; const SALVAGE_TEMPLATE = join('assets', 'salvage.png'); export class InventoryManager { readonly tracker = new InventoryTracker(); private atOwnHideout = true; private currentSellerAccount = ''; private gameController: GameController; private screenReader: ScreenReader; private logWatcher: ClientLogWatcher; private config: Config; constructor( gameController: GameController, screenReader: ScreenReader, logWatcher: ClientLogWatcher, config: Config, ) { this.gameController = gameController; this.screenReader = screenReader; this.logWatcher = logWatcher; this.config = config; } /** Set location state (called by executors when they travel). */ setLocation(atHome: boolean, seller?: string): void { this.atOwnHideout = atHome; this.currentSellerAccount = seller || ''; } get isAtOwnHideout(): boolean { return this.atOwnHideout; } get sellerAccount(): string { return this.currentSellerAccount; } /** Scan the real inventory via grid reader and initialize the tracker. */ async scanInventory(defaultAction: PostAction = 'stash'): Promise { logger.info('Scanning inventory...'); await this.gameController.focusGame(); await sleep(300); await this.gameController.openInventory(); const result = await this.screenReader.grid.scan('inventory'); // Build cells grid from occupied coords const cells: boolean[][] = Array.from({ length: 5 }, () => Array(12).fill(false)); for (const cell of result.occupied) { if (cell.row < 5 && cell.col < 12) { cells[cell.row][cell.col] = true; } } this.tracker.initFromScan(cells, result.items, defaultAction); // Close inventory await this.gameController.pressEscape(); await sleep(300); } /** Startup clear: scan inventory, deposit everything to stash. */ async clearToStash(): Promise { logger.info('Checking inventory for leftover items...'); await this.scanInventory('stash'); if (this.tracker.getItems().length === 0) { logger.info('Inventory empty, nothing to clear'); return; } logger.info({ items: this.tracker.getItems().length }, 'Found leftover items, depositing to stash'); await this.depositItemsToStash(this.tracker.getItems()); this.tracker.clear(); logger.info('Inventory cleared to stash'); } /** Ensure we are at own hideout, travel if needed. */ async ensureAtOwnHideout(): Promise { if (this.atOwnHideout) { logger.info('Already at own hideout'); return true; } await this.gameController.focusGame(); await sleep(300); const arrived = await this.waitForAreaTransition( this.config.travelTimeoutMs, () => this.gameController.goToHideout(), ); if (!arrived) { logger.error('Timed out going to own hideout'); return false; } await sleep(1500); // Wait for hideout to render this.atOwnHideout = true; this.currentSellerAccount = ''; return true; } /** Open stash and Ctrl+click given items to deposit. */ async depositItemsToStash(items: PlacedItem[]): Promise { if (items.length === 0) return; const stashPos = await this.findAndClickNameplate('Stash'); if (!stashPos) { logger.error('Could not find Stash nameplate'); return; } await sleep(1000); // Wait for stash to open const inventoryLayout = GRID_LAYOUTS.inventory; logger.info({ count: items.length }, 'Depositing items to stash'); await this.gameController.holdCtrl(); for (const item of items) { const center = this.screenReader.grid.getCellCenter(inventoryLayout, item.row, item.col); await this.gameController.leftClickAt(center.x, center.y); await sleep(150); } await this.gameController.releaseCtrl(); await sleep(500); // Close stash await this.gameController.pressEscape(); await sleep(500); logger.info({ deposited: items.length }, 'Items deposited to stash'); } /** Open salvage bench, template-match salvage button, Ctrl+click items. */ async salvageItems(items: PlacedItem[]): Promise { if (items.length === 0) return true; const salvageNameplate = await this.findAndClickNameplate('SALVAGE BENCH'); if (!salvageNameplate) { logger.error('Could not find Salvage nameplate'); return false; } await sleep(1000); // Wait for salvage bench UI to open // Template-match salvage.png to activate salvage mode const salvageBtn = await this.screenReader.templateMatch(SALVAGE_TEMPLATE); if (salvageBtn) { await this.gameController.leftClickAt(salvageBtn.x, salvageBtn.y); await sleep(500); } else { logger.warn('Could not find salvage button via template match, trying to proceed anyway'); } // CTRL+Click each inventory item to salvage const inventoryLayout = GRID_LAYOUTS.inventory; logger.info({ count: items.length }, 'Salvaging inventory items'); await this.gameController.holdCtrl(); for (const item of items) { const center = this.screenReader.grid.getCellCenter(inventoryLayout, item.row, item.col); await this.gameController.leftClickAt(center.x, center.y); await sleep(150); } await this.gameController.releaseCtrl(); await sleep(500); // Close salvage bench await this.gameController.pressEscape(); await sleep(500); return true; } /** * Full post-purchase processing cycle: * 1. Go home * 2. Salvage 'salvage' items if any * 3. Re-scan inventory (picks up salvage materials) * 4. Deposit everything to stash * 5. Clear tracker */ async processInventory(): Promise { try { // Step 1: ensure at own hideout const home = await this.ensureAtOwnHideout(); if (!home) { logger.error('Cannot process inventory: failed to reach hideout'); return; } // Step 2: salvage items tagged 'salvage' if (this.tracker.hasItemsWithAction('salvage')) { const salvageItems = this.tracker.getItemsByAction('salvage'); const salvaged = await this.salvageItems(salvageItems); if (salvaged) { this.tracker.removeItemsByAction('salvage'); } else { logger.warn('Salvage failed, depositing all items to stash instead'); } } // Step 3: re-scan inventory (picks up salvage materials + any remaining items) await this.scanInventory('stash'); // Step 4: deposit all remaining items to stash const allItems = this.tracker.getItems(); if (allItems.length > 0) { await this.depositItemsToStash(allItems); } // Step 5: clear tracker this.tracker.clear(); logger.info('Inventory processing complete'); } catch (err) { logger.error({ err }, 'Inventory processing failed'); // Try to recover UI state try { await this.gameController.pressEscape(); await sleep(300); } catch { // Best-effort } this.tracker.clear(); } } /** Find and click a nameplate by OCR text (fuzzy, with retries). */ 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, true); 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) { await sleep(retryDelayMs); } } logger.warn({ name, maxRetries }, 'Nameplate not found after all retries'); return null; } /** * Wait for area transition via Client.txt log. * If `triggerAction` is provided, the listener is registered BEFORE the action * executes, preventing the race where the event fires before we listen. */ waitForAreaTransition( timeoutMs: number, triggerAction?: () => Promise, ): Promise { return new Promise((resolve) => { let resolved = false; const timer = setTimeout(() => { if (!resolved) { resolved = true; this.logWatcher.removeListener('area-entered', handler); resolve(false); } }, timeoutMs); const handler = () => { if (!resolved) { resolved = true; clearTimeout(timer); resolve(true); } }; // Register listener FIRST this.logWatcher.once('area-entered', handler); // THEN trigger the action that causes the transition if (triggerAction) { triggerAction().catch(() => { if (!resolved) { resolved = true; clearTimeout(timer); this.logWatcher.removeListener('area-entered', handler); resolve(false); } }); } }); } /** Get inventory state for dashboard display. */ getInventoryState(): { grid: boolean[][]; items: { row: number; col: number; w: number; h: number }[]; free: number } { return { grid: this.tracker.getGrid(), items: this.tracker.getItems(), free: this.tracker.freeCells, }; } }