From 696fd07e8607f5662ed1cbc096b61979224b88d0 Mon Sep 17 00:00:00 2001 From: Boki Date: Fri, 13 Feb 2026 01:27:20 -0500 Subject: [PATCH] deleted old --- Poe2Trade.sln | 2 +- src-old/bot/Bot.ts | 406 -------- src-old/bot/ConfigStore.ts | 146 --- src-old/bot/LinkManager.ts | 128 --- src-old/config.ts | 38 - src-old/executor/ScrapExecutor.ts | 244 ----- src-old/executor/TradeExecutor.ts | 207 ---- src-old/executor/TradeQueue.ts | 69 -- src-old/game/GameController.ts | 139 --- src-old/game/GridReader.ts | 161 --- src-old/game/InputSender.ts | 342 ------- src-old/game/OcrDaemon.ts | 464 --------- src-old/game/ScreenReader.ts | 297 ------ src-old/game/WindowManager.ts | 90 -- src-old/index.ts | 55 - src-old/inventory/InventoryManager.ts | 316 ------ src-old/inventory/InventoryTracker.ts | 157 --- src-old/log/ClientLogWatcher.ts | 182 ---- src-old/server/Server.ts | 97 -- src-old/server/index.html | 1341 ------------------------- src-old/server/routes/control.ts | 23 - src-old/server/routes/debug.ts | 283 ------ src-old/server/routes/links.ts | 56 -- src-old/server/routes/status.ts | 12 - src-old/trade/TradeMonitor.ts | 290 ------ src-old/trade/selectors.ts | 30 - src-old/types.ts | 73 -- src-old/util/clipboard.ts | 13 - src-old/util/logger.ts | 12 - src-old/util/retry.ts | 24 - src-old/util/sleep.ts | 8 - tools/test-easyocr.js | 104 -- tools/test-ocr.ts | 484 --------- 33 files changed, 1 insertion(+), 6292 deletions(-) delete mode 100644 src-old/bot/Bot.ts delete mode 100644 src-old/bot/ConfigStore.ts delete mode 100644 src-old/bot/LinkManager.ts delete mode 100644 src-old/config.ts delete mode 100644 src-old/executor/ScrapExecutor.ts delete mode 100644 src-old/executor/TradeExecutor.ts delete mode 100644 src-old/executor/TradeQueue.ts delete mode 100644 src-old/game/GameController.ts delete mode 100644 src-old/game/GridReader.ts delete mode 100644 src-old/game/InputSender.ts delete mode 100644 src-old/game/OcrDaemon.ts delete mode 100644 src-old/game/ScreenReader.ts delete mode 100644 src-old/game/WindowManager.ts delete mode 100644 src-old/index.ts delete mode 100644 src-old/inventory/InventoryManager.ts delete mode 100644 src-old/inventory/InventoryTracker.ts delete mode 100644 src-old/log/ClientLogWatcher.ts delete mode 100644 src-old/server/Server.ts delete mode 100644 src-old/server/index.html delete mode 100644 src-old/server/routes/control.ts delete mode 100644 src-old/server/routes/debug.ts delete mode 100644 src-old/server/routes/links.ts delete mode 100644 src-old/server/routes/status.ts delete mode 100644 src-old/trade/TradeMonitor.ts delete mode 100644 src-old/trade/selectors.ts delete mode 100644 src-old/types.ts delete mode 100644 src-old/util/clipboard.ts delete mode 100644 src-old/util/logger.ts delete mode 100644 src-old/util/retry.ts delete mode 100644 src-old/util/sleep.ts delete mode 100644 tools/test-easyocr.js delete mode 100644 tools/test-ocr.ts diff --git a/Poe2Trade.sln b/Poe2Trade.sln index 7611b70..7ab1630 100644 --- a/Poe2Trade.sln +++ b/Poe2Trade.sln @@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "csharp", "csharp", "{67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Core", "src\Poe2Trade.Core\Poe2Trade.Core.csproj", "{6432F6A5-11A0-4960-AFFC-E810D4325C35}" EndProject diff --git a/src-old/bot/Bot.ts b/src-old/bot/Bot.ts deleted file mode 100644 index fb886bc..0000000 --- a/src-old/bot/Bot.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { EventEmitter } from 'events'; -import { logger } from '../util/logger.js'; -import { LinkManager } from './LinkManager.js'; -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 { InventoryManager } from '../inventory/InventoryManager.js'; -import { TradeExecutor } from '../executor/TradeExecutor.js'; -import { TradeQueue } from '../executor/TradeQueue.js'; -import { ScrapExecutor } from '../executor/ScrapExecutor.js'; -import type { TradeLink } from './LinkManager.js'; -import type { ConfigStore } from './ConfigStore.js'; -import type { Config, LinkMode, PostAction } from '../types.js'; -import type { Page } from 'playwright'; - -export interface BotStatus { - paused: boolean; - state: string; - links: TradeLink[]; - tradesCompleted: number; - tradesFailed: number; - uptime: number; - settings: { - poe2LogPath: string; - poe2WindowTitle: string; - travelTimeoutMs: number; - waitForMoreItemsMs: number; - betweenTradesDelayMs: number; - }; - inventory?: { - grid: boolean[][]; - items: { row: number; col: number; w: number; h: number }[]; - free: number; - }; -} - -export class Bot extends EventEmitter { - private paused: boolean; - private _state = 'IDLE'; - private tradesCompleted = 0; - private tradesFailed = 0; - private startTime = Date.now(); - private _inventory: BotStatus['inventory'] = undefined; - private _started = false; - - readonly links: LinkManager; - readonly store: ConfigStore; - readonly config: Config; - - gameController!: GameController; - screenReader!: ScreenReader; - logWatcher!: ClientLogWatcher; - tradeMonitor!: TradeMonitor; - inventoryManager!: InventoryManager; - tradeExecutor!: TradeExecutor; - tradeQueue!: TradeQueue; - scrapExecutors = new Map(); - - constructor(store: ConfigStore, config: Config) { - super(); - this.store = store; - this.config = config; - this.paused = store.settings.paused; - this.links = new LinkManager(store); - } - - get isReady(): boolean { - return this._started; - } - - get isPaused(): boolean { - return this.paused; - } - - get state(): string { - return this._state; - } - - set state(s: string) { - if (this._state !== s) { - this._state = s; - this.emit('status-update'); - } - } - - pause(): void { - this.paused = true; - this.store.setPaused(true); - logger.info('Bot paused'); - this.emit('status-update'); - } - - resume(): void { - this.paused = false; - this.store.setPaused(false); - logger.info('Bot resumed'); - this.emit('status-update'); - } - - // --- Link operations (delegate to LinkManager + emit events) --- - - addLink(url: string, name?: string, mode?: LinkMode, postAction?: PostAction): TradeLink { - const link = this.links.addLink(url, name, mode, postAction); - this.emit('link-added', link); - this.emit('status-update'); - return link; - } - - removeLink(id: string): void { - this.links.removeLink(id); - this.emit('link-removed', id); - this.emit('status-update'); - } - - toggleLink(id: string, active: boolean): void { - const link = this.links.toggleLink(id, active); - if (link) { - this.emit('link-toggled', { id, active, link }); - this.emit('status-update'); - } - } - - updateLinkName(id: string, name: string): void { - this.links.updateName(id, name); - this.emit('status-update'); - } - - updateLinkMode(id: string, mode: LinkMode): void { - const link = this.links.updateMode(id, mode); - if (link) { - this.emit('link-mode-changed', { id, mode, link }); - this.emit('status-update'); - } - } - - updateLinkPostAction(id: string, postAction: PostAction): void { - const link = this.links.updatePostAction(id, postAction); - if (link) { - this.emit('link-postaction-changed', { id, postAction, link }); - this.emit('status-update'); - } - } - - isLinkActive(searchId: string): boolean { - return this.links.isActive(searchId); - } - - updateSettings(updates: Record): void { - this.store.updateSettings(updates); - this.emit('status-update'); - } - - setInventory(inv: BotStatus['inventory']): void { - this._inventory = inv; - } - - getStatus(): BotStatus { - const s = this.store.settings; - return { - paused: this.paused, - state: this._state, - links: this.links.getLinks(), - tradesCompleted: this.tradesCompleted, - tradesFailed: this.tradesFailed, - uptime: Date.now() - this.startTime, - settings: { - poe2LogPath: s.poe2LogPath, - poe2WindowTitle: s.poe2WindowTitle, - travelTimeoutMs: s.travelTimeoutMs, - waitForMoreItemsMs: s.waitForMoreItemsMs, - betweenTradesDelayMs: s.betweenTradesDelayMs, - }, - inventory: this._inventory, - }; - } - - /** Called by executor state callbacks to update bot state */ - updateExecutorState(): void { - this._inventory = this.inventoryManager.getInventoryState(); - - const execState = this.tradeExecutor.getState(); - if (execState !== 'IDLE') { - this.state = execState; - return; - } - - for (const [, scrapExec] of this.scrapExecutors) { - const scrapState = scrapExec.getState(); - if (scrapState !== 'IDLE') { - this.state = scrapState; - return; - } - } - - this.state = 'IDLE'; - } - - // --- Startup / Shutdown --- - - async start(cliUrls: string[], port: number): Promise { - this.screenReader = new ScreenReader(); - this.gameController = new GameController(this.config); - - this.logWatcher = new ClientLogWatcher(this.config.poe2LogPath); - await this.logWatcher.start(); - this.emit('log', 'info', 'Watching Client.txt for game events'); - - this.tradeMonitor = new TradeMonitor(this.config); - await this.tradeMonitor.start(`http://localhost:${port}`); - this.emit('log', 'info', 'Browser launched'); - - this.inventoryManager = new InventoryManager( - this.gameController, this.screenReader, this.logWatcher, this.config, - ); - - // Pre-warm OCR daemon + EasyOCR model in background (don't await yet) - const ocrWarmup = this.screenReader.warmup().catch(err => { - logger.warn({ err }, 'OCR warmup failed (will retry on first use)'); - }); - - // Check if already in hideout from log tail - const alreadyInHideout = this.logWatcher.currentArea.toLowerCase().includes('hideout'); - - if (alreadyInHideout) { - logger.info({ area: this.logWatcher.currentArea }, 'Already in hideout, skipping /hideout command'); - this.inventoryManager.setLocation(true); - } else { - this.emit('log', 'info', 'Sending /hideout command...'); - await this.gameController.focusGame(); - const arrivedHome = await this.inventoryManager.waitForAreaTransition( - this.config.travelTimeoutMs, - () => this.gameController.goToHideout(), - ); - if (arrivedHome) { - this.inventoryManager.setLocation(true); - this.logWatcher.currentArea = 'Hideout'; - } else { - this.inventoryManager.setLocation(true); - this.logWatcher.currentArea = 'Hideout'; - logger.warn('Timed out waiting for hideout transition on startup (may already be in hideout)'); - } - } - this.state = 'IN_HIDEOUT'; - this.emit('log', 'info', 'In hideout, ready to trade'); - - // Ensure OCR warmup finished before proceeding to inventory scan - await ocrWarmup; - - // Clear leftover inventory - this.emit('log', 'info', 'Checking inventory for leftover items...'); - await this.inventoryManager.clearToStash(); - this.emit('log', 'info', 'Inventory cleared'); - - // Create executors - this.tradeExecutor = new TradeExecutor( - this.gameController, this.screenReader, this.tradeMonitor, - this.inventoryManager, this.config, - ); - this.tradeExecutor.onStateChange = () => this.updateExecutorState(); - - this.tradeQueue = new TradeQueue(this.tradeExecutor, this.config); - - // Collect all URLs: CLI args + saved links (deduped) - const allUrls = new Set([ - ...cliUrls, - ...this.store.settings.links.map(l => l.url), - ]); - - // Load links (direct, before wiring events) - for (const url of allUrls) { - const link = this.links.addLink(url); - if (link.active) { - await this.activateLink(link); - } else { - this.emit('log', 'info', `Loaded (inactive): ${link.name || link.label}`); - } - } - - // Wire events for subsequent UI-triggered changes - this.wireEvents(); - - this._started = true; - this.emit('log', 'info', `Loaded ${allUrls.size} trade link(s) from config`); - logger.info('Bot started'); - } - - async stop(): Promise { - logger.info('Shutting down bot...'); - for (const [, scrapExec] of this.scrapExecutors) { - await scrapExec.stop(); - } - await this.screenReader.dispose(); - await this.tradeMonitor.stop(); - await this.logWatcher.stop(); - } - - // --- Internal --- - - private wireEvents(): void { - this.on('link-added', (link: TradeLink) => { - if (link.active) { - this.activateLink(link).catch(err => { - logger.error({ err }, 'Failed to activate link from event'); - }); - } - }); - - this.on('link-removed', (id: string) => { - this.deactivateLink(id).catch(err => { - logger.error({ err }, 'Failed to deactivate link from event'); - }); - this.emit('log', 'info', `Removed search: ${id}`); - }); - - this.on('link-toggled', (data: { id: string; active: boolean; link: TradeLink }) => { - if (data.active) { - this.activateLink(data.link).catch(err => { - logger.error({ err }, 'Failed to activate link from toggle'); - }); - this.emit('log', 'info', `Activated: ${data.link.name || data.id}`); - } else { - this.deactivateLink(data.id).catch(err => { - logger.error({ err }, 'Failed to deactivate link from toggle'); - }); - this.emit('log', 'info', `Deactivated: ${data.link.name || data.id}`); - } - }); - - this.on('link-mode-changed', (data: { id: string; mode: string; link: TradeLink }) => { - if (data.link.active) { - this.deactivateLink(data.id).then(() => this.activateLink(data.link)).catch(err => { - logger.error({ err }, 'Failed to restart link after mode change'); - }); - this.emit('log', 'info', `Mode changed to ${data.mode}: ${data.link.name || data.id}`); - } - }); - - this.on('link-postaction-changed', (data: { id: string; postAction: string; link: TradeLink }) => { - if (data.link.active) { - this.deactivateLink(data.id).then(() => this.activateLink(data.link)).catch(err => { - logger.error({ err }, 'Failed to restart link after postAction change'); - }); - this.emit('log', 'info', `Post-action changed to ${data.postAction}: ${data.link.name || data.id}`); - } - }); - - // Trade monitor → trade queue - this.tradeMonitor.on('new-listings', (data: { searchId: string; itemIds: string[]; page: Page }) => { - if (this.isPaused) { - this.emit('log', 'warn', `New listings (${data.itemIds.length}) skipped - bot paused`); - return; - } - if (!this.isLinkActive(data.searchId)) return; - - logger.info({ searchId: data.searchId, itemCount: data.itemIds.length }, 'New listings received, queuing trade...'); - this.emit('log', 'info', `New listings: ${data.itemIds.length} items from ${data.searchId}`); - - this.tradeQueue.enqueue({ - searchId: data.searchId, - itemIds: data.itemIds, - whisperText: '', - timestamp: Date.now(), - tradeUrl: '', - page: data.page, - }); - }); - } - - private async activateLink(link: TradeLink): Promise { - try { - if (link.mode === 'scrap') { - const scrapExec = new ScrapExecutor( - this.gameController, this.screenReader, this.tradeMonitor, - this.inventoryManager, this.config, - ); - scrapExec.onStateChange = () => this.updateExecutorState(); - this.scrapExecutors.set(link.id, scrapExec); - this.emit('log', 'info', `Scrap loop started: ${link.name || link.label}`); - this.emit('status-update'); - - scrapExec.runScrapLoop(link.url, link.postAction).catch((err) => { - logger.error({ err, linkId: link.id }, 'Scrap loop error'); - this.emit('log', 'error', `Scrap loop failed: ${link.name || link.label}`); - this.scrapExecutors.delete(link.id); - }); - } else { - await this.tradeMonitor.addSearch(link.url); - this.emit('log', 'info', `Monitoring: ${link.name || link.label}`); - this.emit('status-update'); - } - } catch (err) { - logger.error({ err, url: link.url }, 'Failed to activate link'); - this.emit('log', 'error', `Failed to activate: ${link.name || link.label}`); - } - } - - private async deactivateLink(id: string): Promise { - const scrapExec = this.scrapExecutors.get(id); - if (scrapExec) { - await scrapExec.stop(); - this.scrapExecutors.delete(id); - } - await this.tradeMonitor.pauseSearch(id); - } -} diff --git a/src-old/bot/ConfigStore.ts b/src-old/bot/ConfigStore.ts deleted file mode 100644 index 7999e99..0000000 --- a/src-old/bot/ConfigStore.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import path from 'path'; -import { logger } from '../util/logger.js'; -import type { LinkMode, PostAction } from '../types.js'; - -export interface SavedLink { - url: string; - name: string; - active: boolean; - mode: LinkMode; - postAction?: PostAction; - addedAt: string; -} - -export interface SavedSettings { - paused: boolean; - links: SavedLink[]; - poe2LogPath: string; - poe2WindowTitle: string; - browserUserDataDir: string; - travelTimeoutMs: number; - stashScanTimeoutMs: number; - waitForMoreItemsMs: number; - betweenTradesDelayMs: number; - dashboardPort: number; -} - -const DEFAULTS: SavedSettings = { - paused: false, - links: [], - poe2LogPath: 'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Path of Exile 2\\logs\\Client.txt', - poe2WindowTitle: 'Path of Exile 2', - browserUserDataDir: './browser-data', - travelTimeoutMs: 15000, - stashScanTimeoutMs: 10000, - waitForMoreItemsMs: 20000, - betweenTradesDelayMs: 5000, - dashboardPort: 3000, -}; - -export class ConfigStore { - private filePath: string; - private data: SavedSettings; - - constructor(configPath?: string) { - this.filePath = configPath || path.resolve('config.json'); - this.data = this.load(); - } - - private load(): SavedSettings { - if (!existsSync(this.filePath)) { - logger.info({ path: this.filePath }, 'No config.json found, using defaults'); - return { ...DEFAULTS }; - } - - try { - 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, 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) { - logger.warn({ err, path: this.filePath }, 'Failed to read config.json, using defaults'); - return { ...DEFAULTS }; - } - } - - save(): void { - try { - writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), 'utf-8'); - } catch (err) { - logger.error({ err, path: this.filePath }, 'Failed to save config.json'); - } - } - - get settings(): SavedSettings { - return this.data; - } - - get links(): SavedLink[] { - return this.data.links; - } - - 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, - postAction: postAction || (mode === 'scrap' ? 'salvage' : 'stash'), - addedAt: new Date().toISOString(), - }); - this.save(); - } - - removeLink(url: string): void { - this.data.links = this.data.links.filter((l) => l.url !== url); - this.save(); - } - - removeLinkById(id: string): void { - this.data.links = this.data.links.filter((l) => { - const parts = l.url.split('/'); - return parts[parts.length - 1] !== id; - }); - this.save(); - } - - 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; - }); - if (!link) return null; - 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; - } - - setPaused(paused: boolean): void { - this.data.paused = paused; - this.save(); - } - - updateSettings(partial: Record): void { - Object.assign(this.data, partial); - this.save(); - } -} diff --git a/src-old/bot/LinkManager.ts b/src-old/bot/LinkManager.ts deleted file mode 100644 index c8ef55f..0000000 --- a/src-old/bot/LinkManager.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { logger } from '../util/logger.js'; -import type { LinkMode, PostAction } from '../types.js'; -import type { ConfigStore } from './ConfigStore.js'; - -export interface TradeLink { - id: string; - url: string; - name: string; - label: string; - active: boolean; - mode: LinkMode; - postAction: PostAction; - addedAt: string; -} - -export class LinkManager { - private links: Map = new Map(); - private store: ConfigStore; - - constructor(store: ConfigStore) { - this.store = store; - } - - addLink(url: string, name: string = '', mode?: LinkMode, postAction?: PostAction): TradeLink { - url = this.stripLive(url); - const id = this.extractId(url); - const label = this.extractLabel(url); - 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: 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, link.postAction); - logger.info({ id, url, name: link.name, active: link.active, mode: link.mode, postAction: link.postAction }, 'Trade link added'); - return link; - } - - removeLink(id: string): void { - const link = this.links.get(id); - this.links.delete(id); - if (link) { - this.store.removeLink(link.url); - } else { - this.store.removeLinkById(id); - } - logger.info({ id }, 'Trade link removed'); - } - - toggleLink(id: string, active: boolean): TradeLink | undefined { - const link = this.links.get(id); - if (!link) return undefined; - link.active = active; - this.store.updateLinkById(id, { active }); - logger.info({ id, active }, `Trade link ${active ? 'activated' : 'deactivated'}`); - return link; - } - - updateName(id: string, name: string): void { - const link = this.links.get(id); - if (!link) return; - link.name = name; - this.store.updateLinkById(id, { name }); - } - - updateMode(id: string, mode: LinkMode): TradeLink | undefined { - const link = this.links.get(id); - if (!link) return undefined; - link.mode = mode; - this.store.updateLinkById(id, { mode }); - logger.info({ id, mode }, 'Trade link mode updated'); - return link; - } - - updatePostAction(id: string, postAction: PostAction): TradeLink | undefined { - const link = this.links.get(id); - if (!link) return undefined; - link.postAction = postAction; - this.store.updateLinkById(id, { postAction }); - logger.info({ id, postAction }, 'Trade link postAction updated'); - return link; - } - - isActive(id: string): boolean { - const link = this.links.get(id); - return link ? link.active : false; - } - - getLinks(): TradeLink[] { - return Array.from(this.links.values()); - } - - getLink(id: string): TradeLink | undefined { - return this.links.get(id); - } - - private stripLive(url: string): string { - return url.replace(/\/live\/?$/, ''); - } - - private extractId(url: string): string { - const parts = url.split('/'); - return parts[parts.length - 1] || url; - } - - private extractLabel(url: string): string { - try { - const urlObj = new URL(url); - const parts = urlObj.pathname.split('/').filter(Boolean); - const poe2Idx = parts.indexOf('poe2'); - if (poe2Idx >= 0 && parts.length > poe2Idx + 2) { - const league = decodeURIComponent(parts[poe2Idx + 1]); - const searchId = parts[poe2Idx + 2]; - return `${league} / ${searchId}`; - } - } catch { - // fallback - } - return url.substring(0, 60); - } -} diff --git a/src-old/config.ts b/src-old/config.ts deleted file mode 100644 index adaed09..0000000 --- a/src-old/config.ts +++ /dev/null @@ -1,38 +0,0 @@ -import dotenv from 'dotenv'; -import type { Config } from './types.js'; - -dotenv.config(); - -function env(key: string, fallback?: string): string { - const val = process.env[key]; - if (val !== undefined) return val; - if (fallback !== undefined) return fallback; - throw new Error(`Missing required environment variable: ${key}`); -} - -function envInt(key: string, fallback: number): number { - const val = process.env[key]; - return val ? parseInt(val, 10) : fallback; -} - -export function loadConfig(cliUrls?: string[]): Config { - const envUrls = process.env['TRADE_URLS'] - ? process.env['TRADE_URLS'].split(',').map((u) => u.trim()) - : []; - - const tradeUrls = cliUrls && cliUrls.length > 0 ? cliUrls : envUrls; - - return { - tradeUrls, - poe2LogPath: env( - 'POE2_LOG_PATH', - 'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Path of Exile 2\\logs\\Client.txt', - ), - poe2WindowTitle: env('POE2_WINDOW_TITLE', 'Path of Exile 2'), - browserUserDataDir: env('BROWSER_USER_DATA_DIR', './browser-data'), - travelTimeoutMs: envInt('TRAVEL_TIMEOUT_MS', 15000), - stashScanTimeoutMs: envInt('STASH_SCAN_TIMEOUT_MS', 10000), - waitForMoreItemsMs: envInt('WAIT_FOR_MORE_ITEMS_MS', 20000), - betweenTradesDelayMs: envInt('BETWEEN_TRADES_DELAY_MS', 5000), - }; -} diff --git a/src-old/executor/ScrapExecutor.ts b/src-old/executor/ScrapExecutor.ts deleted file mode 100644 index c72747f..0000000 --- a/src-old/executor/ScrapExecutor.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { GameController } from '../game/GameController.js'; -import { GRID_LAYOUTS } from '../game/GridReader.js'; -import { TradeMonitor } from '../trade/TradeMonitor.js'; -import { InventoryManager } from '../inventory/InventoryManager.js'; -import { sleep, randomDelay } from '../util/sleep.js'; -import { logger } from '../util/logger.js'; -import type { Config, ScrapState, TradeItem, PostAction } from '../types.js'; -import type { ScreenReader } from '../game/ScreenReader.js'; -import type { Page } from 'playwright'; - -export class ScrapExecutor { - private state: ScrapState = 'IDLE'; - private stopped = false; - private activePage: Page | null = null; - private postAction: PostAction = 'salvage'; - private gameController: GameController; - private screenReader: ScreenReader; - private tradeMonitor: TradeMonitor; - private inventoryManager: InventoryManager; - private config: Config; - private _onStateChange?: (state: string) => void; - - constructor( - gameController: GameController, - screenReader: ScreenReader, - tradeMonitor: TradeMonitor, - inventoryManager: InventoryManager, - config: Config, - ) { - this.gameController = gameController; - this.screenReader = screenReader; - this.tradeMonitor = tradeMonitor; - this.inventoryManager = inventoryManager; - this.config = config; - } - - set onStateChange(cb: (state: string) => void) { - this._onStateChange = cb; - } - - getState(): ScrapState { - return this.state; - } - - private setState(s: ScrapState): void { - this.state = s; - this._onStateChange?.(s); - } - - /** Stop the scrap loop gracefully. */ - async stop(): Promise { - this.stopped = true; - if (this.activePage) { - try { await this.activePage.close(); } catch { /* best-effort */ } - this.activePage = null; - } - this.setState('IDLE'); - logger.info('Scrap executor stopped'); - } - - /** Main entry point — runs the full scrap loop. */ - async runScrapLoop(tradeUrl: string, postAction: PostAction = 'salvage'): Promise { - this.stopped = false; - this.postAction = postAction; - logger.info({ tradeUrl, postAction }, 'Starting scrap loop'); - - // Scan real inventory to know current state - await this.inventoryManager.scanInventory(this.postAction); - - let { page, items } = await this.tradeMonitor.openScrapPage(tradeUrl); - this.activePage = page; - logger.info({ itemCount: items.length }, 'Trade page opened, items fetched'); - - while (!this.stopped) { - let salvageFailed = false; - - for (const item of items) { - if (this.stopped) break; - - // Check if this item fits before traveling - if (!this.inventoryManager.tracker.canFit(item.w, item.h)) { - // If salvage already failed this page, don't retry — skip remaining items - if (salvageFailed) { - logger.info({ w: item.w, h: item.h }, 'Skipping item (salvage already failed this page)'); - continue; - } - - logger.info({ w: item.w, h: item.h, free: this.inventoryManager.tracker.freeCells }, 'No room for item, running process cycle'); - await this.processItems(); - - // Check if process succeeded (state is IDLE on success, FAILED otherwise) - if (this.state === 'FAILED') { - salvageFailed = true; - this.setState('IDLE'); - logger.warn('Process cycle failed, skipping remaining items that do not fit'); - continue; - } - - // Re-scan inventory after processing to get accurate state - await this.inventoryManager.scanInventory(this.postAction); - } - - // Still no room after processing — skip this item - if (!this.inventoryManager.tracker.canFit(item.w, item.h)) { - logger.warn({ w: item.w, h: item.h, free: this.inventoryManager.tracker.freeCells }, 'Item still cannot fit after processing, skipping'); - continue; - } - - const success = await this.buyItem(page, item); - if (!success) { - logger.warn({ itemId: item.id }, 'Failed to buy item, continuing'); - continue; - } - - await randomDelay(500, 1000); - } - - if (this.stopped) break; - - // Page exhausted — refresh and get new items - logger.info('Page exhausted, refreshing...'); - items = await this.refreshPage(page); - logger.info({ itemCount: items.length }, 'Page refreshed'); - - if (items.length === 0) { - logger.info('No items after refresh, waiting before retry...'); - await sleep(5000); - if (this.stopped) break; - items = await this.refreshPage(page); - } - } - - this.activePage = null; - this.setState('IDLE'); - logger.info('Scrap loop ended'); - } - - /** Buy one item from a seller. */ - private async buyItem(page: Page, item: TradeItem): Promise { - try { - const alreadyAtSeller = !this.inventoryManager.isAtOwnHideout - && item.account - && item.account === this.inventoryManager.sellerAccount; - - if (alreadyAtSeller) { - logger.info({ itemId: item.id, account: item.account }, 'Already at seller hideout, skipping travel'); - } else { - this.setState('TRAVELING'); - - // Register listener BEFORE clicking, then click inside the callback - const arrived = await this.inventoryManager.waitForAreaTransition( - this.config.travelTimeoutMs, - async () => { - const clicked = await this.tradeMonitor.clickTravelToHideout(page, item.id); - if (!clicked) { - throw new Error('Failed to click Travel to Hideout'); - } - }, - ); - if (!arrived) { - logger.error({ itemId: item.id }, 'Timed out waiting for hideout arrival'); - this.setState('FAILED'); - return false; - } - - this.inventoryManager.setLocation(false, item.account); - await this.gameController.focusGame(); - await sleep(1500); // Wait for hideout to render - } - - this.setState('BUYING'); - - // CTRL+Click at seller stash position - const sellerLayout = GRID_LAYOUTS.seller; - const cellCenter = this.screenReader.grid.getCellCenter(sellerLayout, item.stashY, item.stashX); - logger.info({ itemId: item.id, stashX: item.stashX, stashY: item.stashY, screenX: cellCenter.x, screenY: cellCenter.y }, 'CTRL+clicking seller stash item'); - - await this.gameController.ctrlLeftClickAt(cellCenter.x, cellCenter.y); - await randomDelay(200, 400); - - // Track in inventory with this link's postAction - const placed = this.inventoryManager.tracker.tryPlace(item.w, item.h, this.postAction); - if (!placed) { - logger.warn({ itemId: item.id, w: item.w, h: item.h }, 'Item bought but could not track in inventory'); - } - - logger.info({ itemId: item.id, free: this.inventoryManager.tracker.freeCells }, 'Item bought successfully'); - this.setState('IDLE'); - return true; - } catch (err) { - logger.error({ err, itemId: item.id }, 'Error buying item'); - this.setState('FAILED'); - return false; - } - } - - /** Process inventory: salvage/stash cycle via InventoryManager. */ - private async processItems(): Promise { - try { - this.setState('SALVAGING'); - await this.inventoryManager.processInventory(); - this.setState('IDLE'); - logger.info('Process cycle complete'); - } catch (err) { - logger.error({ err }, 'Process cycle failed'); - this.setState('FAILED'); - } - } - - /** Refresh the trade page and return new items. */ - private async refreshPage(page: Page): Promise { - const items: TradeItem[] = []; - - // Set up response listener before reloading - const responseHandler = async (response: { url(): string; json(): Promise }) => { - if (response.url().includes('/api/trade2/fetch/')) { - try { - const json = await response.json(); - if (json.result && Array.isArray(json.result)) { - for (const r of json.result) { - items.push({ - id: r.id, - w: r.item?.w ?? 1, - h: r.item?.h ?? 1, - stashX: r.listing?.stash?.x ?? 0, - stashY: r.listing?.stash?.y ?? 0, - account: r.listing?.account?.name ?? '', - }); - } - } - } catch { - // Response may not be JSON - } - } - }; - - page.on('response', responseHandler); - await page.reload({ waitUntil: 'networkidle' }); - await sleep(2000); - page.off('response', responseHandler); - - return items; - } -} diff --git a/src-old/executor/TradeExecutor.ts b/src-old/executor/TradeExecutor.ts deleted file mode 100644 index a3f0d1c..0000000 --- a/src-old/executor/TradeExecutor.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { GameController } from '../game/GameController.js'; -import { ScreenReader } from '../game/ScreenReader.js'; -import { TradeMonitor } from '../trade/TradeMonitor.js'; -import { InventoryManager } from '../inventory/InventoryManager.js'; -import { sleep } from '../util/sleep.js'; -import { logger } from '../util/logger.js'; -import type { Config, TradeInfo, TradeState, Region } from '../types.js'; -import type { Page } from 'playwright'; - -// Default screen regions for 1920x1080 - these need calibration -const DEFAULT_REGIONS = { - stashArea: { x: 20, y: 140, width: 630, height: 750 }, - priceWarningDialog: { x: 600, y: 350, width: 700, height: 300 }, - priceWarningNoButton: { x: 820, y: 560, width: 120, height: 40 }, - inventoryArea: { x: 1260, y: 580, width: 630, height: 280 }, - stashTabArea: { x: 20, y: 100, width: 630, height: 40 }, -}; - -export class TradeExecutor { - private state: TradeState = 'IDLE'; - private gameController: GameController; - private screenReader: ScreenReader; - private tradeMonitor: TradeMonitor; - private inventoryManager: InventoryManager; - private config: Config; - private _onStateChange?: (state: string) => void; - - constructor( - gameController: GameController, - screenReader: ScreenReader, - tradeMonitor: TradeMonitor, - inventoryManager: InventoryManager, - config: Config, - ) { - this.gameController = gameController; - this.screenReader = screenReader; - this.tradeMonitor = tradeMonitor; - this.inventoryManager = inventoryManager; - this.config = config; - } - - set onStateChange(cb: (state: string) => void) { - this._onStateChange = cb; - } - - getState(): TradeState { - return this.state; - } - - private setState(s: TradeState): void { - this.state = s; - this._onStateChange?.(s); - } - - async executeTrade(trade: TradeInfo): Promise { - const page = trade.page as Page; - - try { - // Step 1: Click "Travel to Hideout" on the trade website - this.setState('TRAVELING'); - logger.info({ searchId: trade.searchId }, 'Clicking Travel to Hideout...'); - - // Register listener BEFORE clicking, then click inside the callback - const arrived = await this.inventoryManager.waitForAreaTransition( - this.config.travelTimeoutMs, - async () => { - const travelClicked = await this.tradeMonitor.clickTravelToHideout( - page, - trade.itemIds[0], - ); - if (!travelClicked) { - throw new Error('Failed to click Travel to Hideout'); - } - }, - ); - if (!arrived) { - logger.error('Timed out waiting for hideout arrival'); - this.setState('FAILED'); - return false; - } - - this.setState('IN_SELLERS_HIDEOUT'); - this.inventoryManager.setLocation(false); - logger.info('Arrived at seller hideout'); - - // Step 3: Focus game window and click on Ange then Stash - await this.gameController.focusGame(); - await sleep(1500); // Wait for hideout to render - - // Click on Ange NPC to interact - const angePos = await this.inventoryManager.findAndClickNameplate('Ange'); - if (!angePos) { - logger.warn('Could not find Ange nameplate, trying Stash directly'); - } else { - await sleep(1000); // Wait for NPC interaction - } - - // Click on Stash to open it - const stashPos = await this.inventoryManager.findAndClickNameplate('Stash'); - if (!stashPos) { - logger.error('Could not find Stash nameplate in seller hideout'); - this.setState('FAILED'); - return false; - } - await sleep(1000); // Wait for stash to open - - // Step 4: Scan stash and buy items - this.setState('SCANNING_STASH'); - logger.info('Scanning stash for items...'); - - await this.scanAndBuyItems(); - - // Step 5: Wait for more items - this.setState('WAITING_FOR_MORE'); - logger.info( - { waitMs: this.config.waitForMoreItemsMs }, - 'Waiting for seller to add more items...', - ); - await sleep(this.config.waitForMoreItemsMs); - - // Do one more scan after waiting - await this.scanAndBuyItems(); - - // Step 6: Go back to own hideout - this.setState('GOING_HOME'); - logger.info('Traveling to own hideout...'); - await this.gameController.focusGame(); - await sleep(300); - - const home = await this.inventoryManager.waitForAreaTransition( - this.config.travelTimeoutMs, - () => this.gameController.goToHideout(), - ); - if (!home) { - logger.warn('Timed out going home, continuing anyway...'); - } - - this.inventoryManager.setLocation(true); - - // Step 7: Store items in stash - this.setState('IN_HIDEOUT'); - await sleep(1000); - await this.storeItems(); - - this.setState('IDLE'); - return true; - } catch (err) { - logger.error({ err }, 'Trade execution failed'); - this.setState('FAILED'); - - // Try to recover by going home - try { - await this.gameController.focusGame(); - await this.gameController.pressEscape(); // Close any open dialogs - await sleep(500); - await this.gameController.goToHideout(); - } catch { - // Best-effort recovery - } - - this.setState('IDLE'); - return false; - } - } - - private async scanAndBuyItems(): Promise { - // Take a screenshot of the stash area - const stashText = await this.screenReader.readRegionText(DEFAULT_REGIONS.stashArea); - logger.info({ stashText: stashText.substring(0, 200) }, 'Stash OCR result'); - - // For now, we'll use a simple grid-based approach to click items - // The exact positions depend on the stash layout and resolution - // This needs calibration with real game screenshots - // - // TODO: Implement item matching logic based on OCR text - // For now, we'll Ctrl+right-click at known grid positions - - this.setState('BUYING'); - - // Check for price warning dialog after each buy - await this.checkPriceWarning(); - } - - private async checkPriceWarning(): Promise { - // Check if a price warning dialog appeared - const hasWarning = await this.screenReader.checkForText( - DEFAULT_REGIONS.priceWarningDialog, - 'price', - ); - - if (hasWarning) { - logger.warn('Price mismatch warning detected! Clicking No.'); - // Click the "No" button - await this.gameController.leftClickAt( - DEFAULT_REGIONS.priceWarningNoButton.x + DEFAULT_REGIONS.priceWarningNoButton.width / 2, - DEFAULT_REGIONS.priceWarningNoButton.y + DEFAULT_REGIONS.priceWarningNoButton.height / 2, - ); - await sleep(500); - } - } - - private async storeItems(): Promise { - logger.info('Storing purchased items...'); - await this.inventoryManager.processInventory(); - logger.info('Item storage complete'); - } -} diff --git a/src-old/executor/TradeQueue.ts b/src-old/executor/TradeQueue.ts deleted file mode 100644 index 3afd9ff..0000000 --- a/src-old/executor/TradeQueue.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { logger } from '../util/logger.js'; -import { sleep, randomDelay } from '../util/sleep.js'; -import type { TradeExecutor } from './TradeExecutor.js'; -import type { TradeInfo, Config } from '../types.js'; - -export class TradeQueue { - private queue: TradeInfo[] = []; - private processing = false; - - constructor( - private executor: TradeExecutor, - private config: Config, - ) {} - - enqueue(trade: TradeInfo): void { - // De-duplicate: skip if same item ID already queued - const existingIds = new Set(this.queue.flatMap((t) => t.itemIds)); - const newIds = trade.itemIds.filter((id) => !existingIds.has(id)); - - if (newIds.length === 0) { - logger.info({ itemIds: trade.itemIds }, 'Skipping duplicate trade'); - return; - } - - const dedupedTrade = { ...trade, itemIds: newIds }; - this.queue.push(dedupedTrade); - logger.info( - { itemIds: newIds, queueLength: this.queue.length }, - 'Trade enqueued', - ); - - this.processNext(); - } - - get length(): number { - return this.queue.length; - } - - get isProcessing(): boolean { - return this.processing; - } - - private async processNext(): Promise { - if (this.processing || this.queue.length === 0) return; - this.processing = true; - - const trade = this.queue.shift()!; - try { - logger.info( - { searchId: trade.searchId, itemIds: trade.itemIds }, - 'Processing trade', - ); - const success = await this.executor.executeTrade(trade); - if (success) { - logger.info({ itemIds: trade.itemIds }, 'Trade completed successfully'); - } else { - logger.warn({ itemIds: trade.itemIds }, 'Trade failed'); - } - } catch (err) { - logger.error({ err, itemIds: trade.itemIds }, 'Trade execution error'); - } - - this.processing = false; - - // Delay between trades - await randomDelay(this.config.betweenTradesDelayMs, this.config.betweenTradesDelayMs + 3000); - this.processNext(); - } -} diff --git a/src-old/game/GameController.ts b/src-old/game/GameController.ts deleted file mode 100644 index 52a1905..0000000 --- a/src-old/game/GameController.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { WindowManager } from './WindowManager.js'; -import { InputSender, VK } from './InputSender.js'; -import { sleep, randomDelay } from '../util/sleep.js'; -import { writeClipboard } from '../util/clipboard.js'; -import { logger } from '../util/logger.js'; -import type { Config } from '../types.js'; - -export class GameController { - private windowManager: WindowManager; - private inputSender: InputSender; - - constructor(config: Config) { - this.windowManager = new WindowManager(config.poe2WindowTitle); - this.inputSender = new InputSender(); - } - - async focusGame(): Promise { - const result = this.windowManager.focusWindow(); - if (result) { - await sleep(300); // Wait for window to actually focus - } - return result; - } - - isGameFocused(): boolean { - return this.windowManager.isGameFocused(); - } - - getWindowRect() { - return this.windowManager.getWindowRect(); - } - - async sendChat(message: string): Promise { - logger.info({ message }, 'Sending chat message'); - - // Open chat - await this.inputSender.pressKey(VK.RETURN); - await randomDelay(100, 200); - - // Clear any existing text - await this.inputSender.selectAll(); - await sleep(50); - await this.inputSender.pressKey(VK.BACK); - await sleep(50); - - // Type the message - await this.inputSender.typeText(message); - await randomDelay(50, 100); - - // Send - await this.inputSender.pressKey(VK.RETURN); - await sleep(100); - } - - async sendChatViaPaste(message: string): Promise { - logger.info({ message }, 'Sending chat message via paste'); - - // Copy message to clipboard - writeClipboard(message); - await sleep(50); - - // Open chat - await this.inputSender.pressKey(VK.RETURN); - await randomDelay(100, 200); - - // Clear any existing text - await this.inputSender.selectAll(); - await sleep(50); - await this.inputSender.pressKey(VK.BACK); - await sleep(50); - - // Paste - await this.inputSender.paste(); - await randomDelay(100, 200); - - // Send - await this.inputSender.pressKey(VK.RETURN); - await sleep(100); - } - - async goToHideout(): Promise { - logger.info('Sending /hideout command'); - await this.sendChatViaPaste('/hideout'); - } - - async ctrlRightClickAt(x: number, y: number): Promise { - await this.inputSender.ctrlRightClick(x, y); - } - - async moveMouseTo(x: number, y: number): Promise { - await this.inputSender.moveMouse(x, y); - } - - moveMouseInstant(x: number, y: number): void { - this.inputSender.moveMouseInstant(x, y); - } - - async moveMouseFast(x: number, y: number): Promise { - await this.inputSender.moveMouseFast(x, y); - } - - async leftClickAt(x: number, y: number): Promise { - await this.inputSender.leftClick(x, y); - } - - async rightClickAt(x: number, y: number): Promise { - await this.inputSender.rightClick(x, y); - } - - async holdAlt(): Promise { - await this.inputSender.keyDown(VK.MENU); - } - - async releaseAlt(): Promise { - await this.inputSender.keyUp(VK.MENU); - } - - async pressEscape(): Promise { - await this.inputSender.pressKey(VK.ESCAPE); - } - - async openInventory(): Promise { - logger.info('Opening inventory'); - await this.inputSender.pressKey(VK.I); - await sleep(300); - } - - async ctrlLeftClickAt(x: number, y: number): Promise { - await this.inputSender.ctrlLeftClick(x, y); - } - - async holdCtrl(): Promise { - await this.inputSender.keyDown(VK.CONTROL); - } - - async releaseCtrl(): Promise { - await this.inputSender.keyUp(VK.CONTROL); - } -} diff --git a/src-old/game/GridReader.ts b/src-old/game/GridReader.ts deleted file mode 100644 index edcbf68..0000000 --- a/src-old/game/GridReader.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { logger } from '../util/logger.js'; -import type { OcrDaemon, GridItem, GridMatch } from './OcrDaemon.js'; -import type { Region } from '../types.js'; - -// ── Grid type definitions ─────────────────────────────────────────────────── - -export interface GridLayout { - region: Region; - cols: number; - rows: number; -} - -export interface CellCoord { - row: number; - col: number; - x: number; - y: number; -} - -export interface ScanResult { - layout: GridLayout; - occupied: CellCoord[]; - items: GridItem[]; - matches?: GridMatch[]; -} - -// ── Calibrated grid layouts (2560×1440) ───────────────────────────────────── - -export const GRID_LAYOUTS: Record = { - /** Player inventory — always 12×5, right side (below equipment slots) */ - inventory: { - region: { x: 1696, y: 788, width: 840, height: 350 }, - cols: 12, - rows: 5, - }, - /** Personal stash 12×12 — left side, tab not in folder */ - stash12: { - region: { x: 23, y: 169, width: 840, height: 840 }, - cols: 12, - rows: 12, - }, - /** Personal stash 12×12 — left side, tab in folder */ - stash12_folder: { - region: { x: 23, y: 216, width: 840, height: 840 }, - cols: 12, - rows: 12, - }, - /** Personal stash 24×24 (quad tab) — left side, tab not in folder */ - stash24: { - region: { x: 23, y: 169, width: 840, height: 840 }, - cols: 24, - rows: 24, - }, - /** Personal stash 24×24 (quad tab) — left side, tab in folder */ - stash24_folder: { - region: { x: 23, y: 216, width: 840, height: 840 }, - cols: 24, - rows: 24, - }, - /** Seller's public stash — always 12×12 */ - seller: { - region: { x: 416, y: 299, width: 840, height: 840 }, - cols: 12, - rows: 12, - }, - /** NPC shop — 12×12 */ - shop: { - region: { x: 23, y: 216, width: 840, height: 840 }, - cols: 12, - rows: 12, - }, - /** NPC vendor inventory — 12×12 */ - vendor: { - region: { x: 416, y: 369, width: 840, height: 840 }, - cols: 12, - rows: 12, - }, -}; - -// Backward-compat exports -export const INVENTORY = GRID_LAYOUTS.inventory; -export const STASH_12x12 = GRID_LAYOUTS.stash12; -export const STASH_24x24 = GRID_LAYOUTS.stash24; -export const SELLER_12x12 = GRID_LAYOUTS.seller; - -// ── GridReader ────────────────────────────────────────────────────────────── - -export class GridReader { - constructor(private daemon: OcrDaemon) {} - - /** - * Scan a named grid layout for occupied cells. - */ - async scan(layoutName: string, threshold?: number, targetRow?: number, targetCol?: number): Promise { - const layout = GRID_LAYOUTS[layoutName]; - if (!layout) throw new Error(`Unknown grid layout: ${layoutName}`); - - const t = performance.now(); - const { occupied, items, matches } = await this.getOccupiedCells(layout, threshold, targetRow, targetCol); - - const ms = (performance.now() - t).toFixed(0); - logger.info( - { layoutName, cols: layout.cols, rows: layout.rows, occupied: occupied.length, items: items.length, matches: matches?.length, ms }, - 'Grid scan complete', - ); - - return { layout, occupied, items, matches }; - } - - /** Get the screen-space center of a grid cell */ - getCellCenter(layout: GridLayout, row: number, col: number): { x: number; y: number } { - const cellW = layout.region.width / layout.cols; - const cellH = layout.region.height / layout.rows; - return { - x: Math.round(layout.region.x + col * cellW + cellW / 2), - y: Math.round(layout.region.y + row * cellH + cellH / 2), - }; - } - - /** Scan the grid and return which cells are occupied and detected items */ - async getOccupiedCells(layout: GridLayout, threshold?: number, targetRow?: number, targetCol?: number): Promise<{ occupied: CellCoord[]; items: GridItem[]; matches?: GridMatch[] }> { - const t = performance.now(); - const result = await this.daemon.gridScan( - layout.region, - layout.cols, - layout.rows, - threshold, - targetRow, - targetCol, - ); - - const occupied: CellCoord[] = []; - for (let row = 0; row < result.cells.length; row++) { - for (let col = 0; col < result.cells[row].length; col++) { - if (result.cells[row][col]) { - const center = this.getCellCenter(layout, row, col); - occupied.push({ row, col, x: center.x, y: center.y }); - } - } - } - - const ms = (performance.now() - t).toFixed(0); - logger.info( - { layout: `${layout.cols}x${layout.rows}`, occupied: occupied.length, items: result.items.length, matches: result.matches?.length, ms }, - 'Grid scan complete', - ); - return { occupied, items: result.items, matches: result.matches }; - } - - /** Get all cell centers in the grid */ - getAllCells(layout: GridLayout): CellCoord[] { - const cells: CellCoord[] = []; - for (let row = 0; row < layout.rows; row++) { - for (let col = 0; col < layout.cols; col++) { - const center = this.getCellCenter(layout, row, col); - cells.push({ row, col, x: center.x, y: center.y }); - } - } - return cells; - } -} diff --git a/src-old/game/InputSender.ts b/src-old/game/InputSender.ts deleted file mode 100644 index b99cdc5..0000000 --- a/src-old/game/InputSender.ts +++ /dev/null @@ -1,342 +0,0 @@ -import koffi from 'koffi'; -import { sleep, randomDelay } from '../util/sleep.js'; - -// Win32 POINT struct for GetCursorPos -const POINT = koffi.struct('POINT', { x: 'int32', y: 'int32' }); - -// Win32 INPUT struct on x64 is 40 bytes: -// type (4) + pad (4) + union (32) -// MOUSEINPUT is 32 bytes (the largest union member) -// KEYBDINPUT is 24 bytes, so needs 8 bytes trailing pad in the union -// -// We define flat structs that match the exact memory layout, -// then cast with koffi.as() when calling SendInput. - -const INPUT_KEYBOARD = koffi.struct('INPUT_KEYBOARD', { - type: 'uint32', // offset 0 - _pad0: 'uint32', // offset 4 (alignment for union at offset 8) - wVk: 'uint16', // offset 8 - wScan: 'uint16', // offset 10 - dwFlags: 'uint32', // offset 12 - time: 'uint32', // offset 16 - _pad1: 'uint32', // offset 20 (alignment for dwExtraInfo) - dwExtraInfo: 'uint64', // offset 24 - _pad2: koffi.array('uint8', 8), // offset 32, pad to 40 bytes total -}); - -const INPUT_MOUSE = koffi.struct('INPUT_MOUSE', { - type: 'uint32', // offset 0 - _pad0: 'uint32', // offset 4 (alignment for union at offset 8) - dx: 'int32', // offset 8 - dy: 'int32', // offset 12 - mouseData: 'uint32', // offset 16 - dwFlags: 'uint32', // offset 20 - time: 'uint32', // offset 24 - _pad1: 'uint32', // offset 28 (alignment for dwExtraInfo) - dwExtraInfo: 'uint64', // offset 32 -}); -// INPUT_MOUSE is already 40 bytes, no trailing pad needed - -const user32 = koffi.load('user32.dll'); - -const SendInput = user32.func('SendInput', 'uint32', ['uint32', 'void *', 'int32']); -const MapVirtualKeyW = user32.func('MapVirtualKeyW', 'uint32', ['uint32', 'uint32']); -const GetSystemMetrics = user32.func('GetSystemMetrics', 'int32', ['int32']); -const GetCursorPos = user32.func('GetCursorPos', 'int32', ['_Out_ POINT *']); - -// Constants -const INPUT_MOUSE_TYPE = 0; -const INPUT_KEYBOARD_TYPE = 1; -const KEYEVENTF_SCANCODE = 0x0008; -const KEYEVENTF_KEYUP = 0x0002; -const KEYEVENTF_UNICODE = 0x0004; - -// Mouse flags -const MOUSEEVENTF_MOVE = 0x0001; -const MOUSEEVENTF_LEFTDOWN = 0x0002; -const MOUSEEVENTF_LEFTUP = 0x0004; -const MOUSEEVENTF_RIGHTDOWN = 0x0008; -const MOUSEEVENTF_RIGHTUP = 0x0010; -const MOUSEEVENTF_ABSOLUTE = 0x8000; - -// System metrics -const SM_CXSCREEN = 0; -const SM_CYSCREEN = 1; - -// Virtual key codes -export const VK = { - RETURN: 0x0d, - CONTROL: 0x11, - MENU: 0x12, // Alt - SHIFT: 0x10, - ESCAPE: 0x1b, - TAB: 0x09, - SPACE: 0x20, - DELETE: 0x2e, - BACK: 0x08, - V: 0x56, - A: 0x41, - C: 0x43, - I: 0x49, -} as const; - -// Size to pass to SendInput (must be sizeof(INPUT) = 40 on x64) -const INPUT_SIZE = koffi.sizeof(INPUT_MOUSE); // 40 - -// Bézier curve helpers for natural mouse movement - -function easeInOutQuad(t: number): number { - return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2; -} - -interface Point { - x: number; - y: number; -} - -function cubicBezier(t: number, p0: Point, p1: Point, p2: Point, p3: Point): Point { - const u = 1 - t; - const u2 = u * u; - const u3 = u2 * u; - const t2 = t * t; - const t3 = t2 * t; - return { - x: u3 * p0.x + 3 * u2 * t * p1.x + 3 * u * t2 * p2.x + t3 * p3.x, - y: u3 * p0.y + 3 * u2 * t * p1.y + 3 * u * t2 * p2.y + t3 * p3.y, - }; -} - -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} - -export class InputSender { - private screenWidth: number; - private screenHeight: number; - - constructor() { - this.screenWidth = GetSystemMetrics(SM_CXSCREEN); - this.screenHeight = GetSystemMetrics(SM_CYSCREEN); - } - - async pressKey(vkCode: number): Promise { - const scanCode = MapVirtualKeyW(vkCode, 0); // MAPVK_VK_TO_VSC - this.sendScanKeyDown(scanCode); - await randomDelay(30, 50); - this.sendScanKeyUp(scanCode); - await randomDelay(20, 40); - } - - async keyDown(vkCode: number): Promise { - const scanCode = MapVirtualKeyW(vkCode, 0); - this.sendScanKeyDown(scanCode); - await randomDelay(15, 30); - } - - async keyUp(vkCode: number): Promise { - const scanCode = MapVirtualKeyW(vkCode, 0); - this.sendScanKeyUp(scanCode); - await randomDelay(15, 30); - } - - async typeText(text: string): Promise { - for (const char of text) { - this.sendUnicodeChar(char); - await randomDelay(20, 50); - } - } - - async paste(): Promise { - await this.keyDown(VK.CONTROL); - await sleep(30); - await this.pressKey(VK.V); - await this.keyUp(VK.CONTROL); - await sleep(50); - } - - async selectAll(): Promise { - await this.keyDown(VK.CONTROL); - await sleep(30); - await this.pressKey(VK.A); - await this.keyUp(VK.CONTROL); - await sleep(50); - } - - private getCursorPos(): Point { - const pt = { x: 0, y: 0 }; - GetCursorPos(pt); - return pt; - } - - private moveMouseRaw(x: number, y: number): void { - const normalizedX = Math.round((x * 65535) / this.screenWidth); - const normalizedY = Math.round((y * 65535) / this.screenHeight); - this.sendMouseInput(normalizedX, normalizedY, 0, MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE); - } - - async moveMouse(x: number, y: number): Promise { - const start = this.getCursorPos(); - const end: Point = { x, y }; - const dx = end.x - start.x; - const dy = end.y - start.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - // Short distance: just teleport - if (distance < 10) { - this.moveMouseRaw(x, y); - await randomDelay(10, 20); - return; - } - - // Generate 2 random control points offset from the straight line - const perpX = -dy / distance; - const perpY = dx / distance; - const spread = distance * 0.3; - - const cp1: Point = { - x: start.x + dx * 0.25 + perpX * (Math.random() - 0.5) * spread, - y: start.y + dy * 0.25 + perpY * (Math.random() - 0.5) * spread, - }; - const cp2: Point = { - x: start.x + dx * 0.75 + perpX * (Math.random() - 0.5) * spread, - y: start.y + dy * 0.75 + perpY * (Math.random() - 0.5) * spread, - }; - - const steps = clamp(Math.round(distance / 30), 8, 20); - - for (let i = 1; i <= steps; i++) { - const rawT = i / steps; - const t = easeInOutQuad(rawT); - const pt = cubicBezier(t, start, cp1, cp2, end); - - // Add ±1px jitter except on the last step - const jitterX = i < steps ? Math.round((Math.random() - 0.5) * 2) : 0; - const jitterY = i < steps ? Math.round((Math.random() - 0.5) * 2) : 0; - - this.moveMouseRaw(Math.round(pt.x) + jitterX, Math.round(pt.y) + jitterY); - await sleep(1 + Math.random() * 2); // 1-3ms between steps - } - - // Final exact landing - this.moveMouseRaw(x, y); - await randomDelay(5, 15); - } - - moveMouseInstant(x: number, y: number): void { - this.moveMouseRaw(x, y); - } - - /** Quick Bézier move — ~10-15ms, 5 steps, no jitter. Fast but not a raw teleport. */ - async moveMouseFast(x: number, y: number): Promise { - const start = this.getCursorPos(); - const end: Point = { x, y }; - const dx = end.x - start.x; - const dy = end.y - start.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < 10) { - this.moveMouseRaw(x, y); - return; - } - - const perpX = -dy / distance; - const perpY = dx / distance; - const spread = distance * 0.15; - - const cp1: Point = { - x: start.x + dx * 0.3 + perpX * (Math.random() - 0.5) * spread, - y: start.y + dy * 0.3 + perpY * (Math.random() - 0.5) * spread, - }; - const cp2: Point = { - x: start.x + dx * 0.7 + perpX * (Math.random() - 0.5) * spread, - y: start.y + dy * 0.7 + perpY * (Math.random() - 0.5) * spread, - }; - - const steps = 5; - for (let i = 1; i <= steps; i++) { - const t = easeInOutQuad(i / steps); - const pt = cubicBezier(t, start, cp1, cp2, end); - this.moveMouseRaw(Math.round(pt.x), Math.round(pt.y)); - await sleep(2); - } - this.moveMouseRaw(x, y); - } - - async leftClick(x: number, y: number): Promise { - await this.moveMouse(x, y); - await randomDelay(20, 50); - this.sendMouseInput(0, 0, 0, MOUSEEVENTF_LEFTDOWN); - await randomDelay(15, 40); - this.sendMouseInput(0, 0, 0, MOUSEEVENTF_LEFTUP); - await randomDelay(15, 30); - } - - async rightClick(x: number, y: number): Promise { - await this.moveMouse(x, y); - await randomDelay(20, 50); - this.sendMouseInput(0, 0, 0, MOUSEEVENTF_RIGHTDOWN); - await randomDelay(15, 40); - this.sendMouseInput(0, 0, 0, MOUSEEVENTF_RIGHTUP); - await randomDelay(15, 30); - } - - async ctrlRightClick(x: number, y: number): Promise { - await this.keyDown(VK.CONTROL); - await randomDelay(30, 60); - await this.rightClick(x, y); - await this.keyUp(VK.CONTROL); - await randomDelay(30, 60); - } - - async ctrlLeftClick(x: number, y: number): Promise { - await this.keyDown(VK.CONTROL); - await randomDelay(30, 60); - await this.leftClick(x, y); - await this.keyUp(VK.CONTROL); - await randomDelay(30, 60); - } - - private sendMouseInput(dx: number, dy: number, mouseData: number, flags: number): void { - const input = { - type: INPUT_MOUSE_TYPE, - _pad0: 0, - dx, - dy, - mouseData, - dwFlags: flags, - time: 0, - _pad1: 0, - dwExtraInfo: 0, - }; - SendInput(1, koffi.as(input, 'INPUT_MOUSE *'), INPUT_SIZE); - } - - private sendKeyInput(wVk: number, wScan: number, flags: number): void { - const input = { - type: INPUT_KEYBOARD_TYPE, - _pad0: 0, - wVk, - wScan, - dwFlags: flags, - time: 0, - _pad1: 0, - dwExtraInfo: 0, - _pad2: [0, 0, 0, 0, 0, 0, 0, 0], - }; - SendInput(1, koffi.as(input, 'INPUT_KEYBOARD *'), INPUT_SIZE); - } - - private sendScanKeyDown(scanCode: number): void { - this.sendKeyInput(0, scanCode, KEYEVENTF_SCANCODE); - } - - private sendScanKeyUp(scanCode: number): void { - this.sendKeyInput(0, scanCode, KEYEVENTF_SCANCODE | KEYEVENTF_KEYUP); - } - - private sendUnicodeChar(char: string): void { - const code = char.charCodeAt(0); - this.sendKeyInput(0, code, KEYEVENTF_UNICODE); - this.sendKeyInput(0, code, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP); - } -} diff --git a/src-old/game/OcrDaemon.ts b/src-old/game/OcrDaemon.ts deleted file mode 100644 index 780b0b3..0000000 --- a/src-old/game/OcrDaemon.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { spawn, type ChildProcess } from 'child_process'; -import { join } from 'path'; -import { logger } from '../util/logger.js'; -import type { Region } from '../types.js'; - -// ── Types ─────────────────────────────────────────────────────────────────── - -export interface OcrWord { - text: string; - x: number; - y: number; - width: number; - height: number; -} - -export interface OcrLine { - text: string; - words: OcrWord[]; -} - -export interface OcrResponse { - ok: true; - text: string; - lines: OcrLine[]; -} - -export interface GridItem { - row: number; - col: number; - w: number; - h: number; -} - -export interface GridMatch { - row: number; - col: number; - similarity: number; -} - -export interface GridScanResult { - cells: boolean[][]; - items: GridItem[]; - matches?: GridMatch[]; -} - -export interface DiffOcrResponse { - text: string; - lines: OcrLine[]; - region?: Region; -} - -export interface DetectGridResult { - detected: boolean; - region?: Region; - cols?: number; - rows?: number; - cellWidth?: number; - cellHeight?: number; -} - -export interface TemplateMatchResult { - found: boolean; - x: number; - y: number; - width: number; - height: number; - confidence: number; -} - -export type OcrEngine = 'tesseract' | 'easyocr' | 'paddleocr'; - -export type OcrPreprocess = 'none' | 'bgsub' | 'tophat'; - -export interface DiffCropParams { - diffThresh?: number; - rowThreshDiv?: number; - colThreshDiv?: number; - maxGap?: number; - trimCutoff?: number; - ocrPad?: number; -} - -export interface OcrParams { - kernelSize?: number; - upscale?: number; - useBackgroundSub?: boolean; - dimPercentile?: number; - textThresh?: number; - softThreshold?: boolean; - usePerLineOcr?: boolean; - lineGapTolerance?: number; - linePadY?: number; - psm?: number; - mergeGap?: number; - linkThreshold?: number; - textThreshold?: number; - lowText?: number; - widthThs?: number; - paragraph?: boolean; -} - -export interface DiffOcrParams { - crop?: DiffCropParams; - ocr?: OcrParams; -} - -export type TooltipMethod = 'diff' | 'edge'; - -export interface EdgeCropParams { - cannyLow?: number; - cannyHigh?: number; - minLineLength?: number; - roiSize?: number; - densityThreshold?: number; - ocrPad?: number; -} - -export interface EdgeOcrParams { - crop?: EdgeCropParams; - ocr?: OcrParams; -} - -interface DaemonRequest { - cmd: string; - region?: Region; - path?: string; - cols?: number; - rows?: number; - threshold?: number; - minCellSize?: number; - maxCellSize?: number; - engine?: string; - preprocess?: string; - params?: DiffOcrParams; - edgeParams?: EdgeOcrParams; - cursorX?: number; - cursorY?: number; -} - -interface DaemonResponse { - ok: boolean; - ready?: boolean; - text?: string; - lines?: OcrLine[]; - image?: string; - cells?: boolean[][]; - items?: GridItem[]; - matches?: GridMatch[]; - detected?: boolean; - region?: Region; - cols?: number; - rows?: number; - cellWidth?: number; - cellHeight?: number; - found?: boolean; - x?: number; - y?: number; - width?: number; - height?: number; - confidence?: number; - error?: string; -} - -// ── OcrDaemon ─────────────────────────────────────────────────────────────── - -const DEFAULT_EXE = join( - 'tools', 'OcrDaemon', 'bin', 'Release', - 'net8.0-windows10.0.19041.0', 'OcrDaemon.exe', -); - -const REQUEST_TIMEOUT = 5_000; -const CAPTURE_TIMEOUT = 10_000; - -export class OcrDaemon { - private proc: ChildProcess | null = null; - private exePath: string; - private readyResolve: ((value: void) => void) | null = null; - private readyReject: ((err: Error) => void) | null = null; - private pendingResolve: ((resp: DaemonResponse) => void) | null = null; - private pendingReject: ((err: Error) => void) | null = null; - private queue: Array<{ request: DaemonRequest; resolve: (resp: DaemonResponse) => void; reject: (err: Error) => void }> = []; - private processing = false; - private buffer = ''; - private stopped = false; - - constructor(exePath?: string) { - this.exePath = exePath ?? DEFAULT_EXE; - } - - // ── Public API ────────────────────────────────────────────────────────── - - async ocr(region?: Region, engine?: OcrEngine, preprocess?: OcrPreprocess): Promise { - const req: DaemonRequest = { cmd: 'ocr' }; - if (region) req.region = region; - if (engine && engine !== 'tesseract') req.engine = engine; - if (preprocess && preprocess !== 'none') req.preprocess = preprocess; - // Python engines need longer timeout for first model load + download - const timeout = (engine && engine !== 'tesseract') ? 120_000 : CAPTURE_TIMEOUT; - const resp = await this.sendWithRetry(req, timeout); - return { - ok: true, - text: resp.text ?? '', - lines: resp.lines ?? [], - }; - } - - async captureBuffer(region?: Region): Promise { - const req: DaemonRequest = { cmd: 'capture' }; - if (region) req.region = region; - const resp = await this.sendWithRetry(req, CAPTURE_TIMEOUT); - return Buffer.from(resp.image!, 'base64'); - } - - async gridScan(region: Region, cols: number, rows: number, threshold?: number, targetRow?: number, targetCol?: number): Promise { - const req: DaemonRequest = { cmd: 'grid', region, cols, rows }; - if (threshold) req.threshold = threshold; - if (targetRow != null && targetRow >= 0) (req as any).targetRow = targetRow; - if (targetCol != null && targetCol >= 0) (req as any).targetCol = targetCol; - const resp = await this.sendWithRetry(req, REQUEST_TIMEOUT); - return { cells: resp.cells ?? [], items: resp.items ?? [], matches: resp.matches ?? undefined }; - } - - async detectGrid(region: Region, minCellSize?: number, maxCellSize?: number): Promise { - const req: DaemonRequest = { cmd: 'detect-grid', region }; - if (minCellSize) req.minCellSize = minCellSize; - if (maxCellSize) req.maxCellSize = maxCellSize; - const resp = await this.sendWithRetry(req, REQUEST_TIMEOUT); - return { - detected: resp.detected ?? false, - region: resp.region, - cols: resp.cols, - rows: resp.rows, - cellWidth: resp.cellWidth, - cellHeight: resp.cellHeight, - }; - } - - async snapshot(): Promise { - await this.sendWithRetry({ cmd: 'snapshot' }, REQUEST_TIMEOUT); - } - - async diffOcr(savePath?: string, region?: Region, engine?: OcrEngine, preprocess?: OcrPreprocess, params?: DiffOcrParams): Promise { - const req: DaemonRequest = { cmd: 'diff-ocr' }; - if (savePath) req.path = savePath; - if (region) req.region = region; - if (engine && engine !== 'tesseract') req.engine = engine; - if (preprocess) req.preprocess = preprocess; - if (params && Object.keys(params).length > 0) req.params = params; - const timeout = (engine && engine !== 'tesseract') ? 120_000 : CAPTURE_TIMEOUT; - const resp = await this.sendWithRetry(req, timeout); - return { - text: resp.text ?? '', - lines: resp.lines ?? [], - region: resp.region, - }; - } - - async edgeOcr(savePath?: string, region?: Region, engine?: OcrEngine, preprocess?: OcrPreprocess, edgeParams?: EdgeOcrParams, cursorX?: number, cursorY?: number): Promise { - const req: DaemonRequest = { cmd: 'edge-ocr' }; - if (savePath) req.path = savePath; - if (region) req.region = region; - if (engine && engine !== 'tesseract') req.engine = engine; - if (preprocess) req.preprocess = preprocess; - if (edgeParams && Object.keys(edgeParams).length > 0) req.edgeParams = edgeParams; - if (cursorX != null) req.cursorX = cursorX; - if (cursorY != null) req.cursorY = cursorY; - const timeout = (engine && engine !== 'tesseract') ? 120_000 : CAPTURE_TIMEOUT; - const resp = await this.sendWithRetry(req, timeout); - return { - text: resp.text ?? '', - lines: resp.lines ?? [], - region: resp.region, - }; - } - - async saveScreenshot(path: string, region?: Region): Promise { - const req: DaemonRequest = { cmd: 'screenshot', path }; - if (region) req.region = region; - await this.sendWithRetry(req, REQUEST_TIMEOUT); - } - - async templateMatch(templatePath: string, region?: Region): Promise { - const req: DaemonRequest = { cmd: 'match-template', path: templatePath }; - if (region) req.region = region; - const resp = await this.sendWithRetry(req, REQUEST_TIMEOUT); - if (!resp.found) return null; - return { - found: true, - x: resp.x!, - y: resp.y!, - width: resp.width!, - height: resp.height!, - confidence: resp.confidence!, - }; - } - - /** Eagerly spawn the daemon process so it's ready for the first real request. */ - async warmup(): Promise { - await this.ensureRunning(); - } - - async stop(): Promise { - this.stopped = true; - if (this.proc) { - const p = this.proc; - this.proc = null; - p.stdin?.end(); - p.kill(); - } - } - - // ── Internal ──────────────────────────────────────────────────────────── - - private async ensureRunning(): Promise { - if (this.proc && this.proc.exitCode === null) return; - - this.proc = null; - this.buffer = ''; - - logger.info({ exe: this.exePath }, 'Spawning OCR daemon'); - - const proc = spawn(this.exePath, [], { - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - - this.proc = proc; - - proc.stderr?.on('data', (data: Buffer) => { - logger.warn({ daemon: data.toString().trim() }, 'OcrDaemon stderr'); - }); - - proc.on('exit', (code) => { - logger.warn({ code }, 'OcrDaemon exited'); - if (this.pendingReject) { - this.pendingReject(new Error(`Daemon exited with code ${code}`)); - this.pendingResolve = null; - this.pendingReject = null; - } - }); - - proc.stdout!.on('data', (data: Buffer) => { - this.buffer += data.toString(); - this.processBuffer(); - }); - - // Wait for ready signal - await new Promise((resolve, reject) => { - this.readyResolve = resolve; - this.readyReject = reject; - - const timeout = setTimeout(() => { - this.readyReject = null; - this.readyResolve = null; - reject(new Error('Daemon did not become ready within 10s')); - }, 10_000); - - // Store so we can clear on resolve - (this as any)._readyTimeout = timeout; - }); - - logger.info('OCR daemon ready'); - } - - private processBuffer(): void { - let newlineIdx: number; - while ((newlineIdx = this.buffer.indexOf('\n')) !== -1) { - const line = this.buffer.slice(0, newlineIdx).trim(); - this.buffer = this.buffer.slice(newlineIdx + 1); - - if (!line) continue; - - let parsed: DaemonResponse; - try { - parsed = JSON.parse(line); - } catch { - logger.warn({ line }, 'Failed to parse daemon response'); - continue; - } - - // Handle ready signal - if (parsed.ready && this.readyResolve) { - clearTimeout((this as any)._readyTimeout); - const resolve = this.readyResolve; - this.readyResolve = null; - this.readyReject = null; - resolve(); - continue; - } - - // Handle normal response - if (this.pendingResolve) { - const resolve = this.pendingResolve; - this.pendingResolve = null; - this.pendingReject = null; - resolve(parsed); - } - } - } - - private async send(request: DaemonRequest, timeout: number): Promise { - await this.ensureRunning(); - - return new Promise((resolve, reject) => { - this.queue.push({ request, resolve, reject }); - this.drainQueue(timeout); - }); - } - - private drainQueue(timeout: number): void { - if (this.processing || this.queue.length === 0) return; - this.processing = true; - - const { request, resolve, reject } = this.queue.shift()!; - - const timer = setTimeout(() => { - this.pendingResolve = null; - this.pendingReject = null; - this.processing = false; - reject(new Error(`Daemon request timed out after ${timeout}ms`)); - this.drainQueue(timeout); - }, timeout); - - this.pendingResolve = (resp) => { - clearTimeout(timer); - this.processing = false; - resolve(resp); - this.drainQueue(timeout); - }; - - this.pendingReject = (err) => { - clearTimeout(timer); - this.processing = false; - reject(err); - this.drainQueue(timeout); - }; - - const json = JSON.stringify(request) + '\n'; - this.proc!.stdin!.write(json); - } - - private async sendWithRetry(request: DaemonRequest, timeout: number): Promise { - try { - const resp = await this.send(request, timeout); - if (!resp.ok) throw new Error(resp.error ?? 'Daemon returned error'); - return resp; - } catch (err) { - if (this.stopped) throw err; - - // Kill and retry once - logger.warn({ err, cmd: request.cmd }, 'Daemon request failed, restarting'); - if (this.proc) { - const p = this.proc; - this.proc = null; - p.stdin?.end(); - p.kill(); - } - - const resp = await this.send(request, timeout); - if (!resp.ok) throw new Error(resp.error ?? 'Daemon returned error on retry'); - return resp; - } - } -} diff --git a/src-old/game/ScreenReader.ts b/src-old/game/ScreenReader.ts deleted file mode 100644 index 728e989..0000000 --- a/src-old/game/ScreenReader.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { mkdir } from 'fs/promises'; -import { join } from 'path'; -import { logger } from '../util/logger.js'; -import { OcrDaemon, type OcrResponse, type OcrEngine, type OcrPreprocess, type DiffOcrParams, type DiffCropParams, type OcrParams, type DiffOcrResponse, type TemplateMatchResult, type TooltipMethod, type EdgeOcrParams } from './OcrDaemon.js'; -import { GridReader, type GridLayout, type CellCoord } from './GridReader.js'; -import type { Region } from '../types.js'; - -function elapsed(start: number): string { - return `${(performance.now() - start).toFixed(0)}ms`; -} - -export interface OcrSettings { - engine: OcrEngine; - screenPreprocess: OcrPreprocess; - tooltipPreprocess: OcrPreprocess; - tooltipMethod: TooltipMethod; - tooltipParams: DiffOcrParams; - edgeParams: EdgeOcrParams; - saveDebugImages: boolean; -} - -export class ScreenReader { - private daemon = new OcrDaemon(); - readonly grid = new GridReader(this.daemon); - settings: OcrSettings = { - engine: 'easyocr', - screenPreprocess: 'none', - tooltipPreprocess: 'tophat', - tooltipMethod: 'diff', - tooltipParams: { - crop: { diffThresh: 10 }, - ocr: { kernelSize: 21 }, - }, - edgeParams: { - crop: {}, - ocr: { kernelSize: 21 }, - }, - saveDebugImages: true, - }; - - /** - * Eagerly spawn the OCR daemon and warm up the EasyOCR model. - * Fire-and-forget a small OCR request so the Python model loads in the background. - */ - async warmup(): Promise { - await this.daemon.warmup(); - // Fire a small EasyOCR request to trigger Python model load - // Use a tiny 1×1 region to minimize work, we only care about loading the model - const { engine } = this.settings; - if (engine !== 'tesseract') { - await this.daemon.ocr({ x: 0, y: 0, width: 100, height: 100 }, engine); - } - } - - // ── Screenshot capture ────────────────────────────────────────────── - - async captureScreen(): Promise { - const t = performance.now(); - const buf = await this.daemon.captureBuffer(); - logger.info({ ms: elapsed(t) }, 'captureScreen'); - return buf; - } - - async captureRegion(region: Region): Promise { - const t = performance.now(); - const buf = await this.daemon.captureBuffer(region); - logger.info({ ms: elapsed(t) }, 'captureRegion'); - return buf; - } - - // ── OCR helpers ───────────────────────────────────────────────────── - - /** Bigram (Dice) similarity between two strings, 0..1. */ - private static bigramSimilarity(a: string, b: string): number { - if (a.length < 2 || b.length < 2) return a === b ? 1 : 0; - const bigramsA = new Map(); - for (let i = 0; i < a.length - 1; i++) { - const bg = a.slice(i, i + 2); - bigramsA.set(bg, (bigramsA.get(bg) ?? 0) + 1); - } - let matches = 0; - for (let i = 0; i < b.length - 1; i++) { - const bg = b.slice(i, i + 2); - const count = bigramsA.get(bg); - if (count && count > 0) { - matches++; - bigramsA.set(bg, count - 1); - } - } - return (2 * matches) / (a.length - 1 + b.length - 1); - } - - /** Normalize text for fuzzy comparison: lowercase, strip non-alphanumeric, collapse spaces. */ - private static normalize(s: string): string { - return s.toLowerCase().replace(/[^a-z0-9]/g, ''); - } - - private findWordInOcrResult( - result: OcrResponse, - needle: string, - fuzzy: boolean = false, - ): { x: number; y: number } | null { - const lower = needle.toLowerCase(); - const FUZZY_THRESHOLD = 0.55; - - // Multi-word: match against the full line text, return center of the line's bounding box - if (lower.includes(' ')) { - const needleNorm = ScreenReader.normalize(needle); - - for (const line of result.lines) { - if (line.words.length === 0) continue; - - const lineText = line.text.toLowerCase(); - // Exact match - if (lineText.includes(lower)) { - return this.lineBounds(line); - } - - // Fuzzy: normalize line text and check sliding windows - if (fuzzy) { - const lineNorm = ScreenReader.normalize(line.text); - // Check windows of similar length to the needle - const windowLen = needleNorm.length; - for (let i = 0; i <= lineNorm.length - windowLen + 2; i++) { - const window = lineNorm.slice(i, i + windowLen + 2); - const sim = ScreenReader.bigramSimilarity(needleNorm, window); - if (sim >= FUZZY_THRESHOLD) { - logger.info({ needle, matched: line.text, similarity: sim.toFixed(2) }, 'Fuzzy nameplate match'); - return this.lineBounds(line); - } - } - } - } - return null; - } - - // Single word: match against individual words - const needleNorm = ScreenReader.normalize(needle); - for (const line of result.lines) { - for (const word of line.words) { - // Exact match - if (word.text.toLowerCase().includes(lower)) { - return { - x: Math.round(word.x + word.width / 2), - y: Math.round(word.y + word.height / 2), - }; - } - - // Fuzzy match - if (fuzzy) { - const wordNorm = ScreenReader.normalize(word.text); - const sim = ScreenReader.bigramSimilarity(needleNorm, wordNorm); - if (sim >= FUZZY_THRESHOLD) { - logger.info({ needle, matched: word.text, similarity: sim.toFixed(2) }, 'Fuzzy word match'); - return { - x: Math.round(word.x + word.width / 2), - y: Math.round(word.y + word.height / 2), - }; - } - } - } - } - return null; - } - - /** Get center of a line's bounding box from its words. */ - private lineBounds(line: { words: { x: number; y: number; width: number; height: number }[] }): { x: number; y: number } { - const first = line.words[0]; - const last = line.words[line.words.length - 1]; - const x1 = first.x; - const y1 = first.y; - const x2 = last.x + last.width; - const y2 = Math.max(...line.words.map(w => w.y + w.height)); - return { - x: Math.round((x1 + x2) / 2), - y: Math.round((y1 + y2) / 2), - }; - } - - // ── Full-screen methods ───────────────────────────────────────────── - - async findTextOnScreen( - searchText: string, - fuzzy: boolean = false, - ): Promise<{ x: number; y: number } | null> { - const t = performance.now(); - const { engine, screenPreprocess } = this.settings; - const pp = screenPreprocess !== 'none' ? screenPreprocess : undefined; - const result = await this.daemon.ocr(undefined, engine, pp); - const pos = this.findWordInOcrResult(result, searchText, fuzzy); - - if (pos) { - logger.info({ searchText, engine, x: pos.x, y: pos.y, totalMs: elapsed(t) }, 'Found text on screen'); - } else { - logger.info({ searchText, engine, totalMs: elapsed(t) }, 'Text not found on screen'); - } - return pos; - } - - async readFullScreen(): Promise { - const { engine, screenPreprocess } = this.settings; - const pp = screenPreprocess !== 'none' ? screenPreprocess : undefined; - const result = await this.daemon.ocr(undefined, engine, pp); - return result.text; - } - - // ── Region methods ────────────────────────────────────────────────── - - async findTextInRegion( - region: Region, - searchText: string, - ): Promise<{ x: number; y: number } | null> { - const t = performance.now(); - const { engine, screenPreprocess } = this.settings; - const pp = screenPreprocess !== 'none' ? screenPreprocess : undefined; - const result = await this.daemon.ocr(region, engine, pp); - const pos = this.findWordInOcrResult(result, searchText); - - if (pos) { - // Offset back to screen space - const screenPos = { x: region.x + pos.x, y: region.y + pos.y }; - logger.info({ searchText, x: screenPos.x, y: screenPos.y, region, totalMs: elapsed(t) }, 'Found text in region'); - return screenPos; - } - - logger.info({ searchText, region, totalMs: elapsed(t) }, 'Text not found in region'); - return null; - } - - async readRegionText(region: Region): Promise { - const { engine, screenPreprocess } = this.settings; - const pp = screenPreprocess !== 'none' ? screenPreprocess : undefined; - const result = await this.daemon.ocr(region, engine, pp); - return result.text; - } - - async checkForText(region: Region, searchText: string): Promise { - const pos = await this.findTextInRegion(region, searchText); - return pos !== null; - } - - // ── Snapshot / Diff-OCR (for tooltip reading) ────────────────────── - - async snapshot(): Promise { - if (this.settings.tooltipMethod === 'edge') return; // no reference frame needed - await this.daemon.snapshot(); - } - - async diffOcr(savePath?: string, region?: Region): Promise { - const { engine, tooltipPreprocess, tooltipMethod, tooltipParams, edgeParams } = this.settings; - const pp = tooltipPreprocess !== 'none' ? tooltipPreprocess : undefined; - if (tooltipMethod === 'edge') { - return this.daemon.edgeOcr(savePath, region, engine, pp, edgeParams); - } - return this.daemon.diffOcr(savePath, region, engine, pp, tooltipParams); - } - - // ── Template matching ────────────────────────────────────────────── - - async templateMatch(templatePath: string, region?: Region): Promise { - const t = performance.now(); - const result = await this.daemon.templateMatch(templatePath, region); - if (result) { - logger.info({ templatePath, x: result.x, y: result.y, confidence: result.confidence.toFixed(3), ms: elapsed(t) }, 'Template match found'); - } else { - logger.info({ templatePath, ms: elapsed(t) }, 'Template match not found'); - } - return result; - } - - // ── Save utilities ────────────────────────────────────────────────── - - async saveScreenshot(path: string): Promise { - await this.daemon.saveScreenshot(path); - logger.info({ path }, 'Screenshot saved'); - } - - async saveDebugScreenshots(dir: string): Promise { - await mkdir(dir, { recursive: true }); - const ts = Date.now(); - const originalPath = join(dir, `${ts}-screenshot.png`); - await this.daemon.saveScreenshot(originalPath); - logger.info({ dir, files: [originalPath.split(/[\\/]/).pop()] }, 'Debug screenshot saved'); - return [originalPath]; - } - - async saveRegion(region: Region, path: string): Promise { - await this.daemon.saveScreenshot(path, region); - logger.info({ path, region }, 'Region screenshot saved'); - } - - // ── Lifecycle ─────────────────────────────────────────────────────── - - async dispose(): Promise { - await this.daemon.stop(); - } -} diff --git a/src-old/game/WindowManager.ts b/src-old/game/WindowManager.ts deleted file mode 100644 index 7f3b083..0000000 --- a/src-old/game/WindowManager.ts +++ /dev/null @@ -1,90 +0,0 @@ -import koffi from 'koffi'; -import { logger } from '../util/logger.js'; - -// Win32 types -const HWND = 'int'; -const BOOL = 'bool'; -const RECT = koffi.struct('RECT', { - left: 'long', - top: 'long', - right: 'long', - bottom: 'long', -}); - -// Load user32.dll -const user32 = koffi.load('user32.dll'); - -const FindWindowW = user32.func('FindWindowW', HWND, ['str16', 'str16']); -const SetForegroundWindow = user32.func('SetForegroundWindow', BOOL, [HWND]); -const ShowWindow = user32.func('ShowWindow', BOOL, [HWND, 'int']); -const BringWindowToTop = user32.func('BringWindowToTop', BOOL, [HWND]); -const GetForegroundWindow = user32.func('GetForegroundWindow', HWND, []); -const GetWindowRect = user32.func('GetWindowRect', BOOL, [HWND, koffi.out(koffi.pointer(RECT))]); -const IsWindow = user32.func('IsWindow', BOOL, [HWND]); -const keybd_event = user32.func('keybd_event', 'void', ['uint8', 'uint8', 'uint32', 'uint']); -const MapVirtualKeyW = user32.func('MapVirtualKeyW', 'uint32', ['uint32', 'uint32']); - -// Constants -const SW_RESTORE = 9; -const VK_MENU = 0x12; // Alt key -const KEYEVENTF_KEYUP = 0x0002; - -export class WindowManager { - private hwnd: number = 0; - - constructor(private windowTitle: string) {} - - findWindow(): number { - this.hwnd = FindWindowW(null as unknown as string, this.windowTitle); - if (this.hwnd === 0) { - logger.warn({ title: this.windowTitle }, 'Window not found'); - } else { - logger.info({ title: this.windowTitle, hwnd: this.hwnd }, 'Window found'); - } - return this.hwnd; - } - - focusWindow(): boolean { - if (!this.hwnd || !IsWindow(this.hwnd)) { - this.findWindow(); - } - if (!this.hwnd) return false; - - // Restore if minimized - ShowWindow(this.hwnd, SW_RESTORE); - - // Alt-key trick to bypass SetForegroundWindow restriction - const altScan = MapVirtualKeyW(VK_MENU, 0); - keybd_event(VK_MENU, altScan, 0, 0); - keybd_event(VK_MENU, altScan, KEYEVENTF_KEYUP, 0); - - BringWindowToTop(this.hwnd); - const result = SetForegroundWindow(this.hwnd); - - if (!result) { - logger.warn('SetForegroundWindow failed'); - } - return result; - } - - getWindowRect(): { left: number; top: number; right: number; bottom: number } | null { - if (!this.hwnd || !IsWindow(this.hwnd)) { - this.findWindow(); - } - if (!this.hwnd) return null; - - const rect = { left: 0, top: 0, right: 0, bottom: 0 }; - const success = GetWindowRect(this.hwnd, rect); - if (!success) return null; - return rect; - } - - isGameFocused(): boolean { - const fg = GetForegroundWindow(); - return fg === this.hwnd && this.hwnd !== 0; - } - - getHwnd(): number { - return this.hwnd; - } -} diff --git a/src-old/index.ts b/src-old/index.ts deleted file mode 100644 index bcc34ea..0000000 --- a/src-old/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Command } from 'commander'; -import { loadConfig } from './config.js'; -import { Bot } from './bot/Bot.js'; -import { Server } from './server/Server.js'; -import { ConfigStore } from './bot/ConfigStore.js'; -import { logger } from './util/logger.js'; - -const program = new Command(); - -program - .name('poe2trade') - .description('POE2 automated trade bot') - .option('-u, --url ', 'Trade search URLs to monitor') - .option('--log-path ', 'Path to POE2 Client.txt') - .option('-p, --port ', 'Dashboard port') - .option('-c, --config ', 'Path to config.json', 'config.json') - .action(async (options) => { - const store = new ConfigStore(options.config); - const saved = store.settings; - - const envConfig = loadConfig(options.url); - if (options.logPath) envConfig.poe2LogPath = options.logPath; - - const config = { - ...envConfig, - poe2LogPath: options.logPath || saved.poe2LogPath, - poe2WindowTitle: saved.poe2WindowTitle, - browserUserDataDir: saved.browserUserDataDir, - travelTimeoutMs: saved.travelTimeoutMs, - stashScanTimeoutMs: saved.stashScanTimeoutMs, - waitForMoreItemsMs: saved.waitForMoreItemsMs, - betweenTradesDelayMs: saved.betweenTradesDelayMs, - }; - - const port = parseInt(options.port, 10) || saved.dashboardPort; - - const bot = new Bot(store, config); - const server = new Server(bot, port); - await server.start(); - await bot.start(config.tradeUrls, port); - - const shutdown = async () => { - logger.info('Shutting down...'); - await bot.stop(); - await server.stop(); - process.exit(0); - }; - - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - - logger.info(`Dashboard: http://localhost:${port}`); - }); - -program.parse(); diff --git a/src-old/inventory/InventoryManager.ts b/src-old/inventory/InventoryManager.ts deleted file mode 100644 index 88ef744..0000000 --- a/src-old/inventory/InventoryManager.ts +++ /dev/null @@ -1,316 +0,0 @@ -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-old/inventory/InventoryTracker.ts b/src-old/inventory/InventoryTracker.ts deleted file mode 100644 index a46b494..0000000 --- a/src-old/inventory/InventoryTracker.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { logger } from '../util/logger.js'; -import type { PostAction } from '../types.js'; - -const ROWS = 5; -const COLS = 12; - -export interface PlacedItem { - row: number; - col: number; - w: number; - h: number; - postAction: PostAction; -} - -export class InventoryTracker { - private grid: boolean[][]; - private items: PlacedItem[] = []; - - constructor() { - this.grid = Array.from({ length: ROWS }, () => Array(COLS).fill(false)); - } - - /** Initialize from a grid scan result (occupied cells + detected items). */ - 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); - } - this.items = []; - - // Mark occupied cells from scan - for (let r = 0; r < Math.min(cells.length, ROWS); r++) { - for (let c = 0; c < Math.min(cells[r].length, COLS); c++) { - this.grid[r][c] = cells[r][c]; - } - } - - // Record detected items, filtering out impossibly large ones (max POE2 item = 2×4) - for (const item of items) { - if (item.w > 2 || item.h > 4) { - logger.warn({ row: item.row, col: item.col, w: item.w, h: item.h }, 'Ignoring oversized item (false positive)'); - continue; - } - 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, 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, postAction); - logger.info({ row, col, w, h, postAction, free: this.freeCells }, 'Item placed in inventory'); - return { row, col }; - } - } - } - return null; - } - - /** Check if an item of size w×h can fit anywhere. */ - canFit(w: number, h: number): boolean { - for (let col = 0; col <= COLS - w; col++) { - for (let row = 0; row <= ROWS - h; row++) { - if (this.fits(row, col, w, h)) return true; - } - } - return false; - } - - /** Get all placed items. */ - getItems(): PlacedItem[] { - 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]); - } - - /** Clear entire grid. */ - clear(): void { - for (let r = 0; r < ROWS; r++) { - this.grid[r].fill(false); - } - this.items = []; - logger.info('Inventory cleared'); - } - - /** Get remaining free cells count. */ - get freeCells(): number { - let count = 0; - for (let r = 0; r < ROWS; r++) { - for (let c = 0; c < COLS; c++) { - if (!this.grid[r][c]) count++; - } - } - return count; - } - - private fits(row: number, col: number, w: number, h: number): boolean { - for (let r = row; r < row + h; r++) { - for (let c = col; c < col + w; c++) { - if (this.grid[r][c]) return false; - } - } - return true; - } - - 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, postAction }); - } -} diff --git a/src-old/log/ClientLogWatcher.ts b/src-old/log/ClientLogWatcher.ts deleted file mode 100644 index 6ebd5e3..0000000 --- a/src-old/log/ClientLogWatcher.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { EventEmitter } from 'events'; -import { watch } from 'chokidar'; -import { createReadStream, statSync, openSync, readSync, closeSync } from 'fs'; -import { createInterface } from 'readline'; -import { logger } from '../util/logger.js'; - -export interface LogEvents { - 'area-entered': (area: string) => void; - 'whisper-received': (data: { player: string; message: string }) => void; - 'whisper-sent': (data: { player: string; message: string }) => void; - 'trade-accepted': () => void; - 'party-joined': (player: string) => void; - 'party-left': (player: string) => void; - line: (line: string) => void; -} - -export class ClientLogWatcher extends EventEmitter { - private watcher: ReturnType | null = null; - private fileOffset: number = 0; - private logPath: string; - - /** Last area we transitioned into (from [SCENE] Set Source or "You have entered"). */ - currentArea: string = ''; - - constructor(logPath: string) { - super(); - this.logPath = logPath; - } - - async start(): Promise { - // Start reading from end of file (only new lines) - try { - const stats = statSync(this.logPath); - this.fileOffset = stats.size; - // Read tail of log to determine current area before we start watching - this.detectCurrentArea(stats.size); - } catch { - logger.warn({ path: this.logPath }, 'Log file not found yet, will watch for creation'); - this.fileOffset = 0; - } - - this.watcher = watch(this.logPath, { - persistent: true, - usePolling: true, - interval: 200, - }); - - this.watcher.on('change', () => { - this.readNewLines(); - }); - - logger.info({ path: this.logPath, currentArea: this.currentArea || '(unknown)' }, 'Watching Client.txt for game events'); - } - - /** Read the last chunk of the log file to determine the current area. */ - private detectCurrentArea(fileSize: number): void { - const TAIL_BYTES = 8192; - const start = Math.max(0, fileSize - TAIL_BYTES); - const buf = Buffer.alloc(Math.min(TAIL_BYTES, fileSize)); - const fd = openSync(this.logPath, 'r'); - try { - readSync(fd, buf, 0, buf.length, start); - } finally { - closeSync(fd); - } - const tail = buf.toString('utf-8'); - const lines = tail.split(/\r?\n/); - - // Walk backwards to find the most recent area transition - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i]; - const sceneMatch = line.match(/\[SCENE\] Set Source \[(.+?)\]/); - if (sceneMatch && sceneMatch[1] !== '(null)') { - this.currentArea = sceneMatch[1]; - logger.info({ area: this.currentArea }, 'Detected current area from log tail'); - return; - } - const areaMatch = line.match(/You have entered (.+?)\.?$/); - if (areaMatch) { - this.currentArea = areaMatch[1]; - logger.info({ area: this.currentArea }, 'Detected current area from log tail'); - return; - } - } - } - - private readNewLines(): void { - const stream = createReadStream(this.logPath, { - start: this.fileOffset, - encoding: 'utf-8', - }); - - const rl = createInterface({ input: stream }); - let bytesRead = 0; - - rl.on('line', (line) => { - bytesRead += Buffer.byteLength(line, 'utf-8') + 2; // +2 for \r\n on Windows - if (line.trim()) { - this.parseLine(line.trim()); - } - }); - - rl.on('close', () => { - this.fileOffset += bytesRead; - }); - } - - private parseLine(line: string): void { - this.emit('line', line); - - // Area transition: "[SCENE] Set Source [Shoreline Hideout]" - // POE2 uses this format instead of "You have entered ..." - const sceneMatch = line.match(/\[SCENE\] Set Source \[(.+?)\]/); - if (sceneMatch) { - const area = sceneMatch[1]; - // Skip the "(null)" transition — it's an intermediate state before the real area loads - if (area !== '(null)') { - this.currentArea = area; - logger.info({ area }, 'Area entered'); - this.emit('area-entered', area); - } - return; - } - - // Legacy fallback: "You have entered Hideout" - const areaMatch = line.match(/You have entered (.+?)\.?$/); - if (areaMatch) { - const area = areaMatch[1]; - this.currentArea = area; - logger.info({ area }, 'Area entered'); - this.emit('area-entered', area); - return; - } - - // Incoming whisper: "@From PlayerName: message" - const whisperFromMatch = line.match(/@From\s+(.+?):\s+(.+)$/); - if (whisperFromMatch) { - const data = { player: whisperFromMatch[1], message: whisperFromMatch[2] }; - logger.info(data, 'Whisper received'); - this.emit('whisper-received', data); - return; - } - - // Outgoing whisper: "@To PlayerName: message" - const whisperToMatch = line.match(/@To\s+(.+?):\s+(.+)$/); - if (whisperToMatch) { - const data = { player: whisperToMatch[1], message: whisperToMatch[2] }; - this.emit('whisper-sent', data); - return; - } - - // Party join: "PlayerName has joined the party" - const partyJoinMatch = line.match(/(.+?) has joined the party/); - if (partyJoinMatch) { - logger.info({ player: partyJoinMatch[1] }, 'Player joined party'); - this.emit('party-joined', partyJoinMatch[1]); - return; - } - - // Party leave: "PlayerName has left the party" - const partyLeaveMatch = line.match(/(.+?) has left the party/); - if (partyLeaveMatch) { - this.emit('party-left', partyLeaveMatch[1]); - return; - } - - // Trade accepted - if (line.includes('Trade accepted') || line.includes('Trade completed')) { - logger.info('Trade accepted/completed'); - this.emit('trade-accepted'); - return; - } - } - - async stop(): Promise { - if (this.watcher) { - await this.watcher.close(); - this.watcher = null; - } - logger.info('Client log watcher stopped'); - } -} diff --git a/src-old/server/Server.ts b/src-old/server/Server.ts deleted file mode 100644 index 1b8cc68..0000000 --- a/src-old/server/Server.ts +++ /dev/null @@ -1,97 +0,0 @@ -import express from 'express'; -import http from 'http'; -import { WebSocketServer, WebSocket } from 'ws'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { logger } from '../util/logger.js'; -import { statusRoutes } from './routes/status.js'; -import { controlRoutes } from './routes/control.js'; -import { linkRoutes } from './routes/links.js'; -import { debugRoutes } from './routes/debug.js'; -import type { Bot } from '../bot/Bot.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -export class Server { - private app = express(); - private server: http.Server; - private wss: WebSocketServer; - private clients: Set = new Set(); - private bot: Bot; - - constructor(bot: Bot, private port: number = 3000) { - this.bot = bot; - this.app.use(express.json()); - - this.app.get('/', (_req, res) => { - res.sendFile(path.join(__dirname, '..', '..', 'src', 'server', 'index.html')); - }); - - // Mount routes - this.app.use('/api', statusRoutes(bot)); - this.app.use('/api', controlRoutes(bot)); - this.app.use('/api/links', linkRoutes(bot)); - this.app.use('/api/debug', debugRoutes(bot, this)); - - this.server = http.createServer(this.app); - this.wss = new WebSocketServer({ server: this.server }); - - this.wss.on('connection', (ws) => { - this.clients.add(ws); - ws.send(JSON.stringify({ type: 'status', data: bot.getStatus() })); - ws.on('close', () => this.clients.delete(ws)); - }); - - // Subscribe to bot events - bot.on('status-update', () => this.broadcastStatus()); - bot.on('log', (level: string, message: string) => this.broadcastLog(level, message)); - } - - broadcastStatus(): void { - const msg = JSON.stringify({ type: 'status', data: this.bot.getStatus() }); - for (const client of this.clients) { - if (client.readyState === WebSocket.OPEN) { - client.send(msg); - } - } - } - - broadcastLog(level: string, message: string): void { - const msg = JSON.stringify({ - type: 'log', - data: { level, message, time: new Date().toISOString() }, - }); - for (const client of this.clients) { - if (client.readyState === WebSocket.OPEN) { - client.send(msg); - } - } - } - - broadcastDebug(action: string, data: Record): void { - const msg = JSON.stringify({ type: 'debug', data: { action, ...data } }); - for (const client of this.clients) { - if (client.readyState === WebSocket.OPEN) { - client.send(msg); - } - } - } - - async start(): Promise { - return new Promise((resolve) => { - this.server.listen(this.port, () => { - logger.info({ port: this.port }, `Dashboard running at http://localhost:${this.port}`); - resolve(); - }); - }); - } - - async stop(): Promise { - for (const client of this.clients) { - client.close(); - } - return new Promise((resolve) => { - this.server.close(() => resolve()); - }); - } -} diff --git a/src-old/server/index.html b/src-old/server/index.html deleted file mode 100644 index fd04e32..0000000 --- a/src-old/server/index.html +++ /dev/null @@ -1,1341 +0,0 @@ - - - - - -POE2 Trade Bot - - - -
-
-

POE2 Trade Bot

-
-
- - Connecting... -
- -
-
- -
-
-
IDLE
-
State
-
-
-
0
-
Active Links
-
-
-
0
-
Trades Done
-
-
-
0
-
Failed
-
-
- -
- -
- -
-
-
Inventory
- -
-
-
No active scrap session
-
-
- -
-
Trade Links
- - -
- -
-
Debug Tools
-
-
- - - - -
-
- - - -
-
- - - - -
-
- - - - - - -
-
- - - - -
-
- - - -
-
- - - -
-
-
-
- -
-
Activity Log
-
-
-
- - - - - - - - diff --git a/src-old/server/routes/control.ts b/src-old/server/routes/control.ts deleted file mode 100644 index ca87866..0000000 --- a/src-old/server/routes/control.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Router } from 'express'; -import type { Bot } from '../../bot/Bot.js'; - -export function controlRoutes(bot: Bot): Router { - const router = Router(); - - router.post('/pause', (_req, res) => { - bot.pause(); - res.json({ ok: true }); - }); - - router.post('/resume', (_req, res) => { - bot.resume(); - res.json({ ok: true }); - }); - - router.post('/settings', (req, res) => { - bot.updateSettings(req.body); - res.json({ ok: true }); - }); - - return router; -} diff --git a/src-old/server/routes/debug.ts b/src-old/server/routes/debug.ts deleted file mode 100644 index a3b8acd..0000000 --- a/src-old/server/routes/debug.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { Router } from 'express'; -import { mkdir } from 'fs/promises'; -import { logger } from '../../util/logger.js'; -import { sleep } from '../../util/sleep.js'; -import { GRID_LAYOUTS } from '../../game/GridReader.js'; -import type { Bot } from '../../bot/Bot.js'; -import type { Server } from '../Server.js'; -import type { OcrEngine, OcrPreprocess, DiffOcrParams, TooltipMethod, EdgeOcrParams } from '../../game/OcrDaemon.js'; -import type { OcrSettings } from '../../game/ScreenReader.js'; - -export function debugRoutes(bot: Bot, server: Server): Router { - const router = Router(); - - const notReady = (_req: any, res: any): boolean => { - if (!bot.isReady) { res.status(503).json({ error: 'Not ready' }); return true; } - return false; - }; - - // --- Sync: OCR settings --- - - router.get('/ocr-settings', (req, res) => { - if (notReady(req, res)) return; - res.json({ ok: true, ...bot.screenReader.settings }); - }); - - router.post('/ocr-settings', (req, res) => { - if (notReady(req, res)) return; - const body = req.body as Partial; - const s = bot.screenReader.settings; - if (body.engine && ['tesseract', 'easyocr', 'paddleocr'].includes(body.engine)) s.engine = body.engine; - if (body.screenPreprocess && ['none', 'bgsub', 'tophat'].includes(body.screenPreprocess)) s.screenPreprocess = body.screenPreprocess; - if (body.tooltipPreprocess && ['none', 'bgsub', 'tophat'].includes(body.tooltipPreprocess)) s.tooltipPreprocess = body.tooltipPreprocess; - if (body.tooltipMethod && ['diff', 'edge'].includes(body.tooltipMethod)) s.tooltipMethod = body.tooltipMethod; - if (body.tooltipParams != null) s.tooltipParams = body.tooltipParams; - if (body.edgeParams != null) s.edgeParams = body.edgeParams; - if (body.saveDebugImages != null) s.saveDebugImages = body.saveDebugImages; - server.broadcastLog('info', `OCR settings updated: engine=${s.engine} screen=${s.screenPreprocess} tooltip=${s.tooltipPreprocess}`); - res.json({ ok: true }); - }); - - // --- Fire-and-forget: slow debug operations --- - - router.post('/screenshot', (req, res) => { - if (notReady(req, res)) return; - res.json({ ok: true }); - bot.screenReader.saveDebugScreenshots('debug-screenshots').then(files => { - server.broadcastLog('info', `Debug screenshots saved: ${files.map(f => f.split(/[\\/]/).pop()).join(', ')}`); - server.broadcastDebug('screenshot', { files }); - }).catch(err => { - logger.error({ err }, 'Debug screenshot failed'); - server.broadcastDebug('screenshot', { error: 'Screenshot failed' }); - }); - }); - - router.post('/ocr', (req, res) => { - if (notReady(req, res)) return; - res.json({ ok: true }); - bot.screenReader.readFullScreen().then(text => { - server.broadcastLog('info', `OCR [${bot.screenReader.settings.engine}] (${text.length} chars): ${text.substring(0, 200)}`); - server.broadcastDebug('ocr', { text }); - }).catch(err => { - logger.error({ err }, 'Debug OCR failed'); - server.broadcastDebug('ocr', { error: 'OCR failed' }); - }); - }); - - router.post('/find-text', (req, res) => { - if (notReady(req, res)) return; - const { text } = req.body as { text: string }; - if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; } - res.json({ ok: true }); - bot.screenReader.findTextOnScreen(text).then(pos => { - if (pos) { - server.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) [${bot.screenReader.settings.engine}]`); - } else { - server.broadcastLog('warn', `"${text}" not found on screen [${bot.screenReader.settings.engine}]`); - } - server.broadcastDebug('find-text', { searchText: text, found: !!pos, position: pos }); - }).catch(err => { - logger.error({ err }, 'Debug find-text failed'); - server.broadcastDebug('find-text', { searchText: text, error: 'Find text failed' }); - }); - }); - - router.post('/find-and-click', (req, res) => { - if (notReady(req, res)) return; - const { text, fuzzy } = req.body as { text: string; fuzzy?: boolean }; - if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; } - res.json({ ok: true }); - (async () => { - const pos = await bot.screenReader.findTextOnScreen(text, !!fuzzy); - if (pos) { - await bot.gameController.focusGame(); - await bot.gameController.leftClickAt(pos.x, pos.y); - server.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) and clicked [${bot.screenReader.settings.engine}]`); - server.broadcastDebug('find-and-click', { searchText: text, found: true, position: pos }); - } else { - server.broadcastLog('warn', `"${text}" not found on screen [${bot.screenReader.settings.engine}]`); - server.broadcastDebug('find-and-click', { searchText: text, found: false, position: null }); - } - })().catch(err => { - logger.error({ err }, 'Debug find-and-click failed'); - server.broadcastDebug('find-and-click', { searchText: text, error: 'Find and click failed' }); - }); - }); - - router.post('/click', (req, res) => { - if (notReady(req, res)) return; - const { x, y } = req.body as { x: number; y: number }; - if (x == null || y == null) { res.status(400).json({ error: 'Missing x/y' }); return; } - res.json({ ok: true }); - (async () => { - await bot.gameController.focusGame(); - await bot.gameController.leftClickAt(x, y); - server.broadcastLog('info', `Clicked at (${x}, ${y})`); - server.broadcastDebug('click', { x, y }); - })().catch(err => { - logger.error({ err }, 'Debug click failed'); - server.broadcastDebug('click', { x, y, error: 'Click failed' }); - }); - }); - - router.post('/hideout', (req, res) => { - if (notReady(req, res)) return; - res.json({ ok: true }); - (async () => { - await bot.gameController.focusGame(); - await bot.gameController.goToHideout(); - server.broadcastLog('info', 'Sent /hideout command'); - server.broadcastDebug('hideout', {}); - })().catch(err => { - logger.error({ err }, 'Debug hideout failed'); - server.broadcastDebug('hideout', { error: 'Hideout command failed' }); - }); - }); - - router.post('/click-then-click', (req, res) => { - if (notReady(req, res)) return; - const { first, second, timeout = 5000 } = req.body as { first: string; second: string; timeout?: number }; - if (!first || !second) { res.status(400).json({ error: 'Missing first/second' }); return; } - res.json({ ok: true }); - (async () => { - const pos1 = await bot.screenReader.findTextOnScreen(first); - if (!pos1) { - server.broadcastLog('warn', `"${first}" not found on screen`); - server.broadcastDebug('click-then-click', { first, second, found: false, step: 'first' }); - return; - } - await bot.gameController.focusGame(); - await bot.gameController.leftClickAt(pos1.x, pos1.y); - server.broadcastLog('info', `Clicked "${first}" at (${pos1.x}, ${pos1.y}), waiting for "${second}"...`); - - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const pos2 = await bot.screenReader.findTextOnScreen(second); - if (pos2) { - await bot.gameController.leftClickAt(pos2.x, pos2.y); - server.broadcastLog('info', `Clicked "${second}" at (${pos2.x}, ${pos2.y})`); - server.broadcastDebug('click-then-click', { first, second, found: true, position: pos2 }); - return; - } - } - server.broadcastLog('warn', `"${second}" not found after clicking "${first}" (timed out)`); - server.broadcastDebug('click-then-click', { first, second, found: false, step: 'second' }); - })().catch(err => { - logger.error({ err }, 'Debug click-then-click failed'); - server.broadcastDebug('click-then-click', { first, second, error: 'Click-then-click failed' }); - }); - }); - - router.post('/grid-scan', (req, res) => { - if (notReady(req, res)) return; - const { layout: layoutName, targetRow, targetCol } = req.body as { layout: string; targetRow?: number; targetCol?: number }; - if (!GRID_LAYOUTS[layoutName]) { res.status(400).json({ error: `Unknown grid layout: ${layoutName}` }); return; } - res.json({ ok: true }); - (async () => { - const result = await bot.screenReader.grid.scan(layoutName, undefined, targetRow, targetCol); - const imageBuffer = await bot.screenReader.captureRegion(result.layout.region); - const imageBase64 = imageBuffer.toString('base64'); - const r = result.layout.region; - const matchInfo = result.matches ? `, ${result.matches.length} matches` : ''; - server.broadcastLog('info', - `Grid scan (${layoutName}): ${result.layout.cols}x${result.layout.rows} at (${r.x},${r.y}) ${r.width}x${r.height} — ${result.occupied.length} occupied cells${matchInfo}`); - server.broadcastDebug('grid-scan', { - layout: layoutName, - occupied: result.occupied, - items: result.items, - matches: result.matches, - cols: result.layout.cols, - rows: result.layout.rows, - image: imageBase64, - region: result.layout.region, - targetRow, - targetCol, - }); - })().catch(err => { - logger.error({ err }, 'Debug grid-scan failed'); - server.broadcastDebug('grid-scan', { layout: layoutName, error: 'Grid scan failed' }); - }); - }); - - router.post('/test-match-hover', (req, res) => { - if (notReady(req, res)) return; - const { layout: layoutName, targetRow, targetCol } = req.body as { layout: string; targetRow: number; targetCol: number }; - if (!GRID_LAYOUTS[layoutName]) { res.status(400).json({ error: `Unknown layout: ${layoutName}` }); return; } - if (targetRow == null || targetCol == null) { res.status(400).json({ error: 'Missing targetRow/targetCol' }); return; } - res.json({ ok: true }); - (async () => { - server.broadcastLog('info', `Scanning ${layoutName} with target (${targetRow},${targetCol})...`); - const result = await bot.screenReader.grid.scan(layoutName, undefined, targetRow, targetCol); - const matches = result.matches ?? []; - const items = result.items ?? []; - - const targetItem = items.find(i => targetRow >= i.row && targetRow < i.row + i.h && targetCol >= i.col && targetCol < i.col + i.w); - const itemSize = targetItem ? `${targetItem.w}x${targetItem.h}` : '1x1'; - server.broadcastLog('info', `Target (${targetRow},${targetCol}) is ${itemSize}, found ${matches.length} matches`); - - const hoverCells = [ - { row: targetRow, col: targetCol, label: 'TARGET' }, - ...matches.map(m => ({ row: m.row, col: m.col, label: `MATCH ${(m.similarity * 100).toFixed(0)}%` })), - ]; - - await bot.gameController.focusGame(); - const saveImages = bot.screenReader.settings.saveDebugImages; - if (saveImages) await mkdir('items', { recursive: true }); - const tooltips: Array<{ row: number; col: number; label: string; text: string }> = []; - const ts = Date.now(); - const reg = result.layout.region; - const cellW = reg.width / result.layout.cols; - const cellH = reg.height / result.layout.rows; - - // Move mouse to empty space and take reference snapshot - bot.gameController.moveMouseInstant(reg.x + reg.width + 50, reg.y + reg.height / 2); - await sleep(50); - await bot.screenReader.snapshot(); - if (saveImages) await bot.screenReader.saveScreenshot(`items/${ts}_snapshot.png`); - await sleep(200); - - for (const cell of hoverCells) { - const cellStart = performance.now(); - const x = Math.round(reg.x + cell.col * cellW + cellW / 2); - const y = Math.round(reg.y + cell.row * cellH + cellH / 2); - - await bot.gameController.moveMouseFast(x, y); - await sleep(50); - const afterMove = performance.now(); - - const imgPath = saveImages ? `items/${ts}_${cell.row}-${cell.col}.png` : undefined; - const diff = await bot.screenReader.diffOcr(imgPath); - const afterOcr = performance.now(); - const text = diff.text.trim(); - - const regionInfo = diff.region ? ` at (${diff.region.x},${diff.region.y}) ${diff.region.width}x${diff.region.height}` : ''; - tooltips.push({ row: cell.row, col: cell.col, label: cell.label, text }); - - server.broadcastLog('info', - `${cell.label} (${cell.row},${cell.col}) [move: ${(afterMove - cellStart).toFixed(0)}ms, ocr: ${(afterOcr - afterMove).toFixed(0)}ms, total: ${(afterOcr - cellStart).toFixed(0)}ms]${regionInfo}:`); - if (diff.lines.length > 0) { - for (const line of diff.lines) { - server.broadcastLog('info', ` ${line.text}`); - } - } else if (text) { - for (const line of text.split('\n')) { - if (line.trim()) server.broadcastLog('info', ` ${line.trim()}`); - } - } - } - - server.broadcastLog('info', `Done — hovered ${hoverCells.length} cells, read ${tooltips.filter(t => t.text).length} tooltips`); - server.broadcastDebug('test-match-hover', { - itemSize, - matchCount: matches.length, - hoveredCount: hoverCells.length, - tooltips, - }); - })().catch(err => { - logger.error({ err }, 'Debug test-match-hover failed'); - server.broadcastDebug('test-match-hover', { error: 'Test match hover failed' }); - }); - }); - - return router; -} diff --git a/src-old/server/routes/links.ts b/src-old/server/routes/links.ts deleted file mode 100644 index 317e1e9..0000000 --- a/src-old/server/routes/links.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Router } from 'express'; -import type { Bot } from '../../bot/Bot.js'; - -export function linkRoutes(bot: Bot): Router { - const router = Router(); - - router.post('/', (req, res) => { - const { url, name, mode } = req.body as { url: string; name?: string; mode?: string }; - if (!url || !url.includes('pathofexile.com/trade')) { - res.status(400).json({ error: 'Invalid trade URL' }); - return; - } - const linkMode = mode === 'scrap' ? 'scrap' : 'live'; - bot.addLink(url, name || '', linkMode); - res.json({ ok: true }); - }); - - router.delete('/:id', (req, res) => { - bot.removeLink(req.params.id); - res.json({ ok: true }); - }); - - router.post('/:id/toggle', (req, res) => { - const { active } = req.body as { active: boolean }; - bot.toggleLink(req.params.id, active); - res.json({ ok: true }); - }); - - router.post('/:id/name', (req, res) => { - const { name } = req.body as { name: string }; - bot.updateLinkName(req.params.id, name); - res.json({ ok: true }); - }); - - router.post('/:id/mode', (req, res) => { - const { mode } = req.body as { mode: string }; - if (mode !== 'live' && mode !== 'scrap') { - res.status(400).json({ error: 'Invalid mode. Must be "live" or "scrap".' }); - return; - } - bot.updateLinkMode(req.params.id, mode); - res.json({ ok: true }); - }); - - router.post('/: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; - } - bot.updateLinkPostAction(req.params.id, postAction); - res.json({ ok: true }); - }); - - return router; -} diff --git a/src-old/server/routes/status.ts b/src-old/server/routes/status.ts deleted file mode 100644 index 9e95c0e..0000000 --- a/src-old/server/routes/status.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Router } from 'express'; -import type { Bot } from '../../bot/Bot.js'; - -export function statusRoutes(bot: Bot): Router { - const router = Router(); - - router.get('/status', (_req, res) => { - res.json(bot.getStatus()); - }); - - return router; -} diff --git a/src-old/trade/TradeMonitor.ts b/src-old/trade/TradeMonitor.ts deleted file mode 100644 index 0b06919..0000000 --- a/src-old/trade/TradeMonitor.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { EventEmitter } from 'events'; -import { chromium, type Browser, type BrowserContext, type Page, type WebSocket } from 'playwright'; -import { SELECTORS } from './selectors.js'; -import { logger } from '../util/logger.js'; -import { sleep } from '../util/sleep.js'; -import type { Config, TradeItem } from '../types.js'; - -// Stealth JS injected into every page to avoid Playwright detection -const STEALTH_SCRIPT = ` - // Remove navigator.webdriver flag - Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); - - // Fake plugins array (empty = headless giveaway) - Object.defineProperty(navigator, 'plugins', { - get: () => [ - { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' }, - { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' }, - { name: 'Native Client', filename: 'internal-nacl-plugin' }, - ], - }); - - // Fake languages - Object.defineProperty(navigator, 'languages', { - get: () => ['en-US', 'en'], - }); - - // Remove Playwright/automation artifacts from window - delete window.__playwright; - delete window.__pw_manual; - - // Fix chrome.runtime to look like a real browser - if (!window.chrome) window.chrome = {}; - if (!window.chrome.runtime) window.chrome.runtime = { id: undefined }; - - // Prevent detection via permissions API - const originalQuery = window.navigator.permissions?.query; - if (originalQuery) { - window.navigator.permissions.query = (params) => { - if (params.name === 'notifications') { - return Promise.resolve({ state: Notification.permission }); - } - return originalQuery(params); - }; - } -`; - -export class TradeMonitor extends EventEmitter { - private browser: Browser | null = null; - private context: BrowserContext | null = null; - private pages: Map = new Map(); - private pausedSearches: Set = new Set(); - private config: Config; - - constructor(config: Config) { - super(); - this.config = config; - } - - async start(dashboardUrl?: string): Promise { - logger.info('Launching Playwright browser (stealth mode)...'); - - this.context = await chromium.launchPersistentContext(this.config.browserUserDataDir, { - headless: false, - viewport: null, - args: [ - '--disable-blink-features=AutomationControlled', - '--disable-features=AutomationControlled', - '--no-first-run', - '--no-default-browser-check', - '--disable-infobars', - ], - ignoreDefaultArgs: ['--enable-automation'], - }); - - // Inject stealth script into all pages (current and future) - await this.context.addInitScript(STEALTH_SCRIPT); - - // Open dashboard as the first tab - if (dashboardUrl) { - const pages = this.context.pages(); - if (pages.length > 0) { - await pages[0].goto(dashboardUrl); - } else { - const page = await this.context.newPage(); - await page.goto(dashboardUrl); - } - logger.info({ dashboardUrl }, 'Dashboard opened in browser'); - } - - logger.info('Browser launched (stealth active).'); - } - - async addSearch(tradeUrl: string): Promise { - if (!this.context) throw new Error('Browser not started'); - - const searchId = this.extractSearchId(tradeUrl); - - // Don't add duplicate - if (this.pages.has(searchId)) { - logger.info({ searchId }, 'Search already open, skipping'); - return; - } - - logger.info({ tradeUrl, searchId }, 'Adding trade search'); - - const page = await this.context.newPage(); - this.pages.set(searchId, page); - - await page.goto(tradeUrl, { waitUntil: 'networkidle' }); - await sleep(2000); - - // Listen for WebSocket connections (must be registered before clicking live search) - page.on('websocket', (ws: WebSocket) => { - this.handleWebSocket(ws, searchId, page); - }); - - // Click the "Activate Live Search" button - try { - const liveBtn = page.locator(SELECTORS.liveSearchButton).first(); - await liveBtn.click({ timeout: 5000 }); - logger.info({ searchId }, 'Live search activated'); - } catch { - logger.warn({ searchId }, 'Could not click Activate Live Search button'); - } - - logger.info({ searchId }, 'Trade search monitoring active'); - } - - async pauseSearch(searchId: string): Promise { - this.pausedSearches.add(searchId); - // Close the page to stop the WebSocket / live search - const page = this.pages.get(searchId); - if (page) { - await page.close(); - this.pages.delete(searchId); - } - logger.info({ searchId }, 'Search paused (page closed)'); - } - - async resumeSearch(tradeUrl: string): Promise { - const searchId = this.extractSearchId(tradeUrl); - this.pausedSearches.delete(searchId); - await this.addSearch(tradeUrl); - logger.info({ searchId }, 'Search resumed'); - } - - isSearchActive(searchId: string): boolean { - return this.pages.has(searchId) && !this.pausedSearches.has(searchId); - } - - private handleWebSocket(ws: WebSocket, searchId: string, page: Page): void { - const url = ws.url(); - - if (!url.includes('/api/trade') || !url.includes('/live/')) { - return; - } - - logger.info({ url, searchId }, 'WebSocket connected for live search'); - - ws.on('framereceived', (frame) => { - // Don't emit if this search is paused - if (this.pausedSearches.has(searchId)) return; - - try { - const payload = typeof frame.payload === 'string' ? frame.payload : frame.payload.toString(); - const data = JSON.parse(payload); - - if (data.new && Array.isArray(data.new) && data.new.length > 0) { - logger.info({ searchId, itemCount: data.new.length, itemIds: data.new }, 'New listings detected!'); - this.emit('new-listings', { - searchId, - itemIds: data.new as string[], - page, - }); - } - } catch { - // Not all frames are JSON - } - }); - - ws.on('close', () => { - logger.warn({ searchId }, 'WebSocket closed'); - }); - - ws.on('socketerror', (err) => { - logger.error({ searchId, err }, 'WebSocket error'); - }); - } - - async clickTravelToHideout(page: Page, itemId?: string): Promise { - try { - if (itemId) { - const row = page.locator(SELECTORS.listingById(itemId)); - if (await row.isVisible({ timeout: 5000 })) { - const travelBtn = row.locator(SELECTORS.travelToHideoutButton).first(); - if (await travelBtn.isVisible({ timeout: 3000 })) { - await travelBtn.click(); - logger.info({ itemId }, 'Clicked Travel to Hideout for specific item'); - await this.handleConfirmDialog(page); - return true; - } - } - } - - const travelBtn = page.locator(SELECTORS.travelToHideoutButton).first(); - await travelBtn.click({ timeout: 5000 }); - logger.info('Clicked Travel to Hideout'); - await this.handleConfirmDialog(page); - return true; - } catch (err) { - logger.error({ err }, 'Failed to click Travel to Hideout'); - return false; - } - } - - private async handleConfirmDialog(page: Page): Promise { - await sleep(500); - try { - const confirmBtn = page.locator(SELECTORS.confirmYesButton).first(); - if (await confirmBtn.isVisible({ timeout: 2000 })) { - await confirmBtn.click(); - logger.info('Confirmed "Are you sure?" dialog'); - } - } catch { - // No dialog - } - } - - async openScrapPage(tradeUrl: string): Promise<{ page: Page; items: TradeItem[] }> { - if (!this.context) throw new Error('Browser not started'); - - const page = await this.context.newPage(); - const items: TradeItem[] = []; - - page.on('response', async (response) => { - if (response.url().includes('/api/trade2/fetch/')) { - try { - const json = await response.json(); - if (json.result && Array.isArray(json.result)) { - for (const r of json.result) { - items.push({ - id: r.id, - w: r.item?.w ?? 1, - h: r.item?.h ?? 1, - stashX: r.listing?.stash?.x ?? 0, - stashY: r.listing?.stash?.y ?? 0, - account: r.listing?.account?.name ?? '', - }); - } - } - } catch { - // Response may not be JSON - } - } - }); - - await page.goto(tradeUrl, { waitUntil: 'networkidle' }); - await sleep(2000); // ensure API response received - logger.info({ url: tradeUrl, itemCount: items.length }, 'Scrap page opened'); - return { page, items }; - } - - extractSearchId(url: string): string { - const cleaned = url.replace(/\/live\/?$/, ''); - const parts = cleaned.split('/'); - return parts[parts.length - 1] || url; - } - - async removeSearch(searchId: string): Promise { - this.pausedSearches.delete(searchId); - const page = this.pages.get(searchId); - if (page) { - await page.close(); - this.pages.delete(searchId); - logger.info({ searchId }, 'Trade search removed'); - } - } - - async stop(): Promise { - for (const [id, page] of this.pages) { - await page.close(); - this.pages.delete(id); - } - if (this.context) { - await this.context.close(); - this.context = null; - } - logger.info('Trade monitor stopped'); - } -} diff --git a/src-old/trade/selectors.ts b/src-old/trade/selectors.ts deleted file mode 100644 index 1606430..0000000 --- a/src-old/trade/selectors.ts +++ /dev/null @@ -1,30 +0,0 @@ -// CSS selectors for the POE2 trade website (pathofexile.com/trade2) -// These need to be verified against the live site and updated if the site changes. - -export const SELECTORS = { - // Live search activation button - liveSearchButton: 'button.livesearch-btn, button:has-text("Activate Live Search")', - - // Individual listing rows - listingRow: '.resultset .row, [class*="result"]', - - // Listing by item ID - listingById: (id: string) => `[data-id="${id}"]`, - - // "Travel to Hideout" / "Visit Hideout" button on a listing - travelToHideoutButton: - 'button:has-text("Travel to Hideout"), button:has-text("Visit Hideout"), a:has-text("Travel to Hideout"), [class*="hideout"]', - - // Whisper / copy button on a listing - whisperButton: - '.whisper-btn, button[class*="whisper"], [data-tooltip="Whisper"], button:has-text("Whisper")', - - // "Are you sure?" confirmation dialog - confirmDialog: '[class*="modal"], [class*="dialog"], [class*="confirm"]', - confirmYesButton: - 'button:has-text("Yes"), button:has-text("Confirm"), button:has-text("OK"), button:has-text("Accept")', - confirmNoButton: 'button:has-text("No"), button:has-text("Cancel"), button:has-text("Decline")', - - // Search results container - resultsContainer: '.resultset, [class*="results"]', -} as const; diff --git a/src-old/types.ts b/src-old/types.ts deleted file mode 100644 index 634c75c..0000000 --- a/src-old/types.ts +++ /dev/null @@ -1,73 +0,0 @@ -export interface Config { - tradeUrls: string[]; - poe2LogPath: string; - poe2WindowTitle: string; - browserUserDataDir: string; - travelTimeoutMs: number; - stashScanTimeoutMs: number; - waitForMoreItemsMs: number; - betweenTradesDelayMs: number; -} - -export interface Region { - x: number; - y: number; - width: number; - height: number; -} - -export interface ScreenRegions { - stashArea: Region; - priceWarningDialog: Region; - priceWarningNoButton: Region; - inventoryArea: Region; - stashTabArea: Region; -} - -export interface TradeInfo { - searchId: string; - itemIds: string[]; - whisperText: string; - timestamp: number; - tradeUrl: string; - page: unknown; // Playwright Page reference -} - -export interface StashItem { - name: string; - stats: string; - price: string; - position: { x: number; y: number }; -} - -export type TradeState = - | 'IDLE' - | 'TRAVELING' - | 'IN_SELLERS_HIDEOUT' - | 'SCANNING_STASH' - | 'BUYING' - | 'WAITING_FOR_MORE' - | 'GOING_HOME' - | 'IN_HIDEOUT' - | 'FAILED'; - -export interface LogEvent { - timestamp: Date; - type: 'area-entered' | 'whisper-received' | 'trade-accepted' | 'unknown'; - data: Record; -} - -export type LinkMode = 'live' | 'scrap'; - -export type PostAction = 'stash' | 'salvage'; - -export type ScrapState = 'IDLE' | 'TRAVELING' | 'BUYING' | 'SALVAGING' | 'STORING' | 'FAILED'; - -export interface TradeItem { - id: string; - w: number; - h: number; - stashX: number; - stashY: number; - account: string; -} diff --git a/src-old/util/clipboard.ts b/src-old/util/clipboard.ts deleted file mode 100644 index 7859549..0000000 --- a/src-old/util/clipboard.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { execSync } from 'child_process'; - -export function readClipboard(): string { - try { - return execSync('powershell -command "Get-Clipboard"', { encoding: 'utf-8' }).trim(); - } catch { - return ''; - } -} - -export function writeClipboard(text: string): void { - execSync(`powershell -command "Set-Clipboard -Value '${text.replace(/'/g, "''")}'"`); -} diff --git a/src-old/util/logger.ts b/src-old/util/logger.ts deleted file mode 100644 index ea7a2ee..0000000 --- a/src-old/util/logger.ts +++ /dev/null @@ -1,12 +0,0 @@ -import pino from 'pino'; - -export const logger = pino({ - transport: { - target: 'pino-pretty', - options: { - colorize: true, - translateTime: 'HH:MM:ss', - ignore: 'pid,hostname', - }, - }, -}); diff --git a/src-old/util/retry.ts b/src-old/util/retry.ts deleted file mode 100644 index 94b2b41..0000000 --- a/src-old/util/retry.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { sleep } from './sleep.js'; -import { logger } from './logger.js'; - -export async function retry( - fn: () => Promise, - options: { maxAttempts?: number; delayMs?: number; label?: string } = {}, -): Promise { - const { maxAttempts = 3, delayMs = 1000, label = 'operation' } = options; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await fn(); - } catch (err) { - if (attempt === maxAttempts) { - logger.error({ err, attempt, label }, `${label} failed after ${maxAttempts} attempts`); - throw err; - } - logger.warn({ err, attempt, label }, `${label} failed, retrying in ${delayMs}ms...`); - await sleep(delayMs * attempt); - } - } - - throw new Error('Unreachable'); -} diff --git a/src-old/util/sleep.ts b/src-old/util/sleep.ts deleted file mode 100644 index ad51fa3..0000000 --- a/src-old/util/sleep.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export function randomDelay(minMs: number, maxMs: number): Promise { - const delay = minMs + Math.random() * (maxMs - minMs); - return sleep(delay); -} diff --git a/tools/test-easyocr.js b/tools/test-easyocr.js deleted file mode 100644 index 49b247b..0000000 --- a/tools/test-easyocr.js +++ /dev/null @@ -1,104 +0,0 @@ -import { spawn } from 'child_process'; -import { join } from 'path'; - -const EXE = join('tools', 'OcrDaemon', 'bin', 'Release', 'net8.0-windows10.0.19041.0', 'OcrDaemon.exe'); -const TESSDATA = join('tools', 'OcrDaemon', 'bin', 'Release', 'net8.0-windows10.0.19041.0', 'tessdata'); -const SAVE_DIR = join('tools', 'OcrDaemon', 'bin', 'Release', 'net8.0-windows10.0.19041.0', 'tessdata', 'images'); - -const expected = { - vertex1: [ - 'The Vertex', 'Tribal Mask', 'Helmet', 'Quality: +20%', - 'Evasion Rating: 79', 'Energy Shield: 34', 'Requires: Level 33', - '16% Increased Life Regeneration Rate', 'Has no Attribute Requirements', - '+15% to Chaos Resistance', 'Skill gems have no attribute requirements', - '+3 to level of all skills', '15% increased mana cost efficiency', - 'Twice Corrupted', 'Asking Price:', '7x Divine Orb', - ], - vertex2: [ - 'The Vertex', 'Tribal Mask', 'Helmet', 'Quality: +20%', - 'Evasion Rating: 182', 'Energy Shield: 77', 'Requires: Level 33', - '+29 To Spirit', '+1 to Level of All Minion Skills', - 'Has no Attribute Requirements', '130% increased Evasion and Energy Shield', - '27% Increased Critical Hit Chance', '+13% to Chaos Resistance', - '+2 to level of all skills', 'Twice Corrupted', 'Asking Price:', '35x Divine Orb', - ], -}; - -function levenshteinSim(a, b) { - a = a.toLowerCase(); b = b.toLowerCase(); - if (a === b) return 1; - const la = a.length, lb = b.length; - if (!la || !lb) return 0; - const d = Array.from({ length: la + 1 }, (_, i) => { const r = new Array(lb + 1); r[0] = i; return r; }); - for (let j = 0; j <= lb; j++) d[0][j] = j; - for (let i = 1; i <= la; i++) - for (let j = 1; j <= lb; j++) { - const cost = a[i-1] === b[j-1] ? 0 : 1; - d[i][j] = Math.min(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+cost); - } - return 1 - d[la][lb] / Math.max(la, lb); -} - -async function run() { - const proc = spawn(EXE, [], { stdio: ['pipe', 'pipe', 'pipe'] }); - let buffer = ''; - let resolveNext; - proc.stdout.on('data', (data) => { - buffer += data.toString(); - let idx; - while ((idx = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, idx).trim(); - buffer = buffer.slice(idx + 1); - if (!line) continue; - try { const p = JSON.parse(line); if (resolveNext) { const r = resolveNext; resolveNext = null; r(p); } } catch {} - } - }); - proc.stderr.on('data', (data) => process.stderr.write(data)); - function sendCmd(cmd) { return new Promise((resolve) => { resolveNext = resolve; proc.stdin.write(JSON.stringify(cmd) + '\n'); }); } - await new Promise((resolve) => { resolveNext = resolve; }); - - const cases = [ - { id: 'vertex1', image: 'vertex1.png', snapshot: 'vertex-snapshot.png' }, - { id: 'vertex2', image: 'vertex2.png', snapshot: 'vertex-snapshot.png' }, - ]; - - for (const tc of cases) { - const snapPath = join(TESSDATA, 'images', tc.snapshot); - const imgPath = join(TESSDATA, 'images', tc.image); - - // 3 runs: first saves crop, rest just timing - for (let i = 0; i < 3; i++) { - await sendCmd({ cmd: 'snapshot', file: snapPath }); - const savePath = i === 0 ? join(SAVE_DIR, `${tc.id}_easyocr_crop.png`) : undefined; - const t0 = performance.now(); - const resp = await sendCmd({ cmd: 'diff-ocr', file: imgPath, engine: 'easyocr', ...(savePath ? { path: savePath } : {}) }); - const ms = (performance.now() - t0).toFixed(0); - const region = resp.region; - const lines = (resp.lines || []).map(l => l.text.trim()).filter(l => l.length > 0); - - if (i === 0) { - // Accuracy check on first run - const exp = expected[tc.id]; - const used = new Set(); - let matched = 0, fuzzy = 0, missed = 0; - for (const e of exp) { - let bestIdx = -1, bestSim = 0; - for (let j = 0; j < lines.length; j++) { - if (used.has(j)) continue; - const sim = levenshteinSim(e, lines[j]); - if (sim > bestSim) { bestSim = sim; bestIdx = j; } - } - if (bestIdx >= 0 && bestSim >= 0.75) { used.add(bestIdx); if (bestSim >= 0.95) matched++; else fuzzy++; } - else { missed++; console.log(` MISS: ${e}${bestIdx >= 0 ? ` (best: "${lines[bestIdx]}", sim=${bestSim.toFixed(2)})` : ''}`); } - } - console.log(`${tc.id}: ${ms}ms crop=${region?.width}x${region?.height} at (${region?.x},${region?.y}) ${matched} OK / ${fuzzy}~ / ${missed} miss lines=${lines.length}${savePath ? ' [saved]' : ''}`); - } else { - console.log(`${tc.id}: ${ms}ms crop=${region?.width}x${region?.height}`); - } - } - console.log(); - } - proc.stdin.end(); - proc.kill(); -} -run().catch(console.error); diff --git a/tools/test-ocr.ts b/tools/test-ocr.ts deleted file mode 100644 index 9e8f5fc..0000000 --- a/tools/test-ocr.ts +++ /dev/null @@ -1,484 +0,0 @@ -/** - * OCR test runner + parameter tuner. - * - * Usage: - * npx tsx tools/test-ocr.ts # test all combos with defaults - * npx tsx tools/test-ocr.ts paddleocr # filter to paddleocr combos - * npx tsx tools/test-ocr.ts --tune # tune all combos (coordinate descent) - * npx tsx tools/test-ocr.ts --tune easyocr # tune only easyocr combos - */ -import { OcrDaemon, type OcrEngine, type OcrPreprocess, type DiffOcrParams, type DiffCropParams, type OcrParams } from '../src/game/OcrDaemon.js'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -// ── Types ────────────────────────────────────────────────────────────────── - -interface TestCase { - id: string; - image: string; - fullImage: string; - expected: string[]; -} - -interface Combo { - engine: OcrEngine; - preprocess: OcrPreprocess; - label: string; -} - -interface TuneResult { - label: string; - score: number; - params: DiffOcrParams; - evals: number; -} - -// ── Combos ───────────────────────────────────────────────────────────────── - -const ALL_COMBOS: Combo[] = [ - { engine: 'tesseract', preprocess: 'bgsub', label: 'tesseract+bgsub' }, - { engine: 'tesseract', preprocess: 'tophat', label: 'tesseract+tophat' }, - { engine: 'tesseract', preprocess: 'none', label: 'tesseract+none' }, - { engine: 'easyocr', preprocess: 'bgsub', label: 'easyocr+bgsub' }, - { engine: 'easyocr', preprocess: 'tophat', label: 'easyocr+tophat' }, - { engine: 'easyocr', preprocess: 'none', label: 'easyocr+none' }, - { engine: 'paddleocr', preprocess: 'bgsub', label: 'paddleocr+bgsub' }, - { engine: 'paddleocr', preprocess: 'tophat', label: 'paddleocr+tophat' }, - { engine: 'paddleocr', preprocess: 'none', label: 'paddleocr+none' }, -]; - -// ── Scoring ──────────────────────────────────────────────────────────────── - -function levenshtein(a: string, b: string): number { - const m = a.length, n = b.length; - const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); - for (let i = 0; i <= m; i++) dp[i][0] = i; - for (let j = 0; j <= n; j++) dp[0][j] = j; - for (let i = 1; i <= m; i++) - for (let j = 1; j <= n; j++) - dp[i][j] = a[i - 1] === b[j - 1] - ? dp[i - 1][j - 1] - : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); - return dp[m][n]; -} - -function similarity(a: string, b: string): number { - const maxLen = Math.max(a.length, b.length); - if (maxLen === 0) return 1; - return 1 - levenshtein(a.toLowerCase(), b.toLowerCase()) / maxLen; -} - -function scoreLines(expected: string[], actual: string[]): number { - const used = new Set(); - let matched = 0; - for (const exp of expected) { - let bestIdx = -1, bestSim = 0; - for (let i = 0; i < actual.length; i++) { - if (used.has(i)) continue; - const sim = similarity(exp, actual[i]); - if (sim > bestSim) { bestSim = sim; bestIdx = i; } - } - if (bestIdx >= 0 && bestSim >= 0.75) { - matched++; - used.add(bestIdx); - } - } - return expected.length > 0 ? matched / expected.length : 1; -} - -function scoreLinesVerbose(expected: string[], actual: string[]): { matched: string[]; missed: string[]; extra: string[]; score: number } { - const used = new Set(); - const matched: string[] = []; - const missed: string[] = []; - for (const exp of expected) { - let bestIdx = -1, bestSim = 0; - for (let i = 0; i < actual.length; i++) { - if (used.has(i)) continue; - const sim = similarity(exp, actual[i]); - if (sim > bestSim) { bestSim = sim; bestIdx = i; } - } - if (bestIdx >= 0 && bestSim >= 0.75) { - matched.push(exp); - used.add(bestIdx); - } else { - missed.push(exp); - } - } - const extra = actual.filter((_, i) => !used.has(i)); - return { matched, missed, extra, score: expected.length > 0 ? matched.length / expected.length : 1 }; -} - -// ── Daemon helpers ───────────────────────────────────────────────────────── - -async function runCase( - daemon: OcrDaemon, - tc: TestCase, - tessdataDir: string, - engine: OcrEngine, - preprocess: OcrPreprocess, - params?: DiffOcrParams, -): Promise { - const fullPath = join(tessdataDir, tc.fullImage).replace(/\//g, '\\'); - const imagePath = join(tessdataDir, tc.image).replace(/\//g, '\\'); - - await (daemon as any).sendWithRetry({ cmd: 'snapshot', file: fullPath }, 10_000); - - const req: any = { cmd: 'diff-ocr', file: imagePath }; - if (engine !== 'tesseract') req.engine = engine; - if (preprocess !== 'none') req.preprocess = preprocess; - if (params && Object.keys(params).length > 0) req.params = params; - - const timeout = engine !== 'tesseract' ? 120_000 : 10_000; - const resp = await (daemon as any).sendWithRetry(req, timeout); - - return (resp.lines ?? []) - .map((l: any) => (l.text ?? '').trim()) - .filter((l: string) => l.length > 0); -} - -async function scoreCombo( - daemon: OcrDaemon, - cases: TestCase[], - tessdataDir: string, - engine: OcrEngine, - preprocess: OcrPreprocess, - params?: DiffOcrParams, -): Promise { - let totalScore = 0; - for (const tc of cases) { - try { - const actual = await runCase(daemon, tc, tessdataDir, engine, preprocess, params); - totalScore += scoreLines(tc.expected, actual); - } catch { - // error = 0 score for this case - } - } - return totalScore / cases.length; -} - -// ── Parameter sweep definitions ──────────────────────────────────────────── - -interface CropIntSweep { - name: keyof DiffCropParams; - values: number[]; -} - -interface OcrIntSweep { - name: keyof OcrParams; - values: number[]; -} - -interface OcrBoolSweep { - name: keyof OcrParams; - values: boolean[]; -} - -const CROP_SWEEPS: CropIntSweep[] = [ - { name: 'diffThresh', values: [10, 15, 20, 25, 30, 40, 50] }, - { name: 'maxGap', values: [5, 10, 15, 20, 25, 30] }, -]; - -const CROP_TRIM_VALUES = [0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5]; - -const SHARED_OCR_SWEEPS: OcrIntSweep[] = [ - { name: 'upscale', values: [1, 2, 3] }, - { name: 'mergeGap', values: [0, 20, 40, 60, 80, 100] }, -]; - -const BGSUB_INT_SWEEPS: OcrIntSweep[] = [ - { name: 'dimPercentile', values: [5, 10, 15, 20, 25, 30, 40, 50, 60] }, - { name: 'textThresh', values: [10, 20, 30, 40, 50, 60, 80, 100] }, -]; - -const BGSUB_BOOL_SWEEPS: OcrBoolSweep[] = [ - { name: 'softThreshold', values: [false, true] }, -]; - -const TOPHAT_SWEEPS: OcrIntSweep[] = [ - { name: 'kernelSize', values: [11, 15, 21, 25, 31, 41, 51, 61] }, -]; - -// ── Default params per preprocess ────────────────────────────────────────── - -function defaultParams(preprocess: OcrPreprocess): DiffOcrParams { - const crop: DiffCropParams = { diffThresh: 20, maxGap: 20, trimCutoff: 0.4 }; - if (preprocess === 'bgsub') { - return { crop, ocr: { useBackgroundSub: true, upscale: 2, dimPercentile: 40, textThresh: 60, softThreshold: false } }; - } else if (preprocess === 'tophat') { - return { crop, ocr: { useBackgroundSub: false, upscale: 2, kernelSize: 41 } }; - } - return { crop, ocr: { upscale: 2 } }; // none -} - -function cloneParams(p: DiffOcrParams): DiffOcrParams { - return JSON.parse(JSON.stringify(p)); -} - -// ── Coordinate descent tuner (two-phase: crop then OCR) ────────────────── - -async function tuneCombo( - daemon: OcrDaemon, - cases: TestCase[], - tessdataDir: string, - combo: Combo, -): Promise { - const params = defaultParams(combo.preprocess); - let bestScore = await scoreCombo(daemon, cases, tessdataDir, combo.engine, combo.preprocess, params); - let evals = 1; - - process.stderr.write(` baseline: ${(bestScore * 100).toFixed(1)}% ${JSON.stringify(params)}\n`); - - // ── Phase A: Tune crop params ── - process.stderr.write(`\n === Phase A: Crop Params ===\n`); - const MAX_ROUNDS = 3; - - for (let round = 0; round < MAX_ROUNDS; round++) { - let improved = false; - process.stderr.write(` --- Crop Round ${round + 1} ---\n`); - - for (const { name, values } of CROP_SWEEPS) { - process.stderr.write(` crop.${name}: `); - let bestVal: number | undefined; - let bestValScore = -1; - - for (const v of values) { - const trial = cloneParams(params); - (trial.crop as any)[name] = v; - const score = await scoreCombo(daemon, cases, tessdataDir, combo.engine, combo.preprocess, trial); - evals++; - process.stderr.write(`${v}=${(score * 100).toFixed(1)} `); - if (score > bestValScore) { bestValScore = score; bestVal = v; } - } - process.stderr.write('\n'); - - if (bestValScore > bestScore && bestVal !== undefined) { - (params.crop as any)![name] = bestVal; - bestScore = bestValScore; - improved = true; - process.stderr.write(` -> crop.${name}=${bestVal} score=${(bestScore * 100).toFixed(1)}%\n`); - } - } - - // Sweep trimCutoff - { - process.stderr.write(` crop.trimCutoff: `); - let bestTrim = params.crop?.trimCutoff ?? 0.2; - let bestTrimScore = bestScore; - - for (const v of CROP_TRIM_VALUES) { - const trial = cloneParams(params); - trial.crop!.trimCutoff = v; - const score = await scoreCombo(daemon, cases, tessdataDir, combo.engine, combo.preprocess, trial); - evals++; - process.stderr.write(`${v}=${(score * 100).toFixed(1)} `); - if (score > bestTrimScore) { bestTrimScore = score; bestTrim = v; } - } - process.stderr.write('\n'); - - if (bestTrimScore > bestScore) { - params.crop!.trimCutoff = bestTrim; - bestScore = bestTrimScore; - improved = true; - process.stderr.write(` -> crop.trimCutoff=${bestTrim} score=${(bestScore * 100).toFixed(1)}%\n`); - } - } - - process.stderr.write(` End crop round ${round + 1}: ${(bestScore * 100).toFixed(1)}% (${evals} evals)\n`); - if (!improved) break; - } - - // ── Phase B: Tune OCR params (crop is now locked) ── - process.stderr.write(`\n === Phase B: OCR Params (crop locked) ===\n`); - - const ocrIntSweeps: OcrIntSweep[] = [...SHARED_OCR_SWEEPS]; - const ocrBoolSweeps: OcrBoolSweep[] = []; - if (combo.preprocess === 'bgsub') { - ocrIntSweeps.push(...BGSUB_INT_SWEEPS); - ocrBoolSweeps.push(...BGSUB_BOOL_SWEEPS); - } else if (combo.preprocess === 'tophat') { - ocrIntSweeps.push(...TOPHAT_SWEEPS); - } - - for (let round = 0; round < MAX_ROUNDS; round++) { - let improved = false; - process.stderr.write(` --- OCR Round ${round + 1} ---\n`); - - for (const { name, values } of ocrIntSweeps) { - process.stderr.write(` ocr.${name}: `); - let bestVal: number | undefined; - let bestValScore = -1; - - for (const v of values) { - const trial = cloneParams(params); - (trial.ocr as any)[name] = v; - const score = await scoreCombo(daemon, cases, tessdataDir, combo.engine, combo.preprocess, trial); - evals++; - process.stderr.write(`${v}=${(score * 100).toFixed(1)} `); - if (score > bestValScore) { bestValScore = score; bestVal = v; } - } - process.stderr.write('\n'); - - if (bestValScore > bestScore && bestVal !== undefined) { - (params.ocr as any)![name] = bestVal; - bestScore = bestValScore; - improved = true; - process.stderr.write(` -> ocr.${name}=${bestVal} score=${(bestScore * 100).toFixed(1)}%\n`); - } - } - - for (const { name, values } of ocrBoolSweeps) { - process.stderr.write(` ocr.${name}: `); - let bestVal: boolean | undefined; - let bestValScore = -1; - - for (const v of values) { - const trial = cloneParams(params); - (trial.ocr as any)[name] = v; - const score = await scoreCombo(daemon, cases, tessdataDir, combo.engine, combo.preprocess, trial); - evals++; - process.stderr.write(`${v}=${(score * 100).toFixed(1)} `); - if (score > bestValScore) { bestValScore = score; bestVal = v; } - } - process.stderr.write('\n'); - - if (bestValScore > bestScore && bestVal !== undefined) { - (params.ocr as any)![name] = bestVal; - bestScore = bestValScore; - improved = true; - process.stderr.write(` -> ocr.${name}=${bestVal} score=${(bestScore * 100).toFixed(1)}%\n`); - } - } - - process.stderr.write(` End OCR round ${round + 1}: ${(bestScore * 100).toFixed(1)}% (${evals} evals)\n`); - if (!improved) break; - } - - return { label: combo.label, score: bestScore, params, evals }; -} - -// ── Verbose test run ─────────────────────────────────────────────────────── - -async function testCombo( - daemon: OcrDaemon, - cases: TestCase[], - tessdataDir: string, - combo: Combo, - params?: DiffOcrParams, -): Promise { - let totalScore = 0; - for (const tc of cases) { - try { - const actual = await runCase(daemon, tc, tessdataDir, combo.engine, combo.preprocess, params); - const { matched, missed, extra, score } = scoreLinesVerbose(tc.expected, actual); - totalScore += score; - const status = missed.length === 0 ? 'PASS' : 'FAIL'; - console.log(` [${status}] ${tc.id} matched=${matched.length}/${tc.expected.length} extra=${extra.length} score=${score.toFixed(2)}`); - for (const m of missed) console.log(` MISS: ${m}`); - for (const e of extra) console.log(` EXTRA: ${e}`); - } catch (err: any) { - console.log(` [ERROR] ${tc.id}: ${err.message}`); - } - } - return totalScore / cases.length; -} - -// ── Main ─────────────────────────────────────────────────────────────────── - -async function main() { - const args = process.argv.slice(2); - const tuneMode = args.includes('--tune'); - const filterArg = args.find(a => !a.startsWith('--'))?.toLowerCase(); - - const combos = filterArg - ? ALL_COMBOS.filter(c => c.label.includes(filterArg)) - : ALL_COMBOS; - - const tessdataDir = join('tools', 'OcrDaemon', 'bin', 'Release', 'net8.0-windows10.0.19041.0', 'tessdata'); - const casesPath = join(tessdataDir, 'cases.json'); - const cases: TestCase[] = JSON.parse(readFileSync(casesPath, 'utf-8')); - - console.log(`Loaded ${cases.length} test cases: ${cases.map(c => c.id).join(', ')}`); - console.log(`Mode: ${tuneMode ? 'TUNE' : 'TEST'} Combos: ${combos.length}\n`); - - const daemon = new OcrDaemon(); - - if (tuneMode) { - // ── Tune mode: coordinate descent for each combo ── - const tuneResults: TuneResult[] = []; - - for (const combo of combos) { - console.log(`\n${'='.repeat(60)}`); - console.log(` TUNING: ${combo.label}`); - console.log(`${'='.repeat(60)}`); - - try { - const result = await tuneCombo(daemon, cases, tessdataDir, combo); - tuneResults.push(result); - - console.log(`\n Best: ${(result.score * 100).toFixed(1)}% (${result.evals} evals)`); - console.log(` Params: ${JSON.stringify(result.params)}`); - - // Verbose run with best params - console.log(''); - await testCombo(daemon, cases, tessdataDir, combo, result.params); - } catch (err: any) { - console.log(` ERROR: ${err.message}`); - tuneResults.push({ label: combo.label, score: 0, params: {}, evals: 0 }); - } - } - - // Summary - console.log(`\n${'='.repeat(70)}`); - console.log(' TUNE RESULTS'); - console.log(`${'='.repeat(70)}`); - - const sorted = tuneResults.sort((a, b) => b.score - a.score); - for (const r of sorted) { - const bar = '#'.repeat(Math.round(r.score * 40)); - console.log(` ${r.label.padEnd(22)} ${(r.score * 100).toFixed(1).padStart(5)}% ${bar}`); - } - - console.log(`\n BEST PARAMS PER COMBO:`); - for (const r of sorted) { - if (r.score > 0) { - console.log(` ${r.label.padEnd(22)} ${JSON.stringify(r.params)}`); - } - } - - } else { - // ── Test mode: defaults only ── - const results: Record = {}; - - for (const combo of combos) { - console.log(`\n${'='.repeat(60)}`); - console.log(` ${combo.label}`); - console.log(`${'='.repeat(60)}`); - - try { - const score = await testCombo(daemon, cases, tessdataDir, combo); - results[combo.label] = score; - console.log(`\n Average: ${(score * 100).toFixed(1)}%`); - } catch (err: any) { - console.log(` ERROR: ${err.message}`); - results[combo.label] = 0; - } - } - - console.log(`\n${'='.repeat(60)}`); - console.log(' SUMMARY'); - console.log(`${'='.repeat(60)}`); - - const sorted = Object.entries(results).sort((a, b) => b[1] - a[1]); - for (const [label, score] of sorted) { - const bar = '#'.repeat(Math.round(score * 40)); - console.log(` ${label.padEnd(22)} ${(score * 100).toFixed(1).padStart(5)}% ${bar}`); - } - } - - await daemon.stop(); -} - -main().catch(err => { - console.error(err); - process.exit(1); -});