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

316 lines
9.7 KiB
TypeScript

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<void> {
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<void> {
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<boolean> {
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<void> {
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<boolean> {
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<void> {
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<void>,
): Promise<boolean> {
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,
};
}
}