inventory types
This commit is contained in:
parent
cf5d944fd1
commit
3d7a8aafdf
9 changed files with 532 additions and 369 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { logger } from '../util/logger.js';
|
||||
import type { LinkMode } from '../types.js';
|
||||
import type { LinkMode, PostAction } from '../types.js';
|
||||
import type { ConfigStore, SavedLink } from './ConfigStore.js';
|
||||
|
||||
export interface TradeLink {
|
||||
|
|
@ -10,6 +10,7 @@ export interface TradeLink {
|
|||
label: string;
|
||||
active: boolean;
|
||||
mode: LinkMode;
|
||||
postAction: PostAction;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -77,24 +78,26 @@ export class BotController extends EventEmitter {
|
|||
this.emit('resumed');
|
||||
}
|
||||
|
||||
addLink(url: string, name: string = '', mode?: LinkMode): TradeLink {
|
||||
addLink(url: string, name: string = '', mode?: LinkMode, postAction?: PostAction): TradeLink {
|
||||
url = this.stripLive(url);
|
||||
const id = this.extractId(url);
|
||||
const label = this.extractLabel(url);
|
||||
// Check if we have saved state for this link
|
||||
const savedLink = this.store.links.find((l) => l.url === url);
|
||||
const resolvedMode = mode || savedLink?.mode || 'live';
|
||||
const link: TradeLink = {
|
||||
id,
|
||||
url,
|
||||
name: name || savedLink?.name || '',
|
||||
label,
|
||||
active: savedLink?.active !== undefined ? savedLink.active : true,
|
||||
mode: mode || savedLink?.mode || 'live',
|
||||
mode: resolvedMode,
|
||||
postAction: postAction || savedLink?.postAction || (resolvedMode === 'scrap' ? 'salvage' : 'stash'),
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
this.links.set(id, link);
|
||||
this.store.addLink(url, link.name, link.mode);
|
||||
logger.info({ id, url, name: link.name, active: link.active, mode: link.mode }, 'Trade link added');
|
||||
this.store.addLink(url, link.name, link.mode, link.postAction);
|
||||
logger.info({ id, url, name: link.name, active: link.active, mode: link.mode, postAction: link.postAction }, 'Trade link added');
|
||||
this.emit('link-added', link);
|
||||
return link;
|
||||
}
|
||||
|
|
@ -136,6 +139,15 @@ export class BotController extends EventEmitter {
|
|||
this.emit('link-mode-changed', { id, mode, link });
|
||||
}
|
||||
|
||||
updateLinkPostAction(id: string, postAction: PostAction): void {
|
||||
const link = this.links.get(id);
|
||||
if (!link) return;
|
||||
link.postAction = postAction;
|
||||
this.store.updateLinkById(id, { postAction });
|
||||
logger.info({ id, postAction }, 'Trade link postAction updated');
|
||||
this.emit('link-postaction-changed', { id, postAction, link });
|
||||
}
|
||||
|
||||
isLinkActive(searchId: string): boolean {
|
||||
const link = this.links.get(searchId);
|
||||
return link ? link.active : false;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from '../util/logger.js';
|
||||
import type { LinkMode } from '../types.js';
|
||||
import type { LinkMode, PostAction } from '../types.js';
|
||||
|
||||
export interface SavedLink {
|
||||
url: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
mode: LinkMode;
|
||||
postAction?: PostAction;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -56,14 +57,18 @@ export class ConfigStore {
|
|||
const raw = readFileSync(this.filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Partial<SavedSettings>;
|
||||
const merged = { ...DEFAULTS, ...parsed };
|
||||
// Migrate old links: add name/active fields, strip /live from URLs
|
||||
merged.links = merged.links.map((l: any) => ({
|
||||
// Migrate old links: add name/active fields, strip /live from URLs, default postAction
|
||||
merged.links = merged.links.map((l: any) => {
|
||||
const mode: LinkMode = l.mode || 'live';
|
||||
return {
|
||||
url: l.url.replace(/\/live\/?$/, ''),
|
||||
name: l.name || '',
|
||||
active: l.active !== undefined ? l.active : true,
|
||||
mode: l.mode || 'live',
|
||||
mode,
|
||||
postAction: l.postAction || (mode === 'scrap' ? 'salvage' : 'stash'),
|
||||
addedAt: l.addedAt || new Date().toISOString(),
|
||||
}));
|
||||
};
|
||||
});
|
||||
logger.info({ path: this.filePath, linkCount: merged.links.length }, 'Loaded config.json');
|
||||
return merged;
|
||||
} catch (err) {
|
||||
|
|
@ -88,10 +93,17 @@ export class ConfigStore {
|
|||
return this.data.links;
|
||||
}
|
||||
|
||||
addLink(url: string, name: string = '', mode: LinkMode = 'live'): void {
|
||||
addLink(url: string, name: string = '', mode: LinkMode = 'live', postAction?: PostAction): void {
|
||||
url = url.replace(/\/live\/?$/, '');
|
||||
if (this.data.links.some((l) => l.url === url)) return;
|
||||
this.data.links.push({ url, name, active: true, mode, addedAt: new Date().toISOString() });
|
||||
this.data.links.push({
|
||||
url,
|
||||
name,
|
||||
active: true,
|
||||
mode,
|
||||
postAction: postAction || (mode === 'scrap' ? 'salvage' : 'stash'),
|
||||
addedAt: new Date().toISOString(),
|
||||
});
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +120,7 @@ export class ConfigStore {
|
|||
this.save();
|
||||
}
|
||||
|
||||
updateLinkById(id: string, updates: { name?: string; active?: boolean; mode?: LinkMode }): SavedLink | null {
|
||||
updateLinkById(id: string, updates: { name?: string; active?: boolean; mode?: LinkMode; postAction?: PostAction }): SavedLink | null {
|
||||
const link = this.data.links.find((l) => {
|
||||
const parts = l.url.split('/');
|
||||
return parts[parts.length - 1] === id;
|
||||
|
|
@ -117,6 +129,7 @@ export class ConfigStore {
|
|||
if (updates.name !== undefined) link.name = updates.name;
|
||||
if (updates.active !== undefined) link.active = updates.active;
|
||||
if (updates.mode !== undefined) link.mode = updates.mode;
|
||||
if (updates.postAction !== undefined) link.postAction = updates.postAction;
|
||||
this.save();
|
||||
return link;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,18 @@ export class DashboardServer {
|
|||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Change link post-action
|
||||
this.app.post('/api/links/:id/post-action', (req, res) => {
|
||||
const { postAction } = req.body as { postAction: string };
|
||||
if (postAction !== 'stash' && postAction !== 'salvage') {
|
||||
res.status(400).json({ error: 'Invalid postAction. Must be "stash" or "salvage".' });
|
||||
return;
|
||||
}
|
||||
this.bot.updateLinkPostAction(req.params.id, postAction);
|
||||
this.broadcastStatus();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Settings
|
||||
this.app.post('/api/settings', (req, res) => {
|
||||
const updates = req.body as Record<string, unknown>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { GameController } from '../game/GameController.js';
|
||||
import { ScreenReader } from '../game/ScreenReader.js';
|
||||
import { ClientLogWatcher } from '../log/ClientLogWatcher.js';
|
||||
import { TradeMonitor } from '../trade/TradeMonitor.js';
|
||||
import { sleep, randomDelay } from '../util/sleep.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';
|
||||
|
|
@ -20,21 +20,21 @@ export class TradeExecutor {
|
|||
private state: TradeState = 'IDLE';
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -50,19 +50,19 @@ export class TradeExecutor {
|
|||
this.state = '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) {
|
||||
logger.error('Failed to click Travel to Hideout');
|
||||
this.state = 'FAILED';
|
||||
return false;
|
||||
throw new Error('Failed to click Travel to Hideout');
|
||||
}
|
||||
|
||||
// Step 2: Wait for area transition (arrival at seller's hideout)
|
||||
logger.info('Waiting for area transition...');
|
||||
const arrived = await this.waitForAreaTransition(this.config.travelTimeoutMs);
|
||||
},
|
||||
);
|
||||
if (!arrived) {
|
||||
logger.error('Timed out waiting for hideout arrival');
|
||||
this.state = 'FAILED';
|
||||
|
|
@ -70,6 +70,7 @@ export class TradeExecutor {
|
|||
}
|
||||
|
||||
this.state = 'IN_SELLERS_HIDEOUT';
|
||||
this.inventoryManager.setLocation(false);
|
||||
logger.info('Arrived at seller hideout');
|
||||
|
||||
// Step 3: Focus game window and click on Ange then Stash
|
||||
|
|
@ -77,7 +78,7 @@ export class TradeExecutor {
|
|||
await sleep(1500); // Wait for hideout to render
|
||||
|
||||
// Click on Ange NPC to interact
|
||||
const angePos = await this.findAndClickNameplate('Ange');
|
||||
const angePos = await this.inventoryManager.findAndClickNameplate('Ange');
|
||||
if (!angePos) {
|
||||
logger.warn('Could not find Ange nameplate, trying Stash directly');
|
||||
} else {
|
||||
|
|
@ -85,7 +86,7 @@ export class TradeExecutor {
|
|||
}
|
||||
|
||||
// Click on Stash to open it
|
||||
const stashPos = await this.findAndClickNameplate('Stash');
|
||||
const stashPos = await this.inventoryManager.findAndClickNameplate('Stash');
|
||||
if (!stashPos) {
|
||||
logger.error('Could not find Stash nameplate in seller hideout');
|
||||
this.state = 'FAILED';
|
||||
|
|
@ -115,13 +116,17 @@ export class TradeExecutor {
|
|||
logger.info('Traveling to own hideout...');
|
||||
await this.gameController.focusGame();
|
||||
await sleep(300);
|
||||
await this.gameController.goToHideout();
|
||||
|
||||
const home = await this.waitForAreaTransition(this.config.travelTimeoutMs);
|
||||
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.state = 'IN_HIDEOUT';
|
||||
await sleep(1000);
|
||||
|
|
@ -185,67 +190,8 @@ export class TradeExecutor {
|
|||
}
|
||||
|
||||
private async storeItems(): Promise<void> {
|
||||
logger.info('Storing purchased items in stash...');
|
||||
|
||||
// Focus game and find Stash in own hideout
|
||||
await this.gameController.focusGame();
|
||||
await sleep(500);
|
||||
|
||||
const stashPos = await this.findAndClickNameplate('Stash');
|
||||
if (!stashPos) {
|
||||
logger.error('Could not find Stash nameplate in own hideout');
|
||||
return;
|
||||
}
|
||||
await sleep(1000); // Wait for stash to open
|
||||
|
||||
// Open inventory
|
||||
await this.gameController.openInventory();
|
||||
await sleep(500);
|
||||
|
||||
// TODO: Implement inventory scanning to find purchased items
|
||||
// and Ctrl+right-click each to transfer to stash
|
||||
|
||||
logger.info('Item storage complete (needs calibration)');
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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) {
|
||||
logger.debug({ name, attempt }, 'Nameplate not found, retrying...');
|
||||
await sleep(retryDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn({ name, maxRetries }, 'Nameplate not found after all retries');
|
||||
return null;
|
||||
}
|
||||
|
||||
private waitForAreaTransition(timeoutMs: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.logWatcher.removeListener('area-entered', handler);
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
const handler = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
this.logWatcher.once('area-entered', handler);
|
||||
});
|
||||
logger.info('Storing purchased items...');
|
||||
await this.inventoryManager.processInventory();
|
||||
logger.info('Item storage complete');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
79
src/index.ts
79
src/index.ts
|
|
@ -7,6 +7,7 @@ import { ClientLogWatcher } from './log/ClientLogWatcher.js';
|
|||
import { TradeExecutor } from './executor/TradeExecutor.js';
|
||||
import { ScrapExecutor } from './executor/ScrapExecutor.js';
|
||||
import { TradeQueue } from './executor/TradeQueue.js';
|
||||
import { InventoryManager } from './inventory/InventoryManager.js';
|
||||
import { BotController } from './dashboard/BotController.js';
|
||||
import { DashboardServer } from './dashboard/DashboardServer.js';
|
||||
import { ConfigStore } from './dashboard/ConfigStore.js';
|
||||
|
|
@ -55,47 +56,69 @@ program
|
|||
// Initialize bot controller with config store
|
||||
const bot = new BotController(store);
|
||||
|
||||
// Start dashboard
|
||||
// 1. Start dashboard
|
||||
const dashboard = new DashboardServer(bot, port);
|
||||
await dashboard.start();
|
||||
|
||||
// Initialize game components
|
||||
// 2. Create game components
|
||||
const screenReader = new ScreenReader();
|
||||
|
||||
const gameController = new GameController(config);
|
||||
dashboard.setDebugDeps({ screenReader, gameController });
|
||||
|
||||
// Go to hideout on startup
|
||||
dashboard.broadcastLog('info', 'Sending /hideout command...');
|
||||
await gameController.focusGame();
|
||||
await gameController.goToHideout();
|
||||
bot.state = 'IN_HIDEOUT';
|
||||
dashboard.broadcastStatus();
|
||||
dashboard.broadcastLog('info', 'In hideout, ready to trade');
|
||||
|
||||
// 3. Start logWatcher BEFORE /hideout so we can wait for area transition
|
||||
const logWatcher = new ClientLogWatcher(config.poe2LogPath);
|
||||
await logWatcher.start();
|
||||
logWatcher.currentArea = 'Hideout'; // We just sent /hideout on startup
|
||||
dashboard.broadcastLog('info', 'Watching Client.txt for game events');
|
||||
|
||||
// 4. Start tradeMonitor
|
||||
const tradeMonitor = new TradeMonitor(config);
|
||||
await tradeMonitor.start(`http://localhost:${port}`);
|
||||
dashboard.broadcastLog('info', 'Browser launched');
|
||||
|
||||
// 5. Create InventoryManager
|
||||
const inventoryManager = new InventoryManager(gameController, screenReader, logWatcher, config);
|
||||
|
||||
// 6. /hideout + waitForAreaTransition
|
||||
dashboard.broadcastLog('info', 'Sending /hideout command...');
|
||||
await gameController.focusGame();
|
||||
const arrivedHome = await inventoryManager.waitForAreaTransition(
|
||||
config.travelTimeoutMs,
|
||||
() => gameController.goToHideout(),
|
||||
);
|
||||
if (arrivedHome) {
|
||||
inventoryManager.setLocation(true);
|
||||
logWatcher.currentArea = 'Hideout';
|
||||
} else {
|
||||
// Assume we're already in hideout if timeout (e.g. already there)
|
||||
inventoryManager.setLocation(true);
|
||||
logWatcher.currentArea = 'Hideout';
|
||||
logger.warn('Timed out waiting for hideout transition on startup (may already be in hideout)');
|
||||
}
|
||||
bot.state = 'IN_HIDEOUT';
|
||||
dashboard.broadcastStatus();
|
||||
dashboard.broadcastLog('info', 'In hideout, ready to trade');
|
||||
|
||||
// 7. Clear leftover inventory items to stash
|
||||
dashboard.broadcastLog('info', 'Checking inventory for leftover items...');
|
||||
await inventoryManager.clearToStash();
|
||||
dashboard.broadcastLog('info', 'Inventory cleared');
|
||||
|
||||
// 8. Create executors with shared InventoryManager
|
||||
const executor = new TradeExecutor(
|
||||
gameController,
|
||||
screenReader,
|
||||
logWatcher,
|
||||
tradeMonitor,
|
||||
inventoryManager,
|
||||
config,
|
||||
);
|
||||
|
||||
// 9. Create tradeQueue
|
||||
const tradeQueue = new TradeQueue(executor, config);
|
||||
|
||||
// Track running scrap executors per link ID
|
||||
const scrapExecutors = new Map<string, ScrapExecutor>();
|
||||
|
||||
// Activate a link based on its mode
|
||||
// 10. Activate a link based on its mode
|
||||
const activateLink = async (link: TradeLink) => {
|
||||
try {
|
||||
if (link.mode === 'scrap') {
|
||||
|
|
@ -103,15 +126,15 @@ program
|
|||
const scrapExec = new ScrapExecutor(
|
||||
gameController,
|
||||
screenReader,
|
||||
logWatcher,
|
||||
tradeMonitor,
|
||||
inventoryManager,
|
||||
config,
|
||||
);
|
||||
scrapExecutors.set(link.id, scrapExec);
|
||||
dashboard.broadcastLog('info', `Scrap loop started: ${link.name || link.label}`);
|
||||
dashboard.broadcastStatus();
|
||||
// Run in background (don't await — it's an infinite loop)
|
||||
scrapExec.runScrapLoop(link.url).catch((err) => {
|
||||
scrapExec.runScrapLoop(link.url, link.postAction).catch((err) => {
|
||||
logger.error({ err, linkId: link.id }, 'Scrap loop error');
|
||||
dashboard.broadcastLog('error', `Scrap loop failed: ${link.name || link.label}`);
|
||||
scrapExecutors.delete(link.id);
|
||||
|
|
@ -140,7 +163,7 @@ program
|
|||
await tradeMonitor.pauseSearch(id);
|
||||
};
|
||||
|
||||
// Load all saved + CLI links (only activate ones marked active)
|
||||
// 11. Load all saved + CLI links (only activate ones marked active)
|
||||
for (const url of allUrls) {
|
||||
const link = bot.addLink(url);
|
||||
if (link.active) {
|
||||
|
|
@ -186,6 +209,15 @@ program
|
|||
}
|
||||
});
|
||||
|
||||
// When link postAction changes, restart executor if active
|
||||
bot.on('link-postaction-changed', async (data: { id: string; postAction: string; link: TradeLink }) => {
|
||||
if (data.link.active) {
|
||||
await deactivateLink(data.id);
|
||||
await activateLink(data.link);
|
||||
dashboard.broadcastLog('info', `Post-action changed to ${data.postAction}: ${data.link.name || data.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Wire up events: when new listings appear, queue them for trading
|
||||
tradeMonitor.on('new-listings', (data: { searchId: string; itemIds: string[]; page: Page }) => {
|
||||
if (bot.isPaused) {
|
||||
|
|
@ -216,17 +248,8 @@ program
|
|||
|
||||
// Forward executor state changes to dashboard
|
||||
const stateInterval = setInterval(() => {
|
||||
// Feed inventory state from active scrap executors
|
||||
let inventorySet = false;
|
||||
for (const [, scrapExec] of scrapExecutors) {
|
||||
const inv = scrapExec.getInventoryState();
|
||||
if (inv) {
|
||||
bot.setInventory(inv);
|
||||
inventorySet = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inventorySet) bot.setInventory(undefined);
|
||||
// Feed inventory state from shared InventoryManager
|
||||
bot.setInventory(inventoryManager.getInventoryState());
|
||||
|
||||
// Check live trade executor state
|
||||
const execState = executor.getState();
|
||||
|
|
|
|||
316
src/inventory/InventoryManager.ts
Normal file
316
src/inventory/InventoryManager.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import { logger } from '../util/logger.js';
|
||||
import type { PostAction } from '../types.js';
|
||||
|
||||
const ROWS = 5;
|
||||
const COLS = 12;
|
||||
|
||||
interface PlacedItem {
|
||||
export interface PlacedItem {
|
||||
row: number;
|
||||
col: number;
|
||||
w: number;
|
||||
h: number;
|
||||
postAction: PostAction;
|
||||
}
|
||||
|
||||
export class InventoryTracker {
|
||||
|
|
@ -19,7 +21,11 @@ export class InventoryTracker {
|
|||
}
|
||||
|
||||
/** Initialize from a grid scan result (occupied cells + detected items). */
|
||||
initFromScan(cells: boolean[][], items: { row: number; col: number; w: number; h: number }[]): void {
|
||||
initFromScan(
|
||||
cells: boolean[][],
|
||||
items: { row: number; col: number; w: number; h: number }[],
|
||||
defaultAction: PostAction = 'stash',
|
||||
): void {
|
||||
// Reset
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
this.grid[r].fill(false);
|
||||
|
|
@ -35,19 +41,19 @@ export class InventoryTracker {
|
|||
|
||||
// Record detected items
|
||||
for (const item of items) {
|
||||
this.items.push({ row: item.row, col: item.col, w: item.w, h: item.h });
|
||||
this.items.push({ row: item.row, col: item.col, w: item.w, h: item.h, postAction: defaultAction });
|
||||
}
|
||||
|
||||
logger.info({ occupied: ROWS * COLS - this.freeCells, items: this.items.length, free: this.freeCells }, 'Inventory initialized from scan');
|
||||
}
|
||||
|
||||
/** Try to place an item of size w×h. Column-first to match game's left-priority placement. */
|
||||
tryPlace(w: number, h: number): { row: number; col: number } | null {
|
||||
tryPlace(w: number, h: number, postAction: PostAction = 'stash'): { row: number; col: number } | null {
|
||||
for (let col = 0; col <= COLS - w; col++) {
|
||||
for (let row = 0; row <= ROWS - h; row++) {
|
||||
if (this.fits(row, col, w, h)) {
|
||||
this.place(row, col, w, h);
|
||||
logger.info({ row, col, w, h, free: this.freeCells }, 'Item placed in inventory');
|
||||
this.place(row, col, w, h, postAction);
|
||||
logger.info({ row, col, w, h, postAction, free: this.freeCells }, 'Item placed in inventory');
|
||||
return { row, col };
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +76,38 @@ export class InventoryTracker {
|
|||
return [...this.items];
|
||||
}
|
||||
|
||||
/** Get items with a specific postAction. */
|
||||
getItemsByAction(action: PostAction): PlacedItem[] {
|
||||
return this.items.filter(i => i.postAction === action);
|
||||
}
|
||||
|
||||
/** Check if any items have a specific postAction. */
|
||||
hasItemsWithAction(action: PostAction): boolean {
|
||||
return this.items.some(i => i.postAction === action);
|
||||
}
|
||||
|
||||
/** Remove a specific item from tracking and unmark its grid cells. */
|
||||
removeItem(item: PlacedItem): void {
|
||||
const idx = this.items.indexOf(item);
|
||||
if (idx === -1) return;
|
||||
// Unmark grid cells
|
||||
for (let r = item.row; r < item.row + item.h; r++) {
|
||||
for (let c = item.col; c < item.col + item.w; c++) {
|
||||
this.grid[r][c] = false;
|
||||
}
|
||||
}
|
||||
this.items.splice(idx, 1);
|
||||
}
|
||||
|
||||
/** Remove all items with a specific postAction. */
|
||||
removeItemsByAction(action: PostAction): void {
|
||||
const toRemove = this.items.filter(i => i.postAction === action);
|
||||
for (const item of toRemove) {
|
||||
this.removeItem(item);
|
||||
}
|
||||
logger.info({ action, removed: toRemove.length, remaining: this.items.length }, 'Removed items by action');
|
||||
}
|
||||
|
||||
/** Get a copy of the occupancy grid. */
|
||||
getGrid(): boolean[][] {
|
||||
return this.grid.map(row => [...row]);
|
||||
|
|
@ -104,12 +142,12 @@ export class InventoryTracker {
|
|||
return true;
|
||||
}
|
||||
|
||||
private place(row: number, col: number, w: number, h: number): void {
|
||||
private place(row: number, col: number, w: number, h: number, postAction: PostAction): void {
|
||||
for (let r = row; r < row + h; r++) {
|
||||
for (let c = col; c < col + w; c++) {
|
||||
this.grid[r][c] = true;
|
||||
}
|
||||
}
|
||||
this.items.push({ row, col, w, h });
|
||||
this.items.push({ row, col, w, h, postAction });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ export interface LogEvent {
|
|||
|
||||
export type LinkMode = 'live' | 'scrap';
|
||||
|
||||
export type PostAction = 'stash' | 'salvage';
|
||||
|
||||
export type ScrapState = 'IDLE' | 'TRAVELING' | 'BUYING' | 'SALVAGING' | 'STORING' | 'FAILED';
|
||||
|
||||
export interface TradeItem {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue