From 3d7a8aafdf6e38f2e7ad44c65e2f04a0a2d4051a Mon Sep 17 00:00:00 2001 From: Boki Date: Thu, 12 Feb 2026 12:00:29 -0500 Subject: [PATCH] inventory types --- src/dashboard/BotController.ts | 22 ++- src/dashboard/ConfigStore.ts | 37 ++-- src/dashboard/DashboardServer.ts | 12 ++ src/executor/ScrapExecutor.ts | 267 ++++--------------------- src/executor/TradeExecutor.ts | 112 +++-------- src/index.ts | 79 +++++--- src/inventory/InventoryManager.ts | 316 ++++++++++++++++++++++++++++++ src/inventory/InventoryTracker.ts | 54 ++++- src/types.ts | 2 + 9 files changed, 532 insertions(+), 369 deletions(-) create mode 100644 src/inventory/InventoryManager.ts diff --git a/src/dashboard/BotController.ts b/src/dashboard/BotController.ts index 1728c62..ddf4dd9 100644 --- a/src/dashboard/BotController.ts +++ b/src/dashboard/BotController.ts @@ -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; diff --git a/src/dashboard/ConfigStore.ts b/src/dashboard/ConfigStore.ts index 1e6cc00..e99c01f 100644 --- a/src/dashboard/ConfigStore.ts +++ b/src/dashboard/ConfigStore.ts @@ -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; const merged = { ...DEFAULTS, ...parsed }; - // Migrate old links: add name/active fields, strip /live from URLs - merged.links = merged.links.map((l: any) => ({ - url: l.url.replace(/\/live\/?$/, ''), - name: l.name || '', - active: l.active !== undefined ? l.active : true, - mode: l.mode || 'live', - addedAt: l.addedAt || new Date().toISOString(), - })); + // 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, + 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; } diff --git a/src/dashboard/DashboardServer.ts b/src/dashboard/DashboardServer.ts index 2bf573c..7a6d728 100644 --- a/src/dashboard/DashboardServer.ts +++ b/src/dashboard/DashboardServer.ts @@ -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; diff --git a/src/executor/ScrapExecutor.ts b/src/executor/ScrapExecutor.ts index c1aebc6..f7cb354 100644 --- a/src/executor/ScrapExecutor.ts +++ b/src/executor/ScrapExecutor.ts @@ -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 { this.stopped = true; @@ -63,12 +49,13 @@ export class ScrapExecutor { } /** Main entry point — runs the full scrap loop. */ - async runScrapLoop(tradeUrl: string): Promise { + async runScrapLoop(tradeUrl: string, postAction: PostAction = 'salvage'): Promise { 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 { - 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 { 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 { + /** Process inventory: salvage/stash cycle via InventoryManager. */ + private async processItems(): Promise { 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, - ): Promise { - 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; - } } diff --git a/src/executor/TradeExecutor.ts b/src/executor/TradeExecutor.ts index 52ac8fd..4311416 100644 --- a/src/executor/TradeExecutor.ts +++ b/src/executor/TradeExecutor.ts @@ -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...'); - const travelClicked = await this.tradeMonitor.clickTravelToHideout( - page, - trade.itemIds[0], + // 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 (!travelClicked) { - logger.error('Failed to click Travel to Hideout'); - this.state = 'FAILED'; - return false; - } - - // 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 { - 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 { - 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'); } } diff --git a/src/index.ts b/src/index.ts index 44c7760..fe18b49 100644 --- a/src/index.ts +++ b/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(); - // 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(); diff --git a/src/inventory/InventoryManager.ts b/src/inventory/InventoryManager.ts new file mode 100644 index 0000000..88ef744 --- /dev/null +++ b/src/inventory/InventoryManager.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + ): Promise { + 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, + }; + } +} diff --git a/src/inventory/InventoryTracker.ts b/src/inventory/InventoryTracker.ts index 45303f7..87a3bb8 100644 --- a/src/inventory/InventoryTracker.ts +++ b/src/inventory/InventoryTracker.ts @@ -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 }); } } diff --git a/src/types.ts b/src/types.ts index 137335b..634c75c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 {