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
22
.env.example
Normal file
22
.env.example
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# POE2 Trade Bot Configuration
|
||||||
|
|
||||||
|
# Path to POE2 Client.txt log file
|
||||||
|
POE2_LOG_PATH=C:\Program Files (x86)\Steam\steamapps\common\Path of Exile 2\logs\Client.txt
|
||||||
|
|
||||||
|
# POE2 game window title (used to find/focus the window)
|
||||||
|
POE2_WINDOW_TITLE=Path of Exile 2
|
||||||
|
|
||||||
|
# Playwright persistent browser data directory
|
||||||
|
BROWSER_USER_DATA_DIR=./browser-data
|
||||||
|
|
||||||
|
# Trade URLs (comma-separated) - can also add via dashboard
|
||||||
|
# TRADE_URLS=https://www.pathofexile.com/trade2/search/poe2/Fate%20of%20the%20Vaal/LgLZ6MBGHn/live
|
||||||
|
|
||||||
|
# Dashboard port
|
||||||
|
DASHBOARD_PORT=3000
|
||||||
|
|
||||||
|
# Timeouts (milliseconds)
|
||||||
|
TRAVEL_TIMEOUT_MS=15000
|
||||||
|
STASH_SCAN_TIMEOUT_MS=10000
|
||||||
|
WAIT_FOR_MORE_ITEMS_MS=20000
|
||||||
|
BETWEEN_TRADES_DELAY_MS=5000
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
config.json
|
||||||
|
browser-data/
|
||||||
|
*.log
|
||||||
|
debug-screenshots/
|
||||||
|
eng.traineddata
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# OcrDaemon build output
|
||||||
|
tools/OcrDaemon/bin/
|
||||||
|
tools/OcrDaemon/obj/
|
||||||
2888
package-lock.json
generated
Normal file
2888
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "poe2trade",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "POE2 trade bot - automated item purchasing via trade site monitoring",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"build:daemon": "dotnet build tools/OcrDaemon -c Release",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^4.0.3",
|
||||||
|
"clipboard-sys": "^1.2.0",
|
||||||
|
"commander": "^13.1.0",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"koffi": "^2.9.2",
|
||||||
|
"pino": "^9.6.0",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
|
"playwright": "^1.50.1",
|
||||||
|
"ws": "^8.19.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/node": "^22.13.1",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/config.ts
Normal file
38
src/config.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
187
src/dashboard/BotController.ts
Normal file
187
src/dashboard/BotController.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { logger } from '../util/logger.js';
|
||||||
|
import type { ConfigStore, SavedLink } from './ConfigStore.js';
|
||||||
|
|
||||||
|
export interface TradeLink {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
addedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BotController extends EventEmitter {
|
||||||
|
private paused = false;
|
||||||
|
private links: Map<string, TradeLink> = new Map();
|
||||||
|
private _state = 'IDLE';
|
||||||
|
private tradesCompleted = 0;
|
||||||
|
private tradesFailed = 0;
|
||||||
|
private startTime = Date.now();
|
||||||
|
private store: ConfigStore;
|
||||||
|
|
||||||
|
constructor(store: ConfigStore) {
|
||||||
|
super();
|
||||||
|
this.store = store;
|
||||||
|
this.paused = store.settings.paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPaused(): boolean {
|
||||||
|
return this.paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
get state(): string {
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
set state(s: string) {
|
||||||
|
this._state = s;
|
||||||
|
this.emit('state-change', s);
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
this.paused = true;
|
||||||
|
this.store.setPaused(true);
|
||||||
|
logger.info('Bot paused');
|
||||||
|
this.emit('paused');
|
||||||
|
}
|
||||||
|
|
||||||
|
resume(): void {
|
||||||
|
this.paused = false;
|
||||||
|
this.store.setPaused(false);
|
||||||
|
logger.info('Bot resumed');
|
||||||
|
this.emit('resumed');
|
||||||
|
}
|
||||||
|
|
||||||
|
addLink(url: string, name: string = ''): TradeLink {
|
||||||
|
url = this.stripLive(url);
|
||||||
|
const id = this.extractId(url);
|
||||||
|
const label = this.extractLabel(url);
|
||||||
|
// Check if we have saved state for this link
|
||||||
|
const savedLink = this.store.links.find((l) => l.url === url);
|
||||||
|
const link: TradeLink = {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
name: name || savedLink?.name || '',
|
||||||
|
label,
|
||||||
|
active: savedLink?.active !== undefined ? savedLink.active : true,
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.links.set(id, link);
|
||||||
|
this.store.addLink(url, link.name);
|
||||||
|
logger.info({ id, url, name: link.name, active: link.active }, 'Trade link added');
|
||||||
|
this.emit('link-added', link);
|
||||||
|
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');
|
||||||
|
this.emit('link-removed', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLink(id: string, active: boolean): void {
|
||||||
|
const link = this.links.get(id);
|
||||||
|
if (!link) return;
|
||||||
|
link.active = active;
|
||||||
|
this.store.updateLinkById(id, { active });
|
||||||
|
logger.info({ id, active }, `Trade link ${active ? 'activated' : 'deactivated'}`);
|
||||||
|
this.emit('link-toggled', { id, active, link });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLinkName(id: string, name: string): void {
|
||||||
|
const link = this.links.get(id);
|
||||||
|
if (!link) return;
|
||||||
|
link.name = name;
|
||||||
|
this.store.updateLinkById(id, { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
isLinkActive(searchId: string): boolean {
|
||||||
|
const link = this.links.get(searchId);
|
||||||
|
return link ? link.active : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLinks(): TradeLink[] {
|
||||||
|
return Array.from(this.links.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
recordTradeSuccess(): void {
|
||||||
|
this.tradesCompleted++;
|
||||||
|
this.emit('trade-completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
recordTradeFailure(): void {
|
||||||
|
this.tradesFailed++;
|
||||||
|
this.emit('trade-failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): BotStatus {
|
||||||
|
const s = this.store.settings;
|
||||||
|
return {
|
||||||
|
paused: this.paused,
|
||||||
|
state: this._state,
|
||||||
|
links: this.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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getStore(): ConfigStore {
|
||||||
|
return this.store;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/dashboard/ConfigStore.ts
Normal file
129
src/dashboard/ConfigStore.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { logger } from '../util/logger.js';
|
||||||
|
|
||||||
|
export interface SavedLink {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
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
|
||||||
|
merged.links = merged.links.map((l) => ({
|
||||||
|
url: l.url.replace(/\/live\/?$/, ''),
|
||||||
|
name: l.name || '',
|
||||||
|
active: l.active !== undefined ? l.active : true,
|
||||||
|
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 = ''): void {
|
||||||
|
url = url.replace(/\/live\/?$/, '');
|
||||||
|
if (this.data.links.some((l) => l.url === url)) return;
|
||||||
|
this.data.links.push({ url, name, active: true, 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 }): 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;
|
||||||
|
this.save();
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPaused(paused: boolean): void {
|
||||||
|
this.data.paused = paused;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(partial: Partial<SavedSettings>): void {
|
||||||
|
Object.assign(this.data, partial);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
227
src/dashboard/DashboardServer.ts
Normal file
227
src/dashboard/DashboardServer.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
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 type { BotController } from './BotController.js';
|
||||||
|
import type { ScreenReader } from '../game/ScreenReader.js';
|
||||||
|
import type { GameController } from '../game/GameController.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export interface DebugDeps {
|
||||||
|
screenReader: ScreenReader;
|
||||||
|
gameController: GameController;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DashboardServer {
|
||||||
|
private app = express();
|
||||||
|
private server: http.Server;
|
||||||
|
private wss: WebSocketServer;
|
||||||
|
private clients: Set<WebSocket> = new Set();
|
||||||
|
private bot: BotController;
|
||||||
|
private debug: DebugDeps | null = null;
|
||||||
|
|
||||||
|
constructor(bot: BotController, private port: number = 3000) {
|
||||||
|
this.bot = bot;
|
||||||
|
this.app.use(express.json());
|
||||||
|
|
||||||
|
this.app.get('/', (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '..', '..', 'src', 'dashboard', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status
|
||||||
|
this.app.get('/api/status', (_req, res) => {
|
||||||
|
res.json(this.bot.getStatus());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pause / Resume
|
||||||
|
this.app.post('/api/pause', (_req, res) => {
|
||||||
|
this.bot.pause();
|
||||||
|
this.broadcastStatus();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.post('/api/resume', (_req, res) => {
|
||||||
|
this.bot.resume();
|
||||||
|
this.broadcastStatus();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Links CRUD
|
||||||
|
this.app.post('/api/links', (req, res) => {
|
||||||
|
const { url, name } = req.body as { url: string; name?: string };
|
||||||
|
if (!url || !url.includes('pathofexile.com/trade')) {
|
||||||
|
res.status(400).json({ error: 'Invalid trade URL' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.bot.addLink(url, name || '');
|
||||||
|
this.broadcastStatus();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.delete('/api/links/:id', (req, res) => {
|
||||||
|
this.bot.removeLink(req.params.id);
|
||||||
|
this.broadcastStatus();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle link active/inactive
|
||||||
|
this.app.post('/api/links/:id/toggle', (req, res) => {
|
||||||
|
const { active } = req.body as { active: boolean };
|
||||||
|
this.bot.toggleLink(req.params.id, active);
|
||||||
|
this.broadcastStatus();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rename link
|
||||||
|
this.app.post('/api/links/:id/name', (req, res) => {
|
||||||
|
const { name } = req.body as { name: string };
|
||||||
|
this.bot.updateLinkName(req.params.id, name);
|
||||||
|
this.broadcastStatus();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
this.app.post('/api/settings', (req, res) => {
|
||||||
|
const updates = req.body as Record<string, unknown>;
|
||||||
|
const store = this.bot.getStore();
|
||||||
|
store.updateSettings(updates);
|
||||||
|
this.broadcastStatus();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug endpoints
|
||||||
|
this.app.post('/api/debug/screenshot', async (_req, res) => {
|
||||||
|
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||||
|
try {
|
||||||
|
const files = await this.debug.screenReader.saveDebugScreenshots('debug-screenshots');
|
||||||
|
this.broadcastLog('info', `Debug screenshots saved: ${files.map(f => f.split(/[\\/]/).pop()).join(', ')}`);
|
||||||
|
res.json({ ok: true, files });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Debug screenshot failed');
|
||||||
|
res.status(500).json({ error: 'Screenshot failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.post('/api/debug/ocr', async (_req, res) => {
|
||||||
|
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||||
|
try {
|
||||||
|
const text = await this.debug.screenReader.readFullScreen();
|
||||||
|
this.broadcastLog('info', `OCR result (${text.length} chars): ${text.substring(0, 200)}`);
|
||||||
|
res.json({ ok: true, text });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Debug OCR failed');
|
||||||
|
res.status(500).json({ error: 'OCR failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.post('/api/debug/find-text', async (req, res) => {
|
||||||
|
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||||
|
const { text } = req.body as { text: string };
|
||||||
|
if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; }
|
||||||
|
try {
|
||||||
|
const pos = await this.debug.screenReader.findTextOnScreen(text);
|
||||||
|
if (pos) {
|
||||||
|
this.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y})`);
|
||||||
|
} else {
|
||||||
|
this.broadcastLog('warn', `"${text}" not found on screen`);
|
||||||
|
}
|
||||||
|
res.json({ ok: true, found: !!pos, position: pos });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Debug find-text failed');
|
||||||
|
res.status(500).json({ error: 'Find text failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.post('/api/debug/click', async (req, res) => {
|
||||||
|
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); 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; }
|
||||||
|
try {
|
||||||
|
await this.debug.gameController.focusGame();
|
||||||
|
await this.debug.gameController.leftClickAt(x, y);
|
||||||
|
this.broadcastLog('info', `Clicked at (${x}, ${y})`);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Debug click failed');
|
||||||
|
res.status(500).json({ error: 'Click failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.post('/api/debug/find-and-click', async (req, res) => {
|
||||||
|
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||||
|
const { text } = req.body as { text: string };
|
||||||
|
if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; }
|
||||||
|
try {
|
||||||
|
const pos = await this.debug.screenReader.findTextOnScreen(text);
|
||||||
|
if (pos) {
|
||||||
|
await this.debug.gameController.focusGame();
|
||||||
|
await this.debug.gameController.leftClickAt(pos.x, pos.y);
|
||||||
|
this.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) and clicked`);
|
||||||
|
res.json({ ok: true, found: true, position: pos });
|
||||||
|
} else {
|
||||||
|
this.broadcastLog('warn', `"${text}" not found on screen`);
|
||||||
|
res.json({ ok: true, found: false, position: null });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Debug find-and-click failed');
|
||||||
|
res.status(500).json({ error: 'Find and click failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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: this.bot.getStatus() }));
|
||||||
|
ws.on('close', () => this.clients.delete(ws));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setDebugDeps(deps: DebugDeps): void {
|
||||||
|
this.debug = deps;
|
||||||
|
logger.info('Debug tools available on dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
670
src/dashboard/index.html
Normal file
670
src/dashboard/index.html
Normal file
|
|
@ -0,0 +1,670 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>POE2 Trade Bot</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid #30363d;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 20px; font-weight: 600; }
|
||||||
|
.header-right { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.settings-btn {
|
||||||
|
background: none; border: none; cursor: pointer; padding: 4px;
|
||||||
|
color: #8b949e; transition: color 0.15s; line-height: 1;
|
||||||
|
}
|
||||||
|
.settings-btn:hover { background: none; color: #e6edf3; }
|
||||||
|
.settings-btn svg { display: block; }
|
||||||
|
.status-dot {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
|
display: inline-block; margin-right: 8px;
|
||||||
|
}
|
||||||
|
.status-dot.running { background: #3fb950; box-shadow: 0 0 6px #3fb950; }
|
||||||
|
.status-dot.paused { background: #d29922; box-shadow: 0 0 6px #d29922; }
|
||||||
|
.status-dot.idle { background: #8b949e; }
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-card .value { font-size: 24px; font-weight: 700; color: #58a6ff; }
|
||||||
|
.stat-card .label { font-size: 11px; color: #8b949e; text-transform: uppercase; margin-top: 4px; }
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #21262d;
|
||||||
|
color: #e6edf3;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
button:hover { background: #30363d; }
|
||||||
|
button.primary { background: #238636; border-color: #2ea043; }
|
||||||
|
button.primary:hover { background: #2ea043; }
|
||||||
|
button.danger { background: #da3633; border-color: #f85149; }
|
||||||
|
button.danger:hover { background: #f85149; }
|
||||||
|
button.warning { background: #9e6a03; border-color: #d29922; }
|
||||||
|
button.warning:hover { background: #d29922; }
|
||||||
|
|
||||||
|
.section { margin-bottom: 20px; }
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #8b949e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-link {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.add-link input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e6edf3;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.add-link input:focus { border-color: #58a6ff; }
|
||||||
|
.add-link input::placeholder { color: #484f58; }
|
||||||
|
|
||||||
|
.links-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.link-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.link-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
|
||||||
|
.link-info { flex: 1; min-width: 0; }
|
||||||
|
.link-name { font-size: 13px; font-weight: 600; color: #e6edf3; }
|
||||||
|
.link-label { font-size: 12px; color: #8b949e; }
|
||||||
|
.link-url {
|
||||||
|
font-size: 11px; color: #484f58;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
.link-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||||
|
.link-item button { padding: 4px 12px; font-size: 12px; }
|
||||||
|
.link-item.inactive { opacity: 0.5; }
|
||||||
|
|
||||||
|
/* Toggle switch */
|
||||||
|
.toggle { position: relative; width: 36px; height: 20px; cursor: pointer; flex-shrink: 0; }
|
||||||
|
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.toggle .slider {
|
||||||
|
position: absolute; inset: 0; background: #30363d;
|
||||||
|
border-radius: 20px; transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.toggle .slider::before {
|
||||||
|
content: ''; position: absolute; left: 2px; top: 2px;
|
||||||
|
width: 16px; height: 16px; background: #8b949e;
|
||||||
|
border-radius: 50%; transition: transform 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.toggle input:checked + .slider { background: #238636; }
|
||||||
|
.toggle input:checked + .slider::before { transform: translateX(16px); background: #fff; }
|
||||||
|
|
||||||
|
.log-panel {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.log-line { padding: 2px 0; line-height: 1.5; }
|
||||||
|
.log-line .time { color: #484f58; }
|
||||||
|
.log-line.info .msg { color: #58a6ff; }
|
||||||
|
.log-line.warn .msg { color: #d29922; }
|
||||||
|
.log-line.error .msg { color: #f85149; }
|
||||||
|
.log-line.debug .msg { color: #8b949e; }
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #484f58;
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.settings-grid.full { grid-template-columns: 1fr; }
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.setting-row label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8b949e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.setting-row input {
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e6edf3;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.setting-row input:focus { border-color: #58a6ff; }
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.saved-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #3fb950;
|
||||||
|
margin-right: 12px;
|
||||||
|
line-height: 32px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
.saved-badge.show { opacity: 1; }
|
||||||
|
|
||||||
|
/* Modal overlay */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 100;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 480px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.modal-header h2 { font-size: 16px; font-weight: 600; }
|
||||||
|
.modal-close {
|
||||||
|
background: none; border: none; color: #8b949e;
|
||||||
|
cursor: pointer; padding: 4px; font-size: 18px; line-height: 1;
|
||||||
|
}
|
||||||
|
.modal-close:hover { background: none; color: #e6edf3; }
|
||||||
|
|
||||||
|
.debug-panel {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.debug-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.debug-row:last-of-type { margin-bottom: 0; }
|
||||||
|
.debug-row input {
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e6edf3;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.debug-row input:focus { border-color: #58a6ff; }
|
||||||
|
.debug-row input[type="text"] { flex: 1; }
|
||||||
|
.debug-result {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8b949e;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.debug-result:empty { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>POE2 Trade Bot</h1>
|
||||||
|
<div class="header-right">
|
||||||
|
<div id="statusBadge">
|
||||||
|
<span class="status-dot idle"></span>
|
||||||
|
<span id="statusText">Connecting...</span>
|
||||||
|
</div>
|
||||||
|
<button class="settings-btn" onclick="openSettings()" title="Settings">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="10" cy="10" r="3"/>
|
||||||
|
<path d="M10 1.5v2M10 16.5v2M3.4 3.4l1.4 1.4M15.2 15.2l1.4 1.4M1.5 10h2M16.5 10h2M3.4 16.6l1.4-1.4M15.2 4.8l1.4-1.4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value" id="stateValue">IDLE</div>
|
||||||
|
<div class="label">State</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value" id="linksValue">0</div>
|
||||||
|
<div class="label">Active Links</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value" id="completedValue">0</div>
|
||||||
|
<div class="label">Trades Done</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value" id="failedValue">0</div>
|
||||||
|
<div class="label">Failed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls" id="controls">
|
||||||
|
<button class="warning" id="pauseBtn" onclick="togglePause()">Pause</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Trade Links</div>
|
||||||
|
<div class="add-link">
|
||||||
|
<input type="text" id="nameInput" placeholder="Name (optional)" style="max-width:180px" />
|
||||||
|
<input type="text" id="urlInput" placeholder="Paste trade URL..." />
|
||||||
|
<button class="primary" onclick="addLink()">Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="links-list" id="linksList">
|
||||||
|
<div class="empty-state">No trade links added yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Debug Tools</div>
|
||||||
|
<div class="debug-panel">
|
||||||
|
<div class="debug-row">
|
||||||
|
<button onclick="debugScreenshot()">Screenshot</button>
|
||||||
|
<button onclick="debugOcr()">OCR Screen</button>
|
||||||
|
</div>
|
||||||
|
<div class="debug-row">
|
||||||
|
<input type="text" id="debugTextInput" placeholder="Text to find (e.g. Stash, Ange)" />
|
||||||
|
<button onclick="debugFindText()">Find</button>
|
||||||
|
<button class="primary" onclick="debugFindAndClick()">Find & Click</button>
|
||||||
|
</div>
|
||||||
|
<div class="debug-row">
|
||||||
|
<input type="number" id="debugClickX" placeholder="X" style="width:80px" />
|
||||||
|
<input type="number" id="debugClickY" placeholder="Y" style="width:80px" />
|
||||||
|
<button onclick="debugClick()">Click At</button>
|
||||||
|
</div>
|
||||||
|
<div class="debug-result" id="debugResult"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Activity Log</div>
|
||||||
|
<div class="log-panel" id="logPanel"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="settingsModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button class="modal-close" onclick="closeSettings()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid full">
|
||||||
|
<div class="setting-row">
|
||||||
|
<label>POE2 Client.txt Path</label>
|
||||||
|
<input type="text" id="settLogPath" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid" style="margin-top:10px">
|
||||||
|
<div class="setting-row">
|
||||||
|
<label>Window Title</label>
|
||||||
|
<input type="text" id="settWindowTitle" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<label>Travel Timeout (ms)</label>
|
||||||
|
<input type="number" id="settTravelTimeout" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<label>Wait for More Items (ms)</label>
|
||||||
|
<input type="number" id="settWaitMore" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<label>Delay Between Trades (ms)</label>
|
||||||
|
<input type="number" id="settTradeDelay" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<span class="saved-badge" id="savedBadge">Saved</span>
|
||||||
|
<button class="primary" onclick="saveSettings()">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let ws;
|
||||||
|
let status = { paused: false, state: 'IDLE', links: [], tradesCompleted: 0, tradesFailed: 0, uptime: 0, settings: {} };
|
||||||
|
let settingsLoaded = false;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(`${proto}//${location.host}`);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'status') {
|
||||||
|
status = msg.data;
|
||||||
|
render();
|
||||||
|
} else if (msg.type === 'log') {
|
||||||
|
addLog(msg.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
addLog({ level: 'warn', message: 'Dashboard disconnected. Reconnecting...', time: new Date().toISOString() });
|
||||||
|
setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
ws.onerror = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
// Status badge
|
||||||
|
const dot = document.querySelector('.status-dot');
|
||||||
|
const text = document.getElementById('statusText');
|
||||||
|
dot.className = 'status-dot ' + (status.paused ? 'paused' : status.state === 'IDLE' ? 'idle' : 'running');
|
||||||
|
text.textContent = status.paused ? 'Paused' : status.state;
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
document.getElementById('stateValue').textContent = status.state;
|
||||||
|
document.getElementById('linksValue').textContent = status.links.length;
|
||||||
|
document.getElementById('completedValue').textContent = status.tradesCompleted;
|
||||||
|
document.getElementById('failedValue').textContent = status.tradesFailed;
|
||||||
|
|
||||||
|
// Pause button
|
||||||
|
const btn = document.getElementById('pauseBtn');
|
||||||
|
btn.textContent = status.paused ? 'Resume' : 'Pause';
|
||||||
|
btn.className = status.paused ? 'primary' : 'warning';
|
||||||
|
|
||||||
|
// Settings (populate once on first status)
|
||||||
|
if (status.settings) populateSettings(status.settings);
|
||||||
|
|
||||||
|
// Active links count
|
||||||
|
document.getElementById('linksValue').textContent = status.links.filter(l => l.active).length;
|
||||||
|
|
||||||
|
// Links list
|
||||||
|
const list = document.getElementById('linksList');
|
||||||
|
if (status.links.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty-state">No trade links added yet</div>';
|
||||||
|
} else {
|
||||||
|
list.innerHTML = status.links.map(link => `
|
||||||
|
<div class="link-item${link.active ? '' : ' inactive'}">
|
||||||
|
<div class="link-left">
|
||||||
|
<label class="toggle" title="${link.active ? 'Active' : 'Inactive'}">
|
||||||
|
<input type="checkbox" ${link.active ? 'checked' : ''} onchange="toggleLink('${esc(link.id)}', this.checked)" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<div class="link-info">
|
||||||
|
<div class="link-name" contenteditable="true" spellcheck="false"
|
||||||
|
onblur="renameLink('${esc(link.id)}', this.textContent)"
|
||||||
|
title="Click to edit name">${esc(link.name || link.label)}</div>
|
||||||
|
<div class="link-url">${esc(link.url)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="link-actions">
|
||||||
|
<button class="danger" onclick="removeLink('${esc(link.id)}')">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLog(data) {
|
||||||
|
const panel = document.getElementById('logPanel');
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'log-line ' + (data.level || 'info');
|
||||||
|
const t = new Date(data.time).toLocaleTimeString();
|
||||||
|
line.innerHTML = `<span class="time">${t}</span> <span class="msg">${esc(data.message)}</span>`;
|
||||||
|
panel.appendChild(line);
|
||||||
|
if (panel.children.length > 500) panel.removeChild(panel.firstChild);
|
||||||
|
panel.scrollTop = panel.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePause() {
|
||||||
|
const endpoint = status.paused ? '/api/resume' : '/api/pause';
|
||||||
|
await fetch(endpoint, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addLink() {
|
||||||
|
const urlEl = document.getElementById('urlInput');
|
||||||
|
const nameEl = document.getElementById('nameInput');
|
||||||
|
const url = urlEl.value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
await fetch('/api/links', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url, name: nameEl.value.trim() }),
|
||||||
|
});
|
||||||
|
urlEl.value = '';
|
||||||
|
nameEl.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLink(id) {
|
||||||
|
await fetch('/api/links/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLink(id, active) {
|
||||||
|
await fetch('/api/links/' + encodeURIComponent(id) + '/toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ active }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let renameTimer = null;
|
||||||
|
async function renameLink(id, name) {
|
||||||
|
clearTimeout(renameTimer);
|
||||||
|
renameTimer = setTimeout(async () => {
|
||||||
|
await fetch('/api/links/' + encodeURIComponent(id) + '/name', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name.trim() }),
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateSettings(s) {
|
||||||
|
if (!s || settingsLoaded) return;
|
||||||
|
settingsLoaded = true;
|
||||||
|
document.getElementById('settLogPath').value = s.poe2LogPath || '';
|
||||||
|
document.getElementById('settWindowTitle').value = s.poe2WindowTitle || '';
|
||||||
|
document.getElementById('settTravelTimeout').value = s.travelTimeoutMs || 15000;
|
||||||
|
document.getElementById('settWaitMore').value = s.waitForMoreItemsMs || 20000;
|
||||||
|
document.getElementById('settTradeDelay').value = s.betweenTradesDelayMs || 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
// Re-populate from latest status in case it changed
|
||||||
|
if (status.settings) {
|
||||||
|
const s = status.settings;
|
||||||
|
document.getElementById('settLogPath').value = s.poe2LogPath || '';
|
||||||
|
document.getElementById('settWindowTitle').value = s.poe2WindowTitle || '';
|
||||||
|
document.getElementById('settTravelTimeout').value = s.travelTimeoutMs || 15000;
|
||||||
|
document.getElementById('settWaitMore').value = s.waitForMoreItemsMs || 20000;
|
||||||
|
document.getElementById('settTradeDelay').value = s.betweenTradesDelayMs || 5000;
|
||||||
|
}
|
||||||
|
document.getElementById('settingsModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() {
|
||||||
|
document.getElementById('settingsModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on overlay click
|
||||||
|
document.getElementById('settingsModal').addEventListener('click', (e) => {
|
||||||
|
if (e.target === e.currentTarget) closeSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
const body = {
|
||||||
|
poe2LogPath: document.getElementById('settLogPath').value,
|
||||||
|
poe2WindowTitle: document.getElementById('settWindowTitle').value,
|
||||||
|
travelTimeoutMs: parseInt(document.getElementById('settTravelTimeout').value) || 15000,
|
||||||
|
waitForMoreItemsMs: parseInt(document.getElementById('settWaitMore').value) || 20000,
|
||||||
|
betweenTradesDelayMs: parseInt(document.getElementById('settTradeDelay').value) || 5000,
|
||||||
|
};
|
||||||
|
await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const badge = document.getElementById('savedBadge');
|
||||||
|
badge.classList.add('show');
|
||||||
|
setTimeout(() => badge.classList.remove('show'), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug functions
|
||||||
|
async function debugScreenshot() {
|
||||||
|
const res = await fetch('/api/debug/screenshot', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
showDebugResult(data.ok ? `Screenshot saved: ${data.filename}` : `Error: ${data.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function debugOcr() {
|
||||||
|
showDebugResult('Running OCR...');
|
||||||
|
const res = await fetch('/api/debug/ocr', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
showDebugResult(data.ok ? data.text : `Error: ${data.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function debugFindText() {
|
||||||
|
const text = document.getElementById('debugTextInput').value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
showDebugResult(`Searching for "${text}"...`);
|
||||||
|
const res = await fetch('/api/debug/find-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.found) {
|
||||||
|
showDebugResult(`Found "${text}" at (${data.position.x}, ${data.position.y})`);
|
||||||
|
document.getElementById('debugClickX').value = data.position.x;
|
||||||
|
document.getElementById('debugClickY').value = data.position.y;
|
||||||
|
} else {
|
||||||
|
showDebugResult(`"${text}" not found on screen`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function debugFindAndClick() {
|
||||||
|
const text = document.getElementById('debugTextInput').value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
showDebugResult(`Finding and clicking "${text}"...`);
|
||||||
|
const res = await fetch('/api/debug/find-and-click', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.found) {
|
||||||
|
showDebugResult(`Clicked "${text}" at (${data.position.x}, ${data.position.y})`);
|
||||||
|
} else {
|
||||||
|
showDebugResult(`"${text}" not found on screen`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function debugClick() {
|
||||||
|
const x = parseInt(document.getElementById('debugClickX').value);
|
||||||
|
const y = parseInt(document.getElementById('debugClickY').value);
|
||||||
|
if (isNaN(x) || isNaN(y)) return;
|
||||||
|
const res = await fetch('/api/debug/click', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ x, y }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
showDebugResult(data.ok ? `Clicked at (${x}, ${y})` : `Error: ${data.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDebugResult(text) {
|
||||||
|
document.getElementById('debugResult').textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter key in debug text input
|
||||||
|
document.getElementById('debugTextInput').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') debugFindText();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter key in URL input
|
||||||
|
document.getElementById('urlInput').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') addLink();
|
||||||
|
});
|
||||||
|
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
251
src/executor/TradeExecutor.ts
Normal file
251
src/executor/TradeExecutor.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
import { GameController } from '../game/GameController.js';
|
||||||
|
import { ScreenReader } from '../game/ScreenReader.js';
|
||||||
|
import { ClientLogWatcher } from '../log/ClientLogWatcher.js';
|
||||||
|
import { TradeMonitor } from '../trade/TradeMonitor.js';
|
||||||
|
import { sleep, randomDelay } from '../util/sleep.js';
|
||||||
|
import { 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 logWatcher: ClientLogWatcher;
|
||||||
|
private tradeMonitor: TradeMonitor;
|
||||||
|
private config: Config;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
gameController: GameController,
|
||||||
|
screenReader: ScreenReader,
|
||||||
|
logWatcher: ClientLogWatcher,
|
||||||
|
tradeMonitor: TradeMonitor,
|
||||||
|
config: Config,
|
||||||
|
) {
|
||||||
|
this.gameController = gameController;
|
||||||
|
this.screenReader = screenReader;
|
||||||
|
this.logWatcher = logWatcher;
|
||||||
|
this.tradeMonitor = tradeMonitor;
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): TradeState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeTrade(trade: TradeInfo): Promise<boolean> {
|
||||||
|
const page = trade.page as Page;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Click "Travel to Hideout" on the trade website
|
||||||
|
this.state = 'TRAVELING';
|
||||||
|
logger.info({ searchId: trade.searchId }, 'Clicking Travel to Hideout...');
|
||||||
|
|
||||||
|
const travelClicked = await this.tradeMonitor.clickTravelToHideout(
|
||||||
|
page,
|
||||||
|
trade.itemIds[0],
|
||||||
|
);
|
||||||
|
if (!travelClicked) {
|
||||||
|
logger.error('Failed to click Travel to Hideout');
|
||||||
|
this.state = 'FAILED';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Wait for area transition (arrival at seller's hideout)
|
||||||
|
logger.info('Waiting for area transition...');
|
||||||
|
const arrived = await this.waitForAreaTransition(this.config.travelTimeoutMs);
|
||||||
|
if (!arrived) {
|
||||||
|
logger.error('Timed out waiting for hideout arrival');
|
||||||
|
this.state = 'FAILED';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'IN_SELLERS_HIDEOUT';
|
||||||
|
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.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.findAndClickNameplate('Stash');
|
||||||
|
if (!stashPos) {
|
||||||
|
logger.error('Could not find Stash nameplate in seller hideout');
|
||||||
|
this.state = 'FAILED';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await sleep(1000); // Wait for stash to open
|
||||||
|
|
||||||
|
// Step 4: Scan stash and buy items
|
||||||
|
this.state = 'SCANNING_STASH';
|
||||||
|
logger.info('Scanning stash for items...');
|
||||||
|
|
||||||
|
await this.scanAndBuyItems();
|
||||||
|
|
||||||
|
// Step 5: Wait for more items
|
||||||
|
this.state = '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.state = 'GOING_HOME';
|
||||||
|
logger.info('Traveling to own hideout...');
|
||||||
|
await this.gameController.focusGame();
|
||||||
|
await sleep(300);
|
||||||
|
await this.gameController.goToHideout();
|
||||||
|
|
||||||
|
const home = await this.waitForAreaTransition(this.config.travelTimeoutMs);
|
||||||
|
if (!home) {
|
||||||
|
logger.warn('Timed out going home, continuing anyway...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Store items in stash
|
||||||
|
this.state = 'IN_HIDEOUT';
|
||||||
|
await sleep(1000);
|
||||||
|
await this.storeItems();
|
||||||
|
|
||||||
|
this.state = 'IDLE';
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Trade execution failed');
|
||||||
|
this.state = '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.state = '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.state = '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 in stash...');
|
||||||
|
|
||||||
|
// Focus game and find Stash in own hideout
|
||||||
|
await this.gameController.focusGame();
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const stashPos = await this.findAndClickNameplate('Stash');
|
||||||
|
if (!stashPos) {
|
||||||
|
logger.error('Could not find Stash nameplate in own hideout');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(1000); // Wait for stash to open
|
||||||
|
|
||||||
|
// Open inventory
|
||||||
|
await this.gameController.openInventory();
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// TODO: Implement inventory scanning to find purchased items
|
||||||
|
// and Ctrl+right-click each to transfer to stash
|
||||||
|
|
||||||
|
logger.info('Item storage complete (needs calibration)');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findAndClickNameplate(
|
||||||
|
name: string,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
retryDelayMs: number = 1000,
|
||||||
|
): Promise<{ x: number; y: number } | null> {
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
logger.info({ name, attempt, maxRetries }, 'Searching for nameplate...');
|
||||||
|
const pos = await this.screenReader.findTextOnScreen(name);
|
||||||
|
|
||||||
|
if (pos) {
|
||||||
|
logger.info({ name, x: pos.x, y: pos.y }, 'Clicking nameplate');
|
||||||
|
await this.gameController.leftClickAt(pos.x, pos.y);
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
logger.debug({ name, attempt }, 'Nameplate not found, retrying...');
|
||||||
|
await sleep(retryDelayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn({ name, maxRetries }, 'Nameplate not found after all retries');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitForAreaTransition(timeoutMs: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.logWatcher.removeListener('area-entered', handler);
|
||||||
|
resolve(false);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const handler = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logWatcher.once('area-entered', handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/executor/TradeQueue.ts
Normal file
69
src/executor/TradeQueue.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/game/GameController.ts
Normal file
107
src/game/GameController.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
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.DELETE);
|
||||||
|
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.DELETE);
|
||||||
|
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.sendChat('/hideout');
|
||||||
|
}
|
||||||
|
|
||||||
|
async ctrlRightClickAt(x: number, y: number): Promise<void> {
|
||||||
|
await this.inputSender.ctrlRightClick(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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
294
src/game/InputSender.ts
Normal file
294
src/game/InputSender.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
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 / 15), 15, 40);
|
||||||
|
|
||||||
|
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(2 + Math.random() * 3); // 2-5ms between steps
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final exact landing
|
||||||
|
this.moveMouseRaw(x, y);
|
||||||
|
await randomDelay(10, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
async leftClick(x: number, y: number): Promise<void> {
|
||||||
|
await this.moveMouse(x, y);
|
||||||
|
await randomDelay(50, 100);
|
||||||
|
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_LEFTDOWN);
|
||||||
|
await randomDelay(30, 80);
|
||||||
|
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_LEFTUP);
|
||||||
|
await randomDelay(30, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rightClick(x: number, y: number): Promise<void> {
|
||||||
|
await this.moveMouse(x, y);
|
||||||
|
await randomDelay(50, 100);
|
||||||
|
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_RIGHTDOWN);
|
||||||
|
await randomDelay(30, 80);
|
||||||
|
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_RIGHTUP);
|
||||||
|
await randomDelay(30, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/game/ScreenReader.ts
Normal file
129
src/game/ScreenReader.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { mkdir } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { logger } from '../util/logger.js';
|
||||||
|
import { OcrDaemon, type OcrResponse } from './OcrDaemon.js';
|
||||||
|
import type { Region } from '../types.js';
|
||||||
|
|
||||||
|
function elapsed(start: number): string {
|
||||||
|
return `${(performance.now() - start).toFixed(0)}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScreenReader {
|
||||||
|
private daemon = new OcrDaemon();
|
||||||
|
|
||||||
|
// ── 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 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private findWordInOcrResult(
|
||||||
|
result: OcrResponse,
|
||||||
|
needle: string,
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
const lower = needle.toLowerCase();
|
||||||
|
for (const line of result.lines) {
|
||||||
|
for (const word of line.words) {
|
||||||
|
if (word.text.toLowerCase().includes(lower)) {
|
||||||
|
return {
|
||||||
|
x: Math.round(word.x + word.width / 2),
|
||||||
|
y: Math.round(word.y + word.height / 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full-screen methods ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async findTextOnScreen(
|
||||||
|
searchText: string,
|
||||||
|
): Promise<{ x: number; y: number } | null> {
|
||||||
|
const t = performance.now();
|
||||||
|
const result = await this.daemon.ocr();
|
||||||
|
const pos = this.findWordInOcrResult(result, searchText);
|
||||||
|
|
||||||
|
if (pos) {
|
||||||
|
logger.info({ searchText, x: pos.x, y: pos.y, totalMs: elapsed(t) }, 'Found text on screen');
|
||||||
|
} else {
|
||||||
|
logger.info({ searchText, totalMs: elapsed(t) }, 'Text not found on screen');
|
||||||
|
}
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFullScreen(): Promise<string> {
|
||||||
|
const result = await this.daemon.ocr();
|
||||||
|
return result.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Region methods ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async findTextInRegion(
|
||||||
|
region: Region,
|
||||||
|
searchText: string,
|
||||||
|
): Promise<{ x: number; y: number } | null> {
|
||||||
|
const t = performance.now();
|
||||||
|
const result = await this.daemon.ocr(region);
|
||||||
|
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 result = await this.daemon.ocr(region);
|
||||||
|
return result.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkForText(region: Region, searchText: string): Promise<boolean> {
|
||||||
|
const pos = await this.findTextInRegion(region, searchText);
|
||||||
|
return pos !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/game/WindowManager.ts
Normal file
90
src/game/WindowManager.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
190
src/index.ts
Normal file
190
src/index.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { loadConfig } from './config.js';
|
||||||
|
import { TradeMonitor } from './trade/TradeMonitor.js';
|
||||||
|
import { GameController } from './game/GameController.js';
|
||||||
|
import { ScreenReader } from './game/ScreenReader.js';
|
||||||
|
import { ClientLogWatcher } from './log/ClientLogWatcher.js';
|
||||||
|
import { TradeExecutor } from './executor/TradeExecutor.js';
|
||||||
|
import { TradeQueue } from './executor/TradeQueue.js';
|
||||||
|
import { BotController } from './dashboard/BotController.js';
|
||||||
|
import { DashboardServer } from './dashboard/DashboardServer.js';
|
||||||
|
import { ConfigStore } from './dashboard/ConfigStore.js';
|
||||||
|
import { logger } from './util/logger.js';
|
||||||
|
import type { Page } from 'playwright';
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// Load persisted config
|
||||||
|
const store = new ConfigStore(options.config);
|
||||||
|
const saved = store.settings;
|
||||||
|
|
||||||
|
// CLI/env overrides persisted values
|
||||||
|
const envConfig = loadConfig(options.url);
|
||||||
|
if (options.logPath) envConfig.poe2LogPath = options.logPath;
|
||||||
|
|
||||||
|
// Merge: CLI args > .env > config.json defaults
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Collect all URLs: CLI args + saved links (deduped)
|
||||||
|
const allUrls = new Set<string>([
|
||||||
|
...config.tradeUrls,
|
||||||
|
...saved.links.map((l) => l.url),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Initialize bot controller with config store
|
||||||
|
const bot = new BotController(store);
|
||||||
|
|
||||||
|
// Start dashboard
|
||||||
|
const dashboard = new DashboardServer(bot, port);
|
||||||
|
await dashboard.start();
|
||||||
|
|
||||||
|
// Initialize game components
|
||||||
|
const screenReader = new ScreenReader();
|
||||||
|
|
||||||
|
const gameController = new GameController(config);
|
||||||
|
dashboard.setDebugDeps({ screenReader, gameController });
|
||||||
|
|
||||||
|
const logWatcher = new ClientLogWatcher(config.poe2LogPath);
|
||||||
|
await logWatcher.start();
|
||||||
|
dashboard.broadcastLog('info', 'Watching Client.txt for game events');
|
||||||
|
|
||||||
|
const tradeMonitor = new TradeMonitor(config);
|
||||||
|
await tradeMonitor.start(`http://localhost:${port}`);
|
||||||
|
dashboard.broadcastLog('info', 'Browser launched');
|
||||||
|
|
||||||
|
const executor = new TradeExecutor(
|
||||||
|
gameController,
|
||||||
|
screenReader,
|
||||||
|
logWatcher,
|
||||||
|
tradeMonitor,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tradeQueue = new TradeQueue(executor, config);
|
||||||
|
|
||||||
|
// Helper to add a trade search
|
||||||
|
const activateLink = async (url: string) => {
|
||||||
|
try {
|
||||||
|
await tradeMonitor.addSearch(url);
|
||||||
|
dashboard.broadcastLog('info', `Monitoring: ${url}`);
|
||||||
|
dashboard.broadcastStatus();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err, url }, 'Failed to add trade search');
|
||||||
|
dashboard.broadcastLog('error', `Failed to add: ${url}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load all saved + CLI links (only activate ones marked active)
|
||||||
|
for (const url of allUrls) {
|
||||||
|
const link = bot.addLink(url);
|
||||||
|
if (link.active) {
|
||||||
|
await activateLink(url);
|
||||||
|
} else {
|
||||||
|
dashboard.broadcastLog('info', `Loaded (inactive): ${link.name || link.label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboard.broadcastLog('info', `Loaded ${allUrls.size} trade link(s) from config`);
|
||||||
|
|
||||||
|
// When dashboard adds a link, activate it in the trade monitor
|
||||||
|
bot.on('link-added', async (link) => {
|
||||||
|
if (link.active) {
|
||||||
|
await activateLink(link.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// When dashboard removes a link, deactivate it
|
||||||
|
bot.on('link-removed', async (id: string) => {
|
||||||
|
await tradeMonitor.removeSearch(id);
|
||||||
|
dashboard.broadcastLog('info', `Removed search: ${id}`);
|
||||||
|
dashboard.broadcastStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// When dashboard toggles a link active/inactive
|
||||||
|
bot.on('link-toggled', async (data: { id: string; active: boolean; link: { url: string; name: string } }) => {
|
||||||
|
if (data.active) {
|
||||||
|
await activateLink(data.link.url);
|
||||||
|
dashboard.broadcastLog('info', `Activated: ${data.link.name || data.id}`);
|
||||||
|
} else {
|
||||||
|
await tradeMonitor.pauseSearch(data.id);
|
||||||
|
dashboard.broadcastLog('info', `Deactivated: ${data.link.name || data.id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire up events: when new listings appear, queue them for trading
|
||||||
|
tradeMonitor.on('new-listings', (data: { searchId: string; itemIds: string[]; page: Page }) => {
|
||||||
|
if (bot.isPaused) {
|
||||||
|
dashboard.broadcastLog('warn', `New listings (${data.itemIds.length}) skipped - bot paused`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this specific link is active
|
||||||
|
if (!bot.isLinkActive(data.searchId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ searchId: data.searchId, itemCount: data.itemIds.length },
|
||||||
|
'New listings received, queuing trade...',
|
||||||
|
);
|
||||||
|
dashboard.broadcastLog('info', `New listings: ${data.itemIds.length} items from ${data.searchId}`);
|
||||||
|
|
||||||
|
tradeQueue.enqueue({
|
||||||
|
searchId: data.searchId,
|
||||||
|
itemIds: data.itemIds,
|
||||||
|
whisperText: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
tradeUrl: '',
|
||||||
|
page: data.page,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward executor state changes to dashboard
|
||||||
|
const stateInterval = setInterval(() => {
|
||||||
|
const execState = executor.getState();
|
||||||
|
if (bot.state !== execState) {
|
||||||
|
bot.state = execState;
|
||||||
|
dashboard.broadcastStatus();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const shutdown = async () => {
|
||||||
|
logger.info('Shutting down...');
|
||||||
|
clearInterval(stateInterval);
|
||||||
|
await screenReader.dispose();
|
||||||
|
await dashboard.stop();
|
||||||
|
await tradeMonitor.stop();
|
||||||
|
await logWatcher.stop();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
|
||||||
|
logger.info(`Dashboard: http://localhost:${port}`);
|
||||||
|
logger.info(
|
||||||
|
`Monitoring ${allUrls.size} trade search(es). Press Ctrl+C to stop.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
program.parse();
|
||||||
130
src/log/ClientLogWatcher.ts
Normal file
130
src/log/ClientLogWatcher.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { watch } from 'chokidar';
|
||||||
|
import { createReadStream, statSync } 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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
} 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 }, 'Watching Client.txt for game events');
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "You have entered Hideout"
|
||||||
|
const areaMatch = line.match(/You have entered (.+?)\.?$/);
|
||||||
|
if (areaMatch) {
|
||||||
|
const area = areaMatch[1];
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
256
src/trade/TradeMonitor.ts
Normal file
256
src/trade/TradeMonitor.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
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 } 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/trade/selectors.ts
Normal file
30
src/trade/selectors.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// 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;
|
||||||
58
src/types.ts
Normal file
58
src/types.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
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>;
|
||||||
|
}
|
||||||
13
src/util/clipboard.ts
Normal file
13
src/util/clipboard.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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, "''")}'"`);
|
||||||
|
}
|
||||||
12
src/util/logger.ts
Normal file
12
src/util/logger.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
transport: {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: 'HH:MM:ss',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
24
src/util/retry.ts
Normal file
24
src/util/retry.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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');
|
||||||
|
}
|
||||||
8
src/util/sleep.ts
Normal file
8
src/util/sleep.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
14
tools/OcrDaemon/OcrDaemon.csproj
Normal file
14
tools/OcrDaemon/OcrDaemon.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.Drawing.Common" Version="8.0.12" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
293
tools/OcrDaemon/Program.cs
Normal file
293
tools/OcrDaemon/Program.cs
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Windows.Graphics.Imaging;
|
||||||
|
using Windows.Media.Ocr;
|
||||||
|
using Windows.Storage.Streams;
|
||||||
|
|
||||||
|
// Make GDI capture DPI-aware so coordinates match physical pixels
|
||||||
|
SetProcessDPIAware();
|
||||||
|
|
||||||
|
// Pre-create the OCR engine (reused across all requests)
|
||||||
|
var ocrEngine = OcrEngine.TryCreateFromUserProfileLanguages();
|
||||||
|
if (ocrEngine == null)
|
||||||
|
{
|
||||||
|
WriteResponse(new ErrorResponse("Failed to create OCR engine. Ensure a language pack is installed."));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal ready
|
||||||
|
WriteResponse(new ReadyResponse());
|
||||||
|
|
||||||
|
// JSON options
|
||||||
|
var jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main loop: read one JSON line, handle, write one JSON line
|
||||||
|
var stdin = Console.In;
|
||||||
|
string? line;
|
||||||
|
while ((line = stdin.ReadLine()) != null)
|
||||||
|
{
|
||||||
|
line = line.Trim();
|
||||||
|
if (line.Length == 0) continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = JsonSerializer.Deserialize<Request>(line, jsonOptions);
|
||||||
|
if (request == null)
|
||||||
|
{
|
||||||
|
WriteResponse(new ErrorResponse("Failed to parse request"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (request.Cmd?.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "ocr":
|
||||||
|
HandleOcr(request, ocrEngine);
|
||||||
|
break;
|
||||||
|
case "screenshot":
|
||||||
|
HandleScreenshot(request);
|
||||||
|
break;
|
||||||
|
case "capture":
|
||||||
|
HandleCapture(request);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
WriteResponse(new ErrorResponse($"Unknown command: {request.Cmd}"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WriteResponse(new ErrorResponse(ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void HandleOcr(Request req, OcrEngine engine)
|
||||||
|
{
|
||||||
|
using var bitmap = CaptureScreen(req.Region);
|
||||||
|
var softwareBitmap = BitmapToSoftwareBitmap(bitmap);
|
||||||
|
var result = engine.RecognizeAsync(softwareBitmap).AsTask().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
var lines = new List<OcrLineResult>();
|
||||||
|
foreach (var ocrLine in result.Lines)
|
||||||
|
{
|
||||||
|
var words = new List<OcrWordResult>();
|
||||||
|
foreach (var word in ocrLine.Words)
|
||||||
|
{
|
||||||
|
words.Add(new OcrWordResult
|
||||||
|
{
|
||||||
|
Text = word.Text,
|
||||||
|
X = (int)Math.Round(word.BoundingRect.X),
|
||||||
|
Y = (int)Math.Round(word.BoundingRect.Y),
|
||||||
|
Width = (int)Math.Round(word.BoundingRect.Width),
|
||||||
|
Height = (int)Math.Round(word.BoundingRect.Height),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lines.Add(new OcrLineResult { Text = ocrLine.Text, Words = words });
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteResponse(new OcrResponse { Text = result.Text, Lines = lines });
|
||||||
|
}
|
||||||
|
|
||||||
|
void HandleScreenshot(Request req)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(req.Path))
|
||||||
|
{
|
||||||
|
WriteResponse(new ErrorResponse("screenshot command requires 'path'"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var bitmap = CaptureScreen(req.Region);
|
||||||
|
var format = GetImageFormat(req.Path);
|
||||||
|
bitmap.Save(req.Path, format);
|
||||||
|
WriteResponse(new OkResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
void HandleCapture(Request req)
|
||||||
|
{
|
||||||
|
using var bitmap = CaptureScreen(req.Region);
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
bitmap.Save(ms, ImageFormat.Png);
|
||||||
|
var base64 = Convert.ToBase64String(ms.ToArray());
|
||||||
|
WriteResponse(new CaptureResponse { Image = base64 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Screen Capture ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Bitmap CaptureScreen(RegionRect? region)
|
||||||
|
{
|
||||||
|
int x, y, w, h;
|
||||||
|
if (region != null)
|
||||||
|
{
|
||||||
|
x = region.X;
|
||||||
|
y = region.Y;
|
||||||
|
w = region.Width;
|
||||||
|
h = region.Height;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Primary monitor only (0,0 origin, SM_CXSCREEN / SM_CYSCREEN)
|
||||||
|
x = 0;
|
||||||
|
y = 0;
|
||||||
|
w = GetSystemMetrics(0); // SM_CXSCREEN
|
||||||
|
h = GetSystemMetrics(1); // SM_CYSCREEN
|
||||||
|
}
|
||||||
|
|
||||||
|
var bitmap = new Bitmap(w, h, PixelFormat.Format32bppArgb);
|
||||||
|
using var g = Graphics.FromImage(bitmap);
|
||||||
|
g.CopyFromScreen(x, y, 0, 0, new Size(w, h), CopyPixelOperation.SourceCopy);
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bitmap → SoftwareBitmap conversion (in-memory) ─────────────────────────
|
||||||
|
|
||||||
|
SoftwareBitmap BitmapToSoftwareBitmap(Bitmap bitmap)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
bitmap.Save(ms, ImageFormat.Bmp);
|
||||||
|
ms.Position = 0;
|
||||||
|
|
||||||
|
var stream = ms.AsRandomAccessStream();
|
||||||
|
var decoder = BitmapDecoder.CreateAsync(stream).AsTask().GetAwaiter().GetResult();
|
||||||
|
var softwareBitmap = decoder.GetSoftwareBitmapAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
return softwareBitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response writing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void WriteResponse(object response)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(response, jsonOptions);
|
||||||
|
Console.Out.WriteLine(json);
|
||||||
|
Console.Out.Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageFormat GetImageFormat(string path)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
return ext switch
|
||||||
|
{
|
||||||
|
".jpg" or ".jpeg" => ImageFormat.Jpeg,
|
||||||
|
".bmp" => ImageFormat.Bmp,
|
||||||
|
_ => ImageFormat.Png,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── P/Invoke ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
static extern bool SetProcessDPIAware();
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
static extern int GetSystemMetrics(int nIndex);
|
||||||
|
|
||||||
|
// ── Request / Response Models ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Request
|
||||||
|
{
|
||||||
|
[JsonPropertyName("cmd")]
|
||||||
|
public string? Cmd { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("region")]
|
||||||
|
public RegionRect? Region { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("path")]
|
||||||
|
public string? Path { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class RegionRect
|
||||||
|
{
|
||||||
|
[JsonPropertyName("x")]
|
||||||
|
public int X { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("y")]
|
||||||
|
public int Y { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("width")]
|
||||||
|
public int Width { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("height")]
|
||||||
|
public int Height { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReadyResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("ok")]
|
||||||
|
public bool Ok => true;
|
||||||
|
|
||||||
|
[JsonPropertyName("ready")]
|
||||||
|
public bool Ready => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OkResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("ok")]
|
||||||
|
public bool Ok => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorResponse(string message)
|
||||||
|
{
|
||||||
|
[JsonPropertyName("ok")]
|
||||||
|
public bool Ok => false;
|
||||||
|
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public string Error => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OcrResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("ok")]
|
||||||
|
public bool Ok => true;
|
||||||
|
|
||||||
|
[JsonPropertyName("text")]
|
||||||
|
public string Text { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("lines")]
|
||||||
|
public List<OcrLineResult> Lines { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class OcrLineResult
|
||||||
|
{
|
||||||
|
[JsonPropertyName("text")]
|
||||||
|
public string Text { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("words")]
|
||||||
|
public List<OcrWordResult> Words { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class OcrWordResult
|
||||||
|
{
|
||||||
|
[JsonPropertyName("text")]
|
||||||
|
public string Text { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("x")]
|
||||||
|
public int X { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("y")]
|
||||||
|
public int Y { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("width")]
|
||||||
|
public int Width { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("height")]
|
||||||
|
public int Height { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class CaptureResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("ok")]
|
||||||
|
public bool Ok => true;
|
||||||
|
|
||||||
|
[JsonPropertyName("image")]
|
||||||
|
public string Image { get; set; } = "";
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue