inventory types

This commit is contained in:
Boki 2026-02-12 12:00:29 -05:00
parent cf5d944fd1
commit 3d7a8aafdf
9 changed files with 532 additions and 369 deletions

View file

@ -1,41 +1,35 @@
import { join } from 'path';
import { GameController } from '../game/GameController.js';
import { ScreenReader } from '../game/ScreenReader.js';
import { GridReader, GRID_LAYOUTS } from '../game/GridReader.js';
import { ClientLogWatcher } from '../log/ClientLogWatcher.js';
import { GRID_LAYOUTS } from '../game/GridReader.js';
import { TradeMonitor } from '../trade/TradeMonitor.js';
import { InventoryTracker } from '../inventory/InventoryTracker.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 } from '../types.js';
import type { Config, ScrapState, TradeItem, PostAction } from '../types.js';
import type { ScreenReader } from '../game/ScreenReader.js';
import type { Page } from 'playwright';
const SALVAGE_TEMPLATE = join('assets', 'salvage.png');
export class ScrapExecutor {
private inventory = new InventoryTracker();
private state: ScrapState = 'IDLE';
private stopped = false;
private atOwnHideout = true;
private currentSellerAccount = '';
private activePage: Page | null = null;
private postAction: PostAction = 'salvage';
private gameController: GameController;
private screenReader: ScreenReader;
private logWatcher: ClientLogWatcher;
private tradeMonitor: TradeMonitor;
private inventoryManager: InventoryManager;
private config: Config;
constructor(
gameController: GameController,
screenReader: ScreenReader,
logWatcher: ClientLogWatcher,
tradeMonitor: TradeMonitor,
inventoryManager: InventoryManager,
config: Config,
) {
this.gameController = gameController;
this.screenReader = screenReader;
this.logWatcher = logWatcher;
this.tradeMonitor = tradeMonitor;
this.inventoryManager = inventoryManager;
this.config = config;
}
@ -43,14 +37,6 @@ export class ScrapExecutor {
return this.state;
}
getInventoryState(): { grid: boolean[][]; items: { row: number; col: number; w: number; h: number }[]; free: number } {
return {
grid: this.inventory.getGrid(),
items: this.inventory.getItems(),
free: this.inventory.freeCells,
};
}
/** Stop the scrap loop gracefully. */
async stop(): Promise<void> {
this.stopped = true;
@ -63,12 +49,13 @@ export class ScrapExecutor {
}
/** Main entry point — runs the full scrap loop. */
async runScrapLoop(tradeUrl: string): Promise<void> {
async runScrapLoop(tradeUrl: string, postAction: PostAction = 'salvage'): Promise<void> {
this.stopped = false;
logger.info({ tradeUrl }, 'Starting scrap loop');
this.postAction = postAction;
logger.info({ tradeUrl, postAction }, 'Starting scrap loop');
// Scan real inventory to know current state
await this.scanInventory();
await this.inventoryManager.scanInventory(this.postAction);
let { page, items } = await this.tradeMonitor.openScrapPage(tradeUrl);
this.activePage = page;
@ -81,31 +68,31 @@ export class ScrapExecutor {
if (this.stopped) break;
// Check if this item fits before traveling
if (!this.inventory.canFit(item.w, item.h)) {
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.inventory.freeCells }, 'No room for item, running salvage cycle');
await this.salvageAndStore();
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 salvage succeeded (state is IDLE on success, FAILED otherwise)
// Check if process succeeded (state is IDLE on success, FAILED otherwise)
if (this.state === 'FAILED') {
salvageFailed = true;
this.state = 'IDLE';
logger.warn('Salvage failed, skipping remaining items that do not fit');
logger.warn('Process cycle failed, skipping remaining items that do not fit');
continue;
}
// Re-scan inventory after salvage to get accurate state
await this.scanInventory();
// Re-scan inventory after processing to get accurate state
await this.inventoryManager.scanInventory(this.postAction);
}
// Still no room after salvage — skip this item
if (!this.inventory.canFit(item.w, item.h)) {
logger.warn({ w: item.w, h: item.h, free: this.inventory.freeCells }, 'Item still cannot fit after salvage, skipping');
// 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;
}
@ -138,35 +125,12 @@ export class ScrapExecutor {
logger.info('Scrap loop ended');
}
/** Scan the real inventory via grid reader and initialize the tracker. */
private async scanInventory(): 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.inventory.initFromScan(cells, result.items);
// Close inventory
await this.gameController.pressEscape();
await sleep(300);
}
/** Buy one item from a seller. */
private async buyItem(page: Page, item: TradeItem): Promise<boolean> {
try {
const alreadyAtSeller = !this.atOwnHideout
const alreadyAtSeller = !this.inventoryManager.isAtOwnHideout
&& item.account
&& item.account === this.currentSellerAccount;
&& item.account === this.inventoryManager.sellerAccount;
if (alreadyAtSeller) {
logger.info({ itemId: item.id, account: item.account }, 'Already at seller hideout, skipping travel');
@ -174,7 +138,7 @@ export class ScrapExecutor {
this.state = 'TRAVELING';
// Register listener BEFORE clicking, then click inside the callback
const arrived = await this.waitForAreaTransition(
const arrived = await this.inventoryManager.waitForAreaTransition(
this.config.travelTimeoutMs,
async () => {
const clicked = await this.tradeMonitor.clickTravelToHideout(page, item.id);
@ -189,8 +153,7 @@ export class ScrapExecutor {
return false;
}
this.atOwnHideout = false;
this.currentSellerAccount = item.account;
this.inventoryManager.setLocation(false, item.account);
await this.gameController.focusGame();
await sleep(1500); // Wait for hideout to render
}
@ -205,13 +168,13 @@ export class ScrapExecutor {
await this.gameController.ctrlLeftClickAt(cellCenter.x, cellCenter.y);
await randomDelay(200, 400);
// Track in inventory
const placed = this.inventory.tryPlace(item.w, item.h);
// 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.inventory.freeCells }, 'Item bought successfully');
logger.info({ itemId: item.id, free: this.inventoryManager.tracker.freeCells }, 'Item bought successfully');
this.state = 'IDLE';
return true;
} catch (err) {
@ -221,106 +184,15 @@ export class ScrapExecutor {
}
}
/** Salvage all items in inventory and store the materials. */
private async salvageAndStore(): Promise<void> {
/** Process inventory: salvage/stash cycle via InventoryManager. */
private async processItems(): Promise<void> {
try {
// Go to own hideout (skip if already there)
await this.gameController.focusGame();
await sleep(300);
if (this.atOwnHideout) {
logger.info('Already at own hideout, skipping /hideout');
} else {
this.state = 'TRAVELING';
// Register listener BEFORE sending /hideout command
const arrived = await this.waitForAreaTransition(
this.config.travelTimeoutMs,
() => this.gameController.goToHideout(),
);
if (!arrived) {
logger.error('Timed out going home for salvage');
this.state = 'FAILED';
return;
}
await sleep(1500); // Wait for hideout to render
}
this.atOwnHideout = true;
this.currentSellerAccount = '';
// Open salvage bench via nameplate OCR
this.state = 'SALVAGING';
const salvageNameplate = await this.findAndClickNameplate('SALVAGE BENCH');
if (!salvageNameplate) {
logger.error('Could not find Salvage nameplate');
this.state = 'FAILED';
return;
}
await sleep(1000); // Wait for salvage bench UI to open
// Template-match salvage.png to activate salvage mode within the bench UI
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;
const itemsToSalvage = this.inventory.getItems();
logger.info({ count: itemsToSalvage.length }, 'Salvaging inventory items');
await this.gameController.holdCtrl();
for (const item of itemsToSalvage) {
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 (Escape)
await this.gameController.pressEscape();
await sleep(500);
// Open stash to store salvaged materials
this.state = 'STORING';
const stashPos = await this.findAndClickNameplate('Stash');
if (!stashPos) {
logger.error('Could not find Stash nameplate');
this.state = 'FAILED';
return;
}
await sleep(1000); // Wait for stash to open
// CTRL+Click each remaining inventory item to store
await this.gameController.holdCtrl();
for (const item of itemsToSalvage) {
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);
// Clear inventory tracker
this.inventory.clear();
await this.inventoryManager.processInventory();
this.state = 'IDLE';
logger.info('Salvage and store cycle complete');
logger.info('Process cycle complete');
} catch (err) {
logger.error({ err }, 'Salvage cycle failed');
// Try to recover UI state
try {
await this.gameController.pressEscape();
await sleep(300);
} catch {
// Best-effort
}
this.inventory.clear();
// Leave state as FAILED so the caller knows salvage didn't succeed
logger.error({ err }, 'Process cycle failed');
this.state = 'FAILED';
}
}
@ -359,75 +231,4 @@ export class ScrapExecutor {
return items;
}
/**
* 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.
*/
private 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 the action itself fails, clean up and resolve false
if (!resolved) {
resolved = true;
clearTimeout(timer);
this.logWatcher.removeListener('area-entered', handler);
resolve(false);
}
});
}
});
}
/** Find and click a nameplate by OCR text. */
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, 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;
}
}