Initial commit: POE2 automated trade bot
Monitors pathofexile.com/trade2 for new listings, travels to seller hideouts, buys items from public stash tabs, and stores them. Includes persistent C# OCR daemon for fast screen capture + Windows native OCR, web dashboard for managing trade links and settings, and full game automation via Win32 SendInput. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
41d174195e
28 changed files with 6449 additions and 0 deletions
256
src/game/OcrDaemon.ts
Normal file
256
src/game/OcrDaemon.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
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[];
|
||||
}
|
||||
|
||||
interface DaemonRequest {
|
||||
cmd: string;
|
||||
region?: Region;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface DaemonResponse {
|
||||
ok: boolean;
|
||||
ready?: boolean;
|
||||
text?: string;
|
||||
lines?: OcrLine[];
|
||||
image?: string;
|
||||
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): Promise<OcrResponse> {
|
||||
const req: DaemonRequest = { cmd: 'ocr' };
|
||||
if (region) req.region = region;
|
||||
const resp = await this.sendWithRetry(req, REQUEST_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 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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue