deleted old

This commit is contained in:
Boki 2026-02-13 01:27:20 -05:00
parent 4a65c8e17b
commit 696fd07e86
33 changed files with 1 additions and 6292 deletions

View file

@ -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

View file

@ -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<string, ScrapExecutor>();
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<string, unknown>): 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<void> {
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<string>([
...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<void> {
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<void> {
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<void> {
const scrapExec = this.scrapExecutors.get(id);
if (scrapExec) {
await scrapExec.stop();
this.scrapExecutors.delete(id);
}
await this.tradeMonitor.pauseSearch(id);
}
}

View file

@ -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<SavedSettings>;
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<string, unknown>): void {
Object.assign(this.data, partial);
this.save();
}
}

View file

@ -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<string, TradeLink> = 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);
}
}

View file

@ -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),
};
}

View file

@ -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<void> {
this.stopped = true;
if (this.activePage) {
try { await this.activePage.close(); } catch { /* best-effort */ }
this.activePage = null;
}
this.setState('IDLE');
logger.info('Scrap executor stopped');
}
/** Main entry point — runs the full scrap loop. */
async runScrapLoop(tradeUrl: string, postAction: PostAction = 'salvage'): Promise<void> {
this.stopped = false;
this.postAction = postAction;
logger.info({ tradeUrl, postAction }, 'Starting scrap loop');
// Scan real inventory to know current state
await this.inventoryManager.scanInventory(this.postAction);
let { page, items } = await this.tradeMonitor.openScrapPage(tradeUrl);
this.activePage = page;
logger.info({ itemCount: items.length }, 'Trade page opened, items fetched');
while (!this.stopped) {
let salvageFailed = false;
for (const item of items) {
if (this.stopped) break;
// Check if this item fits before traveling
if (!this.inventoryManager.tracker.canFit(item.w, item.h)) {
// If salvage already failed this page, don't retry — skip remaining items
if (salvageFailed) {
logger.info({ w: item.w, h: item.h }, 'Skipping item (salvage already failed this page)');
continue;
}
logger.info({ w: item.w, h: item.h, free: this.inventoryManager.tracker.freeCells }, 'No room for item, running process cycle');
await this.processItems();
// Check if process succeeded (state is IDLE on success, FAILED otherwise)
if (this.state === 'FAILED') {
salvageFailed = true;
this.setState('IDLE');
logger.warn('Process cycle failed, skipping remaining items that do not fit');
continue;
}
// Re-scan inventory after processing to get accurate state
await this.inventoryManager.scanInventory(this.postAction);
}
// Still no room after processing — skip this item
if (!this.inventoryManager.tracker.canFit(item.w, item.h)) {
logger.warn({ w: item.w, h: item.h, free: this.inventoryManager.tracker.freeCells }, 'Item still cannot fit after processing, skipping');
continue;
}
const success = await this.buyItem(page, item);
if (!success) {
logger.warn({ itemId: item.id }, 'Failed to buy item, continuing');
continue;
}
await randomDelay(500, 1000);
}
if (this.stopped) break;
// Page exhausted — refresh and get new items
logger.info('Page exhausted, refreshing...');
items = await this.refreshPage(page);
logger.info({ itemCount: items.length }, 'Page refreshed');
if (items.length === 0) {
logger.info('No items after refresh, waiting before retry...');
await sleep(5000);
if (this.stopped) break;
items = await this.refreshPage(page);
}
}
this.activePage = null;
this.setState('IDLE');
logger.info('Scrap loop ended');
}
/** Buy one item from a seller. */
private async buyItem(page: Page, item: TradeItem): Promise<boolean> {
try {
const alreadyAtSeller = !this.inventoryManager.isAtOwnHideout
&& item.account
&& item.account === this.inventoryManager.sellerAccount;
if (alreadyAtSeller) {
logger.info({ itemId: item.id, account: item.account }, 'Already at seller hideout, skipping travel');
} else {
this.setState('TRAVELING');
// Register listener BEFORE clicking, then click inside the callback
const arrived = await this.inventoryManager.waitForAreaTransition(
this.config.travelTimeoutMs,
async () => {
const clicked = await this.tradeMonitor.clickTravelToHideout(page, item.id);
if (!clicked) {
throw new Error('Failed to click Travel to Hideout');
}
},
);
if (!arrived) {
logger.error({ itemId: item.id }, 'Timed out waiting for hideout arrival');
this.setState('FAILED');
return false;
}
this.inventoryManager.setLocation(false, item.account);
await this.gameController.focusGame();
await sleep(1500); // Wait for hideout to render
}
this.setState('BUYING');
// CTRL+Click at seller stash position
const sellerLayout = GRID_LAYOUTS.seller;
const cellCenter = this.screenReader.grid.getCellCenter(sellerLayout, item.stashY, item.stashX);
logger.info({ itemId: item.id, stashX: item.stashX, stashY: item.stashY, screenX: cellCenter.x, screenY: cellCenter.y }, 'CTRL+clicking seller stash item');
await this.gameController.ctrlLeftClickAt(cellCenter.x, cellCenter.y);
await randomDelay(200, 400);
// Track in inventory with this link's postAction
const placed = this.inventoryManager.tracker.tryPlace(item.w, item.h, this.postAction);
if (!placed) {
logger.warn({ itemId: item.id, w: item.w, h: item.h }, 'Item bought but could not track in inventory');
}
logger.info({ itemId: item.id, free: this.inventoryManager.tracker.freeCells }, 'Item bought successfully');
this.setState('IDLE');
return true;
} catch (err) {
logger.error({ err, itemId: item.id }, 'Error buying item');
this.setState('FAILED');
return false;
}
}
/** Process inventory: salvage/stash cycle via InventoryManager. */
private async processItems(): Promise<void> {
try {
this.setState('SALVAGING');
await this.inventoryManager.processInventory();
this.setState('IDLE');
logger.info('Process cycle complete');
} catch (err) {
logger.error({ err }, 'Process cycle failed');
this.setState('FAILED');
}
}
/** Refresh the trade page and return new items. */
private async refreshPage(page: Page): Promise<TradeItem[]> {
const items: TradeItem[] = [];
// Set up response listener before reloading
const responseHandler = async (response: { url(): string; json(): Promise<any> }) => {
if (response.url().includes('/api/trade2/fetch/')) {
try {
const json = await response.json();
if (json.result && Array.isArray(json.result)) {
for (const r of json.result) {
items.push({
id: r.id,
w: r.item?.w ?? 1,
h: r.item?.h ?? 1,
stashX: r.listing?.stash?.x ?? 0,
stashY: r.listing?.stash?.y ?? 0,
account: r.listing?.account?.name ?? '',
});
}
}
} catch {
// Response may not be JSON
}
}
};
page.on('response', responseHandler);
await page.reload({ waitUntil: 'networkidle' });
await sleep(2000);
page.off('response', responseHandler);
return items;
}
}

View file

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

View file

@ -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<void> {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
const trade = this.queue.shift()!;
try {
logger.info(
{ searchId: trade.searchId, itemIds: trade.itemIds },
'Processing trade',
);
const success = await this.executor.executeTrade(trade);
if (success) {
logger.info({ itemIds: trade.itemIds }, 'Trade completed successfully');
} else {
logger.warn({ itemIds: trade.itemIds }, 'Trade failed');
}
} catch (err) {
logger.error({ err, itemIds: trade.itemIds }, 'Trade execution error');
}
this.processing = false;
// Delay between trades
await randomDelay(this.config.betweenTradesDelayMs, this.config.betweenTradesDelayMs + 3000);
this.processNext();
}
}

View file

@ -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<boolean> {
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<void> {
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<void> {
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<void> {
logger.info('Sending /hideout command');
await this.sendChatViaPaste('/hideout');
}
async ctrlRightClickAt(x: number, y: number): Promise<void> {
await this.inputSender.ctrlRightClick(x, y);
}
async moveMouseTo(x: number, y: number): Promise<void> {
await this.inputSender.moveMouse(x, y);
}
moveMouseInstant(x: number, y: number): void {
this.inputSender.moveMouseInstant(x, y);
}
async moveMouseFast(x: number, y: number): Promise<void> {
await this.inputSender.moveMouseFast(x, y);
}
async leftClickAt(x: number, y: number): Promise<void> {
await this.inputSender.leftClick(x, y);
}
async rightClickAt(x: number, y: number): Promise<void> {
await this.inputSender.rightClick(x, y);
}
async holdAlt(): Promise<void> {
await this.inputSender.keyDown(VK.MENU);
}
async releaseAlt(): Promise<void> {
await this.inputSender.keyUp(VK.MENU);
}
async pressEscape(): Promise<void> {
await this.inputSender.pressKey(VK.ESCAPE);
}
async openInventory(): Promise<void> {
logger.info('Opening inventory');
await this.inputSender.pressKey(VK.I);
await sleep(300);
}
async ctrlLeftClickAt(x: number, y: number): Promise<void> {
await this.inputSender.ctrlLeftClick(x, y);
}
async holdCtrl(): Promise<void> {
await this.inputSender.keyDown(VK.CONTROL);
}
async releaseCtrl(): Promise<void> {
await this.inputSender.keyUp(VK.CONTROL);
}
}

View file

@ -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<string, GridLayout> = {
/** 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<ScanResult> {
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;
}
}

View file

@ -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<void> {
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<void> {
const scanCode = MapVirtualKeyW(vkCode, 0);
this.sendScanKeyDown(scanCode);
await randomDelay(15, 30);
}
async keyUp(vkCode: number): Promise<void> {
const scanCode = MapVirtualKeyW(vkCode, 0);
this.sendScanKeyUp(scanCode);
await randomDelay(15, 30);
}
async typeText(text: string): Promise<void> {
for (const char of text) {
this.sendUnicodeChar(char);
await randomDelay(20, 50);
}
}
async paste(): Promise<void> {
await this.keyDown(VK.CONTROL);
await sleep(30);
await this.pressKey(VK.V);
await this.keyUp(VK.CONTROL);
await sleep(50);
}
async selectAll(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}

View file

@ -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<OcrResponse> {
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<Buffer> {
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<GridScanResult> {
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<DetectGridResult> {
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<void> {
await this.sendWithRetry({ cmd: 'snapshot' }, REQUEST_TIMEOUT);
}
async diffOcr(savePath?: string, region?: Region, engine?: OcrEngine, preprocess?: OcrPreprocess, params?: DiffOcrParams): Promise<DiffOcrResponse> {
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<DiffOcrResponse> {
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<void> {
const req: DaemonRequest = { cmd: 'screenshot', path };
if (region) req.region = region;
await this.sendWithRetry(req, REQUEST_TIMEOUT);
}
async templateMatch(templatePath: string, region?: Region): Promise<TemplateMatchResult | null> {
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<void> {
await this.ensureRunning();
}
async stop(): Promise<void> {
this.stopped = true;
if (this.proc) {
const p = this.proc;
this.proc = null;
p.stdin?.end();
p.kill();
}
}
// ── Internal ────────────────────────────────────────────────────────────
private async ensureRunning(): Promise<void> {
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<void>((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<DaemonResponse> {
await this.ensureRunning();
return new Promise<DaemonResponse>((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<DaemonResponse> {
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;
}
}
}

View file

@ -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<void> {
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<Buffer> {
const t = performance.now();
const buf = await this.daemon.captureBuffer();
logger.info({ ms: elapsed(t) }, 'captureScreen');
return buf;
}
async captureRegion(region: Region): Promise<Buffer> {
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<string, number>();
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<string> {
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<string> {
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<boolean> {
const pos = await this.findTextInRegion(region, searchText);
return pos !== null;
}
// ── Snapshot / Diff-OCR (for tooltip reading) ──────────────────────
async snapshot(): Promise<void> {
if (this.settings.tooltipMethod === 'edge') return; // no reference frame needed
await this.daemon.snapshot();
}
async diffOcr(savePath?: string, region?: Region): Promise<DiffOcrResponse> {
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<TemplateMatchResult | null> {
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<void> {
await this.daemon.saveScreenshot(path);
logger.info({ path }, 'Screenshot saved');
}
async saveDebugScreenshots(dir: string): Promise<string[]> {
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<void> {
await this.daemon.saveScreenshot(path, region);
logger.info({ path, region }, 'Region screenshot saved');
}
// ── Lifecycle ───────────────────────────────────────────────────────
async dispose(): Promise<void> {
await this.daemon.stop();
}
}

View file

@ -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;
}
}

View file

@ -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 <urls...>', 'Trade search URLs to monitor')
.option('--log-path <path>', 'Path to POE2 Client.txt')
.option('-p, --port <number>', 'Dashboard port')
.option('-c, --config <path>', '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();

View file

@ -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<void> {
logger.info('Scanning inventory...');
await this.gameController.focusGame();
await sleep(300);
await this.gameController.openInventory();
const result = await this.screenReader.grid.scan('inventory');
// Build cells grid from occupied coords
const cells: boolean[][] = Array.from({ length: 5 }, () => Array(12).fill(false));
for (const cell of result.occupied) {
if (cell.row < 5 && cell.col < 12) {
cells[cell.row][cell.col] = true;
}
}
this.tracker.initFromScan(cells, result.items, defaultAction);
// Close inventory
await this.gameController.pressEscape();
await sleep(300);
}
/** Startup clear: scan inventory, deposit everything to stash. */
async clearToStash(): Promise<void> {
logger.info('Checking inventory for leftover items...');
await this.scanInventory('stash');
if (this.tracker.getItems().length === 0) {
logger.info('Inventory empty, nothing to clear');
return;
}
logger.info({ items: this.tracker.getItems().length }, 'Found leftover items, depositing to stash');
await this.depositItemsToStash(this.tracker.getItems());
this.tracker.clear();
logger.info('Inventory cleared to stash');
}
/** Ensure we are at own hideout, travel if needed. */
async ensureAtOwnHideout(): Promise<boolean> {
if (this.atOwnHideout) {
logger.info('Already at own hideout');
return true;
}
await this.gameController.focusGame();
await sleep(300);
const arrived = await this.waitForAreaTransition(
this.config.travelTimeoutMs,
() => this.gameController.goToHideout(),
);
if (!arrived) {
logger.error('Timed out going to own hideout');
return false;
}
await sleep(1500); // Wait for hideout to render
this.atOwnHideout = true;
this.currentSellerAccount = '';
return true;
}
/** Open stash and Ctrl+click given items to deposit. */
async depositItemsToStash(items: PlacedItem[]): Promise<void> {
if (items.length === 0) return;
const stashPos = await this.findAndClickNameplate('Stash');
if (!stashPos) {
logger.error('Could not find Stash nameplate');
return;
}
await sleep(1000); // Wait for stash to open
const inventoryLayout = GRID_LAYOUTS.inventory;
logger.info({ count: items.length }, 'Depositing items to stash');
await this.gameController.holdCtrl();
for (const item of items) {
const center = this.screenReader.grid.getCellCenter(inventoryLayout, item.row, item.col);
await this.gameController.leftClickAt(center.x, center.y);
await sleep(150);
}
await this.gameController.releaseCtrl();
await sleep(500);
// Close stash
await this.gameController.pressEscape();
await sleep(500);
logger.info({ deposited: items.length }, 'Items deposited to stash');
}
/** Open salvage bench, template-match salvage button, Ctrl+click items. */
async salvageItems(items: PlacedItem[]): Promise<boolean> {
if (items.length === 0) return true;
const salvageNameplate = await this.findAndClickNameplate('SALVAGE BENCH');
if (!salvageNameplate) {
logger.error('Could not find Salvage nameplate');
return false;
}
await sleep(1000); // Wait for salvage bench UI to open
// Template-match salvage.png to activate salvage mode
const salvageBtn = await this.screenReader.templateMatch(SALVAGE_TEMPLATE);
if (salvageBtn) {
await this.gameController.leftClickAt(salvageBtn.x, salvageBtn.y);
await sleep(500);
} else {
logger.warn('Could not find salvage button via template match, trying to proceed anyway');
}
// CTRL+Click each inventory item to salvage
const inventoryLayout = GRID_LAYOUTS.inventory;
logger.info({ count: items.length }, 'Salvaging inventory items');
await this.gameController.holdCtrl();
for (const item of items) {
const center = this.screenReader.grid.getCellCenter(inventoryLayout, item.row, item.col);
await this.gameController.leftClickAt(center.x, center.y);
await sleep(150);
}
await this.gameController.releaseCtrl();
await sleep(500);
// Close salvage bench
await this.gameController.pressEscape();
await sleep(500);
return true;
}
/**
* Full post-purchase processing cycle:
* 1. Go home
* 2. Salvage 'salvage' items if any
* 3. Re-scan inventory (picks up salvage materials)
* 4. Deposit everything to stash
* 5. Clear tracker
*/
async processInventory(): Promise<void> {
try {
// Step 1: ensure at own hideout
const home = await this.ensureAtOwnHideout();
if (!home) {
logger.error('Cannot process inventory: failed to reach hideout');
return;
}
// Step 2: salvage items tagged 'salvage'
if (this.tracker.hasItemsWithAction('salvage')) {
const salvageItems = this.tracker.getItemsByAction('salvage');
const salvaged = await this.salvageItems(salvageItems);
if (salvaged) {
this.tracker.removeItemsByAction('salvage');
} else {
logger.warn('Salvage failed, depositing all items to stash instead');
}
}
// Step 3: re-scan inventory (picks up salvage materials + any remaining items)
await this.scanInventory('stash');
// Step 4: deposit all remaining items to stash
const allItems = this.tracker.getItems();
if (allItems.length > 0) {
await this.depositItemsToStash(allItems);
}
// Step 5: clear tracker
this.tracker.clear();
logger.info('Inventory processing complete');
} catch (err) {
logger.error({ err }, 'Inventory processing failed');
// Try to recover UI state
try {
await this.gameController.pressEscape();
await sleep(300);
} catch {
// Best-effort
}
this.tracker.clear();
}
}
/** Find and click a nameplate by OCR text (fuzzy, with retries). */
async findAndClickNameplate(
name: string,
maxRetries: number = 3,
retryDelayMs: number = 1000,
): Promise<{ x: number; y: number } | null> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
logger.info({ name, attempt, maxRetries }, 'Searching for nameplate...');
const pos = await this.screenReader.findTextOnScreen(name, true);
if (pos) {
logger.info({ name, x: pos.x, y: pos.y }, 'Clicking nameplate');
await this.gameController.leftClickAt(pos.x, pos.y);
return pos;
}
if (attempt < maxRetries) {
await sleep(retryDelayMs);
}
}
logger.warn({ name, maxRetries }, 'Nameplate not found after all retries');
return null;
}
/**
* Wait for area transition via Client.txt log.
* If `triggerAction` is provided, the listener is registered BEFORE the action
* executes, preventing the race where the event fires before we listen.
*/
waitForAreaTransition(
timeoutMs: number,
triggerAction?: () => Promise<void>,
): Promise<boolean> {
return new Promise((resolve) => {
let resolved = false;
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
this.logWatcher.removeListener('area-entered', handler);
resolve(false);
}
}, timeoutMs);
const handler = () => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
resolve(true);
}
};
// Register listener FIRST
this.logWatcher.once('area-entered', handler);
// THEN trigger the action that causes the transition
if (triggerAction) {
triggerAction().catch(() => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
this.logWatcher.removeListener('area-entered', handler);
resolve(false);
}
});
}
});
}
/** Get inventory state for dashboard display. */
getInventoryState(): { grid: boolean[][]; items: { row: number; col: number; w: number; h: number }[]; free: number } {
return {
grid: this.tracker.getGrid(),
items: this.tracker.getItems(),
free: this.tracker.freeCells,
};
}
}

View file

@ -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 });
}
}

View file

@ -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<typeof watch> | 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<void> {
// 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<void> {
if (this.watcher) {
await this.watcher.close();
this.watcher = null;
}
logger.info('Client log watcher stopped');
}
}

View file

@ -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<WebSocket> = 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<string, unknown>): 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<void> {
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<void> {
for (const client of this.clients) {
client.close();
}
return new Promise((resolve) => {
this.server.close(() => resolve());
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -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;
}

View file

@ -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<OcrSettings>;
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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<string, Page> = new Map();
private pausedSearches: Set<string> = new Set();
private config: Config;
constructor(config: Config) {
super();
this.config = config;
}
async start(dashboardUrl?: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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');
}
}

View file

@ -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;

View file

@ -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<string, string>;
}
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;
}

View file

@ -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, "''")}'"`);
}

View file

@ -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',
},
},
});

View file

@ -1,24 +0,0 @@
import { sleep } from './sleep.js';
import { logger } from './logger.js';
export async function retry<T>(
fn: () => Promise<T>,
options: { maxAttempts?: number; delayMs?: number; label?: string } = {},
): Promise<T> {
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');
}

View file

@ -1,8 +0,0 @@
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function randomDelay(minMs: number, maxMs: number): Promise<void> {
const delay = minMs + Math.random() * (maxMs - minMs);
return sleep(delay);
}

View file

@ -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);

View file

@ -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<number>();
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<number>();
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<string[]> {
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<number> {
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<TuneResult> {
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<number> {
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<string, number> = {};
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);
});