switched to new way :)

This commit is contained in:
Boki 2026-02-13 01:12:11 -05:00
parent b03a2a25f1
commit f22d182c8f
30 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,244 @@
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;
private _onStateChange?: (state: string) => void;
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;
}
set onStateChange(cb: (state: string) => void) {
this._onStateChange = cb;
}
getState(): ScrapState {
return this.state;
}
private setState(s: ScrapState): void {
this.state = s;
this._onStateChange?.(s);
}
/** 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.setState('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.setState('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.setState('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.setState('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.setState('FAILED');
return false;
}
this.inventoryManager.setLocation(false, item.account);
await this.gameController.focusGame();
await sleep(1500); // Wait for hideout to render
}
this.setState('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.setState('IDLE');
return true;
} catch (err) {
logger.error({ err, itemId: item.id }, 'Error buying item');
this.setState('FAILED');
return false;
}
}
/** Process inventory: salvage/stash cycle via InventoryManager. */
private async processItems(): Promise<void> {
try {
this.setState('SALVAGING');
await this.inventoryManager.processInventory();
this.setState('IDLE');
logger.info('Process cycle complete');
} catch (err) {
logger.error({ err }, 'Process cycle failed');
this.setState('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;
}
}

View file

@ -0,0 +1,207 @@
import { GameController } from '../game/GameController.js';
import { ScreenReader } from '../game/ScreenReader.js';
import { TradeMonitor } from '../trade/TradeMonitor.js';
import { InventoryManager } from '../inventory/InventoryManager.js';
import { sleep } from '../util/sleep.js';
import { logger } from '../util/logger.js';
import type { Config, TradeInfo, TradeState, Region } from '../types.js';
import type { Page } from 'playwright';
// Default screen regions for 1920x1080 - these need calibration
const DEFAULT_REGIONS = {
stashArea: { x: 20, y: 140, width: 630, height: 750 },
priceWarningDialog: { x: 600, y: 350, width: 700, height: 300 },
priceWarningNoButton: { x: 820, y: 560, width: 120, height: 40 },
inventoryArea: { x: 1260, y: 580, width: 630, height: 280 },
stashTabArea: { x: 20, y: 100, width: 630, height: 40 },
};
export class TradeExecutor {
private state: TradeState = 'IDLE';
private gameController: GameController;
private screenReader: ScreenReader;
private tradeMonitor: TradeMonitor;
private inventoryManager: InventoryManager;
private config: Config;
private _onStateChange?: (state: string) => void;
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;
}
set onStateChange(cb: (state: string) => void) {
this._onStateChange = cb;
}
getState(): TradeState {
return this.state;
}
private setState(s: TradeState): void {
this.state = s;
this._onStateChange?.(s);
}
async executeTrade(trade: TradeInfo): Promise<boolean> {
const page = trade.page as Page;
try {
// Step 1: Click "Travel to Hideout" on the trade website
this.setState('TRAVELING');
logger.info({ searchId: trade.searchId }, 'Clicking Travel to Hideout...');
// Register listener BEFORE clicking, then click inside the callback
const arrived = await this.inventoryManager.waitForAreaTransition(
this.config.travelTimeoutMs,
async () => {
const travelClicked = await this.tradeMonitor.clickTravelToHideout(
page,
trade.itemIds[0],
);
if (!travelClicked) {
throw new Error('Failed to click Travel to Hideout');
}
},
);
if (!arrived) {
logger.error('Timed out waiting for hideout arrival');
this.setState('FAILED');
return false;
}
this.setState('IN_SELLERS_HIDEOUT');
this.inventoryManager.setLocation(false);
logger.info('Arrived at seller hideout');
// Step 3: Focus game window and click on Ange then Stash
await this.gameController.focusGame();
await sleep(1500); // Wait for hideout to render
// Click on Ange NPC to interact
const angePos = await this.inventoryManager.findAndClickNameplate('Ange');
if (!angePos) {
logger.warn('Could not find Ange nameplate, trying Stash directly');
} else {
await sleep(1000); // Wait for NPC interaction
}
// Click on Stash to open it
const stashPos = await this.inventoryManager.findAndClickNameplate('Stash');
if (!stashPos) {
logger.error('Could not find Stash nameplate in seller hideout');
this.setState('FAILED');
return false;
}
await sleep(1000); // Wait for stash to open
// Step 4: Scan stash and buy items
this.setState('SCANNING_STASH');
logger.info('Scanning stash for items...');
await this.scanAndBuyItems();
// Step 5: Wait for more items
this.setState('WAITING_FOR_MORE');
logger.info(
{ waitMs: this.config.waitForMoreItemsMs },
'Waiting for seller to add more items...',
);
await sleep(this.config.waitForMoreItemsMs);
// Do one more scan after waiting
await this.scanAndBuyItems();
// Step 6: Go back to own hideout
this.setState('GOING_HOME');
logger.info('Traveling to own hideout...');
await this.gameController.focusGame();
await sleep(300);
const home = await this.inventoryManager.waitForAreaTransition(
this.config.travelTimeoutMs,
() => this.gameController.goToHideout(),
);
if (!home) {
logger.warn('Timed out going home, continuing anyway...');
}
this.inventoryManager.setLocation(true);
// Step 7: Store items in stash
this.setState('IN_HIDEOUT');
await sleep(1000);
await this.storeItems();
this.setState('IDLE');
return true;
} catch (err) {
logger.error({ err }, 'Trade execution failed');
this.setState('FAILED');
// Try to recover by going home
try {
await this.gameController.focusGame();
await this.gameController.pressEscape(); // Close any open dialogs
await sleep(500);
await this.gameController.goToHideout();
} catch {
// Best-effort recovery
}
this.setState('IDLE');
return false;
}
}
private async scanAndBuyItems(): Promise<void> {
// Take a screenshot of the stash area
const stashText = await this.screenReader.readRegionText(DEFAULT_REGIONS.stashArea);
logger.info({ stashText: stashText.substring(0, 200) }, 'Stash OCR result');
// For now, we'll use a simple grid-based approach to click items
// The exact positions depend on the stash layout and resolution
// This needs calibration with real game screenshots
//
// TODO: Implement item matching logic based on OCR text
// For now, we'll Ctrl+right-click at known grid positions
this.setState('BUYING');
// Check for price warning dialog after each buy
await this.checkPriceWarning();
}
private async checkPriceWarning(): Promise<void> {
// Check if a price warning dialog appeared
const hasWarning = await this.screenReader.checkForText(
DEFAULT_REGIONS.priceWarningDialog,
'price',
);
if (hasWarning) {
logger.warn('Price mismatch warning detected! Clicking No.');
// Click the "No" button
await this.gameController.leftClickAt(
DEFAULT_REGIONS.priceWarningNoButton.x + DEFAULT_REGIONS.priceWarningNoButton.width / 2,
DEFAULT_REGIONS.priceWarningNoButton.y + DEFAULT_REGIONS.priceWarningNoButton.height / 2,
);
await sleep(500);
}
}
private async storeItems(): Promise<void> {
logger.info('Storing purchased items...');
await this.inventoryManager.processInventory();
logger.info('Item storage complete');
}
}

View file

@ -0,0 +1,69 @@
import { logger } from '../util/logger.js';
import { sleep, randomDelay } from '../util/sleep.js';
import type { TradeExecutor } from './TradeExecutor.js';
import type { TradeInfo, Config } from '../types.js';
export class TradeQueue {
private queue: TradeInfo[] = [];
private processing = false;
constructor(
private executor: TradeExecutor,
private config: Config,
) {}
enqueue(trade: TradeInfo): void {
// De-duplicate: skip if same item ID already queued
const existingIds = new Set(this.queue.flatMap((t) => t.itemIds));
const newIds = trade.itemIds.filter((id) => !existingIds.has(id));
if (newIds.length === 0) {
logger.info({ itemIds: trade.itemIds }, 'Skipping duplicate trade');
return;
}
const dedupedTrade = { ...trade, itemIds: newIds };
this.queue.push(dedupedTrade);
logger.info(
{ itemIds: newIds, queueLength: this.queue.length },
'Trade enqueued',
);
this.processNext();
}
get length(): number {
return this.queue.length;
}
get isProcessing(): boolean {
return this.processing;
}
private async processNext(): Promise<void> {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
const trade = this.queue.shift()!;
try {
logger.info(
{ searchId: trade.searchId, itemIds: trade.itemIds },
'Processing trade',
);
const success = await this.executor.executeTrade(trade);
if (success) {
logger.info({ itemIds: trade.itemIds }, 'Trade completed successfully');
} else {
logger.warn({ itemIds: trade.itemIds }, 'Trade failed');
}
} catch (err) {
logger.error({ err, itemIds: trade.itemIds }, 'Trade execution error');
}
this.processing = false;
// Delay between trades
await randomDelay(this.config.betweenTradesDelayMs, this.config.betweenTradesDelayMs + 3000);
this.processNext();
}
}