234 lines
8.1 KiB
TypeScript
234 lines
8.1 KiB
TypeScript
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;
|
|
|
|
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;
|
|
}
|
|
|
|
getState(): ScrapState {
|
|
return this.state;
|
|
}
|
|
|
|
/** 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, postAction: PostAction = 'salvage'): Promise<void> {
|
|
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.state = '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.state = 'IDLE';
|
|
logger.info('Scrap loop ended');
|
|
}
|
|
|
|
/** Buy one item from a seller. */
|
|
private async buyItem(page: Page, item: TradeItem): Promise<boolean> {
|
|
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.state = '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.state = 'FAILED';
|
|
return false;
|
|
}
|
|
|
|
this.inventoryManager.setLocation(false, 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 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.state = 'IDLE';
|
|
return true;
|
|
} catch (err) {
|
|
logger.error({ err, itemId: item.id }, 'Error buying item');
|
|
this.state = 'FAILED';
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Process inventory: salvage/stash cycle via InventoryManager. */
|
|
private async processItems(): Promise<void> {
|
|
try {
|
|
this.state = 'SALVAGING';
|
|
await this.inventoryManager.processInventory();
|
|
this.state = 'IDLE';
|
|
logger.info('Process cycle complete');
|
|
} catch (err) {
|
|
logger.error({ err }, 'Process cycle failed');
|
|
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;
|
|
}
|
|
}
|