added easyOCR
This commit is contained in:
parent
37d6678577
commit
9f208b0606
27 changed files with 1780 additions and 112 deletions
433
src/executor/ScrapExecutor.ts
Normal file
433
src/executor/ScrapExecutor.ts
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
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 { TradeMonitor } from '../trade/TradeMonitor.js';
|
||||
import { InventoryTracker } from '../inventory/InventoryTracker.js';
|
||||
import { sleep, randomDelay } from '../util/sleep.js';
|
||||
import { logger } from '../util/logger.js';
|
||||
import type { Config, ScrapState, TradeItem } from '../types.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 gameController: GameController;
|
||||
private screenReader: ScreenReader;
|
||||
private logWatcher: ClientLogWatcher;
|
||||
private tradeMonitor: TradeMonitor;
|
||||
private config: Config;
|
||||
|
||||
constructor(
|
||||
gameController: GameController,
|
||||
screenReader: ScreenReader,
|
||||
logWatcher: ClientLogWatcher,
|
||||
tradeMonitor: TradeMonitor,
|
||||
config: Config,
|
||||
) {
|
||||
this.gameController = gameController;
|
||||
this.screenReader = screenReader;
|
||||
this.logWatcher = logWatcher;
|
||||
this.tradeMonitor = tradeMonitor;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
getState(): ScrapState {
|
||||
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;
|
||||
if (this.activePage) {
|
||||
try { await this.activePage.close(); } catch { /* best-effort */ }
|
||||
this.activePage = null;
|
||||
}
|
||||
this.state = 'IDLE';
|
||||
logger.info('Scrap executor stopped');
|
||||
}
|
||||
|
||||
/** Main entry point — runs the full scrap loop. */
|
||||
async runScrapLoop(tradeUrl: string): Promise<void> {
|
||||
this.stopped = false;
|
||||
logger.info({ tradeUrl }, 'Starting scrap loop');
|
||||
|
||||
// Scan real inventory to know current state
|
||||
await this.scanInventory();
|
||||
|
||||
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.inventory.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();
|
||||
|
||||
// Check if salvage 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');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-scan inventory after salvage to get accurate state
|
||||
await this.scanInventory();
|
||||
}
|
||||
|
||||
// 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');
|
||||
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.state = 'IDLE';
|
||||
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
|
||||
&& item.account
|
||||
&& item.account === this.currentSellerAccount;
|
||||
|
||||
if (alreadyAtSeller) {
|
||||
logger.info({ itemId: item.id, account: item.account }, 'Already at seller hideout, skipping travel');
|
||||
} else {
|
||||
this.state = 'TRAVELING';
|
||||
|
||||
// Register listener BEFORE clicking, then click inside the callback
|
||||
const arrived = await this.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.state = 'FAILED';
|
||||
return false;
|
||||
}
|
||||
|
||||
this.atOwnHideout = false;
|
||||
this.currentSellerAccount = item.account;
|
||||
await this.gameController.focusGame();
|
||||
await sleep(1500); // Wait for hideout to render
|
||||
}
|
||||
|
||||
this.state = '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
|
||||
const placed = this.inventory.tryPlace(item.w, item.h);
|
||||
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');
|
||||
this.state = 'IDLE';
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error({ err, itemId: item.id }, 'Error buying item');
|
||||
this.state = 'FAILED';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Salvage all items in inventory and store the materials. */
|
||||
private async salvageAndStore(): 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();
|
||||
this.state = 'IDLE';
|
||||
logger.info('Salvage and store 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
|
||||
this.state = 'FAILED';
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh the trade page and return new items. */
|
||||
private async refreshPage(page: Page): Promise<TradeItem[]> {
|
||||
const items: TradeItem[] = [];
|
||||
|
||||
// Set up response listener before reloading
|
||||
const responseHandler = async (response: { url(): string; json(): Promise<any> }) => {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue