deleted old
This commit is contained in:
parent
4a65c8e17b
commit
696fd07e86
33 changed files with 1 additions and 6292 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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, "''")}'"`);
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue