cleanup
This commit is contained in:
parent
3d7a8aafdf
commit
c1892230b7
13 changed files with 1111 additions and 993 deletions
391
src/bot/Bot.ts
Normal file
391
src/bot/Bot.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { logger } from '../util/logger.js';
|
||||||
|
import { LinkManager } from './LinkManager.js';
|
||||||
|
import { GameController } from '../game/GameController.js';
|
||||||
|
import { ScreenReader } from '../game/ScreenReader.js';
|
||||||
|
import { ClientLogWatcher } from '../log/ClientLogWatcher.js';
|
||||||
|
import { TradeMonitor } from '../trade/TradeMonitor.js';
|
||||||
|
import { InventoryManager } from '../inventory/InventoryManager.js';
|
||||||
|
import { TradeExecutor } from '../executor/TradeExecutor.js';
|
||||||
|
import { TradeQueue } from '../executor/TradeQueue.js';
|
||||||
|
import { ScrapExecutor } from '../executor/ScrapExecutor.js';
|
||||||
|
import type { TradeLink } from './LinkManager.js';
|
||||||
|
import type { ConfigStore } from './ConfigStore.js';
|
||||||
|
import type { Config, LinkMode, PostAction } from '../types.js';
|
||||||
|
import type { Page } from 'playwright';
|
||||||
|
|
||||||
|
export interface BotStatus {
|
||||||
|
paused: boolean;
|
||||||
|
state: string;
|
||||||
|
links: TradeLink[];
|
||||||
|
tradesCompleted: number;
|
||||||
|
tradesFailed: number;
|
||||||
|
uptime: number;
|
||||||
|
settings: {
|
||||||
|
poe2LogPath: string;
|
||||||
|
poe2WindowTitle: string;
|
||||||
|
travelTimeoutMs: number;
|
||||||
|
waitForMoreItemsMs: number;
|
||||||
|
betweenTradesDelayMs: number;
|
||||||
|
};
|
||||||
|
inventory?: {
|
||||||
|
grid: boolean[][];
|
||||||
|
items: { row: number; col: number; w: number; h: number }[];
|
||||||
|
free: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Bot extends EventEmitter {
|
||||||
|
private paused: boolean;
|
||||||
|
private _state = 'IDLE';
|
||||||
|
private tradesCompleted = 0;
|
||||||
|
private tradesFailed = 0;
|
||||||
|
private startTime = Date.now();
|
||||||
|
private _inventory: BotStatus['inventory'] = undefined;
|
||||||
|
private _started = false;
|
||||||
|
|
||||||
|
readonly links: LinkManager;
|
||||||
|
readonly store: ConfigStore;
|
||||||
|
readonly config: Config;
|
||||||
|
|
||||||
|
gameController!: GameController;
|
||||||
|
screenReader!: ScreenReader;
|
||||||
|
logWatcher!: ClientLogWatcher;
|
||||||
|
tradeMonitor!: TradeMonitor;
|
||||||
|
inventoryManager!: InventoryManager;
|
||||||
|
tradeExecutor!: TradeExecutor;
|
||||||
|
tradeQueue!: TradeQueue;
|
||||||
|
scrapExecutors = new Map<string, ScrapExecutor>();
|
||||||
|
|
||||||
|
constructor(store: ConfigStore, config: Config) {
|
||||||
|
super();
|
||||||
|
this.store = store;
|
||||||
|
this.config = config;
|
||||||
|
this.paused = store.settings.paused;
|
||||||
|
this.links = new LinkManager(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReady(): boolean {
|
||||||
|
return this._started;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPaused(): boolean {
|
||||||
|
return this.paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
get state(): string {
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
set state(s: string) {
|
||||||
|
if (this._state !== s) {
|
||||||
|
this._state = s;
|
||||||
|
this.emit('status-update');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
this.paused = true;
|
||||||
|
this.store.setPaused(true);
|
||||||
|
logger.info('Bot paused');
|
||||||
|
this.emit('status-update');
|
||||||
|
}
|
||||||
|
|
||||||
|
resume(): void {
|
||||||
|
this.paused = false;
|
||||||
|
this.store.setPaused(false);
|
||||||
|
logger.info('Bot resumed');
|
||||||
|
this.emit('status-update');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Link operations (delegate to LinkManager + emit events) ---
|
||||||
|
|
||||||
|
addLink(url: string, name?: string, mode?: LinkMode, postAction?: PostAction): TradeLink {
|
||||||
|
const link = this.links.addLink(url, name, mode, postAction);
|
||||||
|
this.emit('link-added', link);
|
||||||
|
this.emit('status-update');
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeLink(id: string): void {
|
||||||
|
this.links.removeLink(id);
|
||||||
|
this.emit('link-removed', id);
|
||||||
|
this.emit('status-update');
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLink(id: string, active: boolean): void {
|
||||||
|
const link = this.links.toggleLink(id, active);
|
||||||
|
if (link) {
|
||||||
|
this.emit('link-toggled', { id, active, link });
|
||||||
|
this.emit('status-update');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLinkName(id: string, name: string): void {
|
||||||
|
this.links.updateName(id, name);
|
||||||
|
this.emit('status-update');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLinkMode(id: string, mode: LinkMode): void {
|
||||||
|
const link = this.links.updateMode(id, mode);
|
||||||
|
if (link) {
|
||||||
|
this.emit('link-mode-changed', { id, mode, link });
|
||||||
|
this.emit('status-update');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLinkPostAction(id: string, postAction: PostAction): void {
|
||||||
|
const link = this.links.updatePostAction(id, postAction);
|
||||||
|
if (link) {
|
||||||
|
this.emit('link-postaction-changed', { id, postAction, link });
|
||||||
|
this.emit('status-update');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLinkActive(searchId: string): boolean {
|
||||||
|
return this.links.isActive(searchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(updates: Record<string, unknown>): void {
|
||||||
|
this.store.updateSettings(updates);
|
||||||
|
this.emit('status-update');
|
||||||
|
}
|
||||||
|
|
||||||
|
setInventory(inv: BotStatus['inventory']): void {
|
||||||
|
this._inventory = inv;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): BotStatus {
|
||||||
|
const s = this.store.settings;
|
||||||
|
return {
|
||||||
|
paused: this.paused,
|
||||||
|
state: this._state,
|
||||||
|
links: this.links.getLinks(),
|
||||||
|
tradesCompleted: this.tradesCompleted,
|
||||||
|
tradesFailed: this.tradesFailed,
|
||||||
|
uptime: Date.now() - this.startTime,
|
||||||
|
settings: {
|
||||||
|
poe2LogPath: s.poe2LogPath,
|
||||||
|
poe2WindowTitle: s.poe2WindowTitle,
|
||||||
|
travelTimeoutMs: s.travelTimeoutMs,
|
||||||
|
waitForMoreItemsMs: s.waitForMoreItemsMs,
|
||||||
|
betweenTradesDelayMs: s.betweenTradesDelayMs,
|
||||||
|
},
|
||||||
|
inventory: this._inventory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by executor state callbacks to update bot state */
|
||||||
|
updateExecutorState(): void {
|
||||||
|
this._inventory = this.inventoryManager.getInventoryState();
|
||||||
|
|
||||||
|
const execState = this.tradeExecutor.getState();
|
||||||
|
if (execState !== 'IDLE') {
|
||||||
|
this.state = execState;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, scrapExec] of this.scrapExecutors) {
|
||||||
|
const scrapState = scrapExec.getState();
|
||||||
|
if (scrapState !== 'IDLE') {
|
||||||
|
this.state = scrapState;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'IDLE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Startup / Shutdown ---
|
||||||
|
|
||||||
|
async start(cliUrls: string[], port: number): Promise<void> {
|
||||||
|
this.screenReader = new ScreenReader();
|
||||||
|
this.gameController = new GameController(this.config);
|
||||||
|
|
||||||
|
this.logWatcher = new ClientLogWatcher(this.config.poe2LogPath);
|
||||||
|
await this.logWatcher.start();
|
||||||
|
this.emit('log', 'info', 'Watching Client.txt for game events');
|
||||||
|
|
||||||
|
this.tradeMonitor = new TradeMonitor(this.config);
|
||||||
|
await this.tradeMonitor.start(`http://localhost:${port}`);
|
||||||
|
this.emit('log', 'info', 'Browser launched');
|
||||||
|
|
||||||
|
this.inventoryManager = new InventoryManager(
|
||||||
|
this.gameController, this.screenReader, this.logWatcher, this.config,
|
||||||
|
);
|
||||||
|
|
||||||
|
// /hideout + waitForAreaTransition
|
||||||
|
this.emit('log', 'info', 'Sending /hideout command...');
|
||||||
|
await this.gameController.focusGame();
|
||||||
|
const arrivedHome = await this.inventoryManager.waitForAreaTransition(
|
||||||
|
this.config.travelTimeoutMs,
|
||||||
|
() => this.gameController.goToHideout(),
|
||||||
|
);
|
||||||
|
if (arrivedHome) {
|
||||||
|
this.inventoryManager.setLocation(true);
|
||||||
|
this.logWatcher.currentArea = 'Hideout';
|
||||||
|
} else {
|
||||||
|
this.inventoryManager.setLocation(true);
|
||||||
|
this.logWatcher.currentArea = 'Hideout';
|
||||||
|
logger.warn('Timed out waiting for hideout transition on startup (may already be in hideout)');
|
||||||
|
}
|
||||||
|
this.state = 'IN_HIDEOUT';
|
||||||
|
this.emit('log', 'info', 'In hideout, ready to trade');
|
||||||
|
|
||||||
|
// Clear leftover inventory
|
||||||
|
this.emit('log', 'info', 'Checking inventory for leftover items...');
|
||||||
|
await this.inventoryManager.clearToStash();
|
||||||
|
this.emit('log', 'info', 'Inventory cleared');
|
||||||
|
|
||||||
|
// Create executors
|
||||||
|
this.tradeExecutor = new TradeExecutor(
|
||||||
|
this.gameController, this.screenReader, this.tradeMonitor,
|
||||||
|
this.inventoryManager, this.config,
|
||||||
|
);
|
||||||
|
this.tradeExecutor.onStateChange = () => this.updateExecutorState();
|
||||||
|
|
||||||
|
this.tradeQueue = new TradeQueue(this.tradeExecutor, this.config);
|
||||||
|
|
||||||
|
// Collect all URLs: CLI args + saved links (deduped)
|
||||||
|
const allUrls = new Set<string>([
|
||||||
|
...cliUrls,
|
||||||
|
...this.store.settings.links.map(l => l.url),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Load links (direct, before wiring events)
|
||||||
|
for (const url of allUrls) {
|
||||||
|
const link = this.links.addLink(url);
|
||||||
|
if (link.active) {
|
||||||
|
await this.activateLink(link);
|
||||||
|
} else {
|
||||||
|
this.emit('log', 'info', `Loaded (inactive): ${link.name || link.label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire events for subsequent UI-triggered changes
|
||||||
|
this.wireEvents();
|
||||||
|
|
||||||
|
this._started = true;
|
||||||
|
this.emit('log', 'info', `Loaded ${allUrls.size} trade link(s) from config`);
|
||||||
|
logger.info('Bot started');
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
logger.info('Shutting down bot...');
|
||||||
|
for (const [, scrapExec] of this.scrapExecutors) {
|
||||||
|
await scrapExec.stop();
|
||||||
|
}
|
||||||
|
await this.screenReader.dispose();
|
||||||
|
await this.tradeMonitor.stop();
|
||||||
|
await this.logWatcher.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal ---
|
||||||
|
|
||||||
|
private wireEvents(): void {
|
||||||
|
this.on('link-added', (link: TradeLink) => {
|
||||||
|
if (link.active) {
|
||||||
|
this.activateLink(link).catch(err => {
|
||||||
|
logger.error({ err }, 'Failed to activate link from event');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.on('link-removed', (id: string) => {
|
||||||
|
this.deactivateLink(id).catch(err => {
|
||||||
|
logger.error({ err }, 'Failed to deactivate link from event');
|
||||||
|
});
|
||||||
|
this.emit('log', 'info', `Removed search: ${id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.on('link-toggled', (data: { id: string; active: boolean; link: TradeLink }) => {
|
||||||
|
if (data.active) {
|
||||||
|
this.activateLink(data.link).catch(err => {
|
||||||
|
logger.error({ err }, 'Failed to activate link from toggle');
|
||||||
|
});
|
||||||
|
this.emit('log', 'info', `Activated: ${data.link.name || data.id}`);
|
||||||
|
} else {
|
||||||
|
this.deactivateLink(data.id).catch(err => {
|
||||||
|
logger.error({ err }, 'Failed to deactivate link from toggle');
|
||||||
|
});
|
||||||
|
this.emit('log', 'info', `Deactivated: ${data.link.name || data.id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.on('link-mode-changed', (data: { id: string; mode: string; link: TradeLink }) => {
|
||||||
|
if (data.link.active) {
|
||||||
|
this.deactivateLink(data.id).then(() => this.activateLink(data.link)).catch(err => {
|
||||||
|
logger.error({ err }, 'Failed to restart link after mode change');
|
||||||
|
});
|
||||||
|
this.emit('log', 'info', `Mode changed to ${data.mode}: ${data.link.name || data.id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.on('link-postaction-changed', (data: { id: string; postAction: string; link: TradeLink }) => {
|
||||||
|
if (data.link.active) {
|
||||||
|
this.deactivateLink(data.id).then(() => this.activateLink(data.link)).catch(err => {
|
||||||
|
logger.error({ err }, 'Failed to restart link after postAction change');
|
||||||
|
});
|
||||||
|
this.emit('log', 'info', `Post-action changed to ${data.postAction}: ${data.link.name || data.id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trade monitor → trade queue
|
||||||
|
this.tradeMonitor.on('new-listings', (data: { searchId: string; itemIds: string[]; page: Page }) => {
|
||||||
|
if (this.isPaused) {
|
||||||
|
this.emit('log', 'warn', `New listings (${data.itemIds.length}) skipped - bot paused`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.isLinkActive(data.searchId)) return;
|
||||||
|
|
||||||
|
logger.info({ searchId: data.searchId, itemCount: data.itemIds.length }, 'New listings received, queuing trade...');
|
||||||
|
this.emit('log', 'info', `New listings: ${data.itemIds.length} items from ${data.searchId}`);
|
||||||
|
|
||||||
|
this.tradeQueue.enqueue({
|
||||||
|
searchId: data.searchId,
|
||||||
|
itemIds: data.itemIds,
|
||||||
|
whisperText: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
tradeUrl: '',
|
||||||
|
page: data.page,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async activateLink(link: TradeLink): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (link.mode === 'scrap') {
|
||||||
|
const scrapExec = new ScrapExecutor(
|
||||||
|
this.gameController, this.screenReader, this.tradeMonitor,
|
||||||
|
this.inventoryManager, this.config,
|
||||||
|
);
|
||||||
|
scrapExec.onStateChange = () => this.updateExecutorState();
|
||||||
|
this.scrapExecutors.set(link.id, scrapExec);
|
||||||
|
this.emit('log', 'info', `Scrap loop started: ${link.name || link.label}`);
|
||||||
|
this.emit('status-update');
|
||||||
|
|
||||||
|
scrapExec.runScrapLoop(link.url, link.postAction).catch((err) => {
|
||||||
|
logger.error({ err, linkId: link.id }, 'Scrap loop error');
|
||||||
|
this.emit('log', 'error', `Scrap loop failed: ${link.name || link.label}`);
|
||||||
|
this.scrapExecutors.delete(link.id);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.tradeMonitor.addSearch(link.url);
|
||||||
|
this.emit('log', 'info', `Monitoring: ${link.name || link.label}`);
|
||||||
|
this.emit('status-update');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err, url: link.url }, 'Failed to activate link');
|
||||||
|
this.emit('log', 'error', `Failed to activate: ${link.name || link.label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deactivateLink(id: string): Promise<void> {
|
||||||
|
const scrapExec = this.scrapExecutors.get(id);
|
||||||
|
if (scrapExec) {
|
||||||
|
await scrapExec.stop();
|
||||||
|
this.scrapExecutors.delete(id);
|
||||||
|
}
|
||||||
|
await this.tradeMonitor.pauseSearch(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -139,7 +139,7 @@ export class ConfigStore {
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSettings(partial: Partial<SavedSettings>): void {
|
updateSettings(partial: Record<string, unknown>): void {
|
||||||
Object.assign(this.data, partial);
|
Object.assign(this.data, partial);
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import { logger } from '../util/logger.js';
|
import { logger } from '../util/logger.js';
|
||||||
import type { LinkMode, PostAction } from '../types.js';
|
import type { LinkMode, PostAction } from '../types.js';
|
||||||
import type { ConfigStore, SavedLink } from './ConfigStore.js';
|
import type { ConfigStore } from './ConfigStore.js';
|
||||||
|
|
||||||
export interface TradeLink {
|
export interface TradeLink {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -14,75 +13,18 @@ export interface TradeLink {
|
||||||
addedAt: string;
|
addedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BotStatus {
|
export class LinkManager {
|
||||||
paused: boolean;
|
|
||||||
state: string;
|
|
||||||
links: TradeLink[];
|
|
||||||
tradesCompleted: number;
|
|
||||||
tradesFailed: number;
|
|
||||||
uptime: number;
|
|
||||||
settings: {
|
|
||||||
poe2LogPath: string;
|
|
||||||
poe2WindowTitle: string;
|
|
||||||
travelTimeoutMs: number;
|
|
||||||
waitForMoreItemsMs: number;
|
|
||||||
betweenTradesDelayMs: number;
|
|
||||||
};
|
|
||||||
inventory?: {
|
|
||||||
grid: boolean[][];
|
|
||||||
items: { row: number; col: number; w: number; h: number }[];
|
|
||||||
free: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BotController extends EventEmitter {
|
|
||||||
private paused = false;
|
|
||||||
private links: Map<string, TradeLink> = new Map();
|
private links: Map<string, TradeLink> = new Map();
|
||||||
private _state = 'IDLE';
|
|
||||||
private tradesCompleted = 0;
|
|
||||||
private tradesFailed = 0;
|
|
||||||
private startTime = Date.now();
|
|
||||||
private store: ConfigStore;
|
private store: ConfigStore;
|
||||||
private _inventory: BotStatus['inventory'] = undefined;
|
|
||||||
|
|
||||||
constructor(store: ConfigStore) {
|
constructor(store: ConfigStore) {
|
||||||
super();
|
|
||||||
this.store = store;
|
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 = '', mode?: LinkMode, postAction?: PostAction): TradeLink {
|
addLink(url: string, name: string = '', mode?: LinkMode, postAction?: PostAction): TradeLink {
|
||||||
url = this.stripLive(url);
|
url = this.stripLive(url);
|
||||||
const id = this.extractId(url);
|
const id = this.extractId(url);
|
||||||
const label = this.extractLabel(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 savedLink = this.store.links.find((l) => l.url === url);
|
||||||
const resolvedMode = mode || savedLink?.mode || 'live';
|
const resolvedMode = mode || savedLink?.mode || 'live';
|
||||||
const link: TradeLink = {
|
const link: TradeLink = {
|
||||||
|
|
@ -98,7 +40,6 @@ export class BotController extends EventEmitter {
|
||||||
this.links.set(id, link);
|
this.links.set(id, link);
|
||||||
this.store.addLink(url, link.name, link.mode, link.postAction);
|
this.store.addLink(url, link.name, link.mode, link.postAction);
|
||||||
logger.info({ id, url, name: link.name, active: link.active, mode: link.mode, postAction: link.postAction }, 'Trade link added');
|
logger.info({ id, url, name: link.name, active: link.active, mode: link.mode, postAction: link.postAction }, 'Trade link added');
|
||||||
this.emit('link-added', link);
|
|
||||||
return link;
|
return link;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,45 +52,44 @@ export class BotController extends EventEmitter {
|
||||||
this.store.removeLinkById(id);
|
this.store.removeLinkById(id);
|
||||||
}
|
}
|
||||||
logger.info({ id }, 'Trade link removed');
|
logger.info({ id }, 'Trade link removed');
|
||||||
this.emit('link-removed', id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleLink(id: string, active: boolean): void {
|
toggleLink(id: string, active: boolean): TradeLink | undefined {
|
||||||
const link = this.links.get(id);
|
const link = this.links.get(id);
|
||||||
if (!link) return;
|
if (!link) return undefined;
|
||||||
link.active = active;
|
link.active = active;
|
||||||
this.store.updateLinkById(id, { active });
|
this.store.updateLinkById(id, { active });
|
||||||
logger.info({ id, active }, `Trade link ${active ? 'activated' : 'deactivated'}`);
|
logger.info({ id, active }, `Trade link ${active ? 'activated' : 'deactivated'}`);
|
||||||
this.emit('link-toggled', { id, active, link });
|
return link;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLinkName(id: string, name: string): void {
|
updateName(id: string, name: string): void {
|
||||||
const link = this.links.get(id);
|
const link = this.links.get(id);
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
link.name = name;
|
link.name = name;
|
||||||
this.store.updateLinkById(id, { name });
|
this.store.updateLinkById(id, { name });
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLinkMode(id: string, mode: LinkMode): void {
|
updateMode(id: string, mode: LinkMode): TradeLink | undefined {
|
||||||
const link = this.links.get(id);
|
const link = this.links.get(id);
|
||||||
if (!link) return;
|
if (!link) return undefined;
|
||||||
link.mode = mode;
|
link.mode = mode;
|
||||||
this.store.updateLinkById(id, { mode });
|
this.store.updateLinkById(id, { mode });
|
||||||
logger.info({ id, mode }, 'Trade link mode updated');
|
logger.info({ id, mode }, 'Trade link mode updated');
|
||||||
this.emit('link-mode-changed', { id, mode, link });
|
return link;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLinkPostAction(id: string, postAction: PostAction): void {
|
updatePostAction(id: string, postAction: PostAction): TradeLink | undefined {
|
||||||
const link = this.links.get(id);
|
const link = this.links.get(id);
|
||||||
if (!link) return;
|
if (!link) return undefined;
|
||||||
link.postAction = postAction;
|
link.postAction = postAction;
|
||||||
this.store.updateLinkById(id, { postAction });
|
this.store.updateLinkById(id, { postAction });
|
||||||
logger.info({ id, postAction }, 'Trade link postAction updated');
|
logger.info({ id, postAction }, 'Trade link postAction updated');
|
||||||
this.emit('link-postaction-changed', { id, postAction, link });
|
return link;
|
||||||
}
|
}
|
||||||
|
|
||||||
isLinkActive(searchId: string): boolean {
|
isActive(id: string): boolean {
|
||||||
const link = this.links.get(searchId);
|
const link = this.links.get(id);
|
||||||
return link ? link.active : false;
|
return link ? link.active : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,42 +97,8 @@ export class BotController extends EventEmitter {
|
||||||
return Array.from(this.links.values());
|
return Array.from(this.links.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
recordTradeSuccess(): void {
|
getLink(id: string): TradeLink | undefined {
|
||||||
this.tradesCompleted++;
|
return this.links.get(id);
|
||||||
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,
|
|
||||||
},
|
|
||||||
inventory: this._inventory,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
setInventory(inv: BotStatus['inventory']): void {
|
|
||||||
this._inventory = inv;
|
|
||||||
}
|
|
||||||
|
|
||||||
getStore(): ConfigStore {
|
|
||||||
return this.store;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private stripLive(url: string): string {
|
private stripLive(url: string): string {
|
||||||
|
|
@ -1,450 +0,0 @@
|
||||||
import express from 'express';
|
|
||||||
import http from 'http';
|
|
||||||
import { WebSocketServer, WebSocket } from 'ws';
|
|
||||||
import path from 'path';
|
|
||||||
import { mkdir } from 'fs/promises';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { logger } from '../util/logger.js';
|
|
||||||
import { sleep } from '../util/sleep.js';
|
|
||||||
import type { BotController } from './BotController.js';
|
|
||||||
import type { ScreenReader } from '../game/ScreenReader.js';
|
|
||||||
import type { OcrEngine, OcrPreprocess } from '../game/OcrDaemon.js';
|
|
||||||
import { GRID_LAYOUTS } from '../game/GridReader.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, mode } = req.body as { url: string; name?: string; mode?: string };
|
|
||||||
if (!url || !url.includes('pathofexile.com/trade')) {
|
|
||||||
res.status(400).json({ error: 'Invalid trade URL' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const linkMode = mode === 'scrap' ? 'scrap' : 'live';
|
|
||||||
this.bot.addLink(url, name || '', linkMode);
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Change link mode
|
|
||||||
this.app.post('/api/links/:id/mode', (req, res) => {
|
|
||||||
const { mode } = req.body as { mode: string };
|
|
||||||
if (mode !== 'live' && mode !== 'scrap') {
|
|
||||||
res.status(400).json({ error: 'Invalid mode. Must be "live" or "scrap".' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.bot.updateLinkMode(req.params.id, mode);
|
|
||||||
this.broadcastStatus();
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Change link post-action
|
|
||||||
this.app.post('/api/links/:id/post-action', (req, res) => {
|
|
||||||
const { postAction } = req.body as { postAction: string };
|
|
||||||
if (postAction !== 'stash' && postAction !== 'salvage') {
|
|
||||||
res.status(400).json({ error: 'Invalid postAction. Must be "stash" or "salvage".' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.bot.updateLinkPostAction(req.params.id, postAction);
|
|
||||||
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' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// OCR engine selection
|
|
||||||
this.app.get('/api/debug/ocr-engine', (_req, res) => {
|
|
||||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
|
||||||
res.json({ ok: true, engine: this.debug.screenReader.debugOcrEngine });
|
|
||||||
});
|
|
||||||
|
|
||||||
this.app.post('/api/debug/ocr-engine', (req, res) => {
|
|
||||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
|
||||||
const { engine } = req.body as { engine: string };
|
|
||||||
if (!['tesseract', 'easyocr', 'paddleocr'].includes(engine)) {
|
|
||||||
res.status(400).json({ error: 'Invalid engine. Must be tesseract, easyocr, or paddleocr.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.debug.screenReader.debugOcrEngine = engine as OcrEngine;
|
|
||||||
this.broadcastLog('info', `OCR engine set to: ${engine}`);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// OCR preprocess selection
|
|
||||||
this.app.get('/api/debug/ocr-preprocess', (_req, res) => {
|
|
||||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
|
||||||
res.json({ ok: true, preprocess: this.debug.screenReader.debugPreprocess });
|
|
||||||
});
|
|
||||||
|
|
||||||
this.app.post('/api/debug/ocr-preprocess', (req, res) => {
|
|
||||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
|
||||||
const { preprocess } = req.body as { preprocess: string };
|
|
||||||
if (!['none', 'bgsub', 'tophat'].includes(preprocess)) {
|
|
||||||
res.status(400).json({ error: 'Invalid preprocess. Must be none, bgsub, or tophat.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.debug.screenReader.debugPreprocess = preprocess as OcrPreprocess;
|
|
||||||
this.broadcastLog('info', `OCR preprocess set to: ${preprocess}`);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
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.debugReadFullScreen();
|
|
||||||
this.broadcastLog('info', `OCR [${this.debug.screenReader.debugOcrEngine}] (${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.debugFindTextOnScreen(text);
|
|
||||||
if (pos) {
|
|
||||||
this.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) [${this.debug.screenReader.debugOcrEngine}]`);
|
|
||||||
} else {
|
|
||||||
this.broadcastLog('warn', `"${text}" not found on screen [${this.debug.screenReader.debugOcrEngine}]`);
|
|
||||||
}
|
|
||||||
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/hideout', async (_req, res) => {
|
|
||||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
|
||||||
try {
|
|
||||||
await this.debug.gameController.focusGame();
|
|
||||||
await this.debug.gameController.goToHideout();
|
|
||||||
this.broadcastLog('info', 'Sent /hideout command');
|
|
||||||
res.json({ ok: true });
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, 'Debug hideout failed');
|
|
||||||
res.status(500).json({ error: 'Hideout command failed' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click first text, then wait for second text to appear and click it
|
|
||||||
this.app.post('/api/debug/click-then-click', async (req, res) => {
|
|
||||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
|
||||||
const { first, second, timeout = 5000 } = req.body as { first: string; second: string; timeout?: number };
|
|
||||||
if (!first || !second) { res.status(400).json({ error: 'Missing first/second' }); return; }
|
|
||||||
try {
|
|
||||||
// Click the first target
|
|
||||||
const pos1 = await this.debug.screenReader.findTextOnScreen(first);
|
|
||||||
if (!pos1) {
|
|
||||||
this.broadcastLog('warn', `"${first}" not found on screen`);
|
|
||||||
res.json({ ok: true, found: false, step: 'first' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.debug.gameController.focusGame();
|
|
||||||
await this.debug.gameController.leftClickAt(pos1.x, pos1.y);
|
|
||||||
this.broadcastLog('info', `Clicked "${first}" at (${pos1.x}, ${pos1.y}), waiting for "${second}"...`);
|
|
||||||
|
|
||||||
// Poll OCR until second text appears
|
|
||||||
const deadline = Date.now() + timeout;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const pos2 = await this.debug.screenReader.findTextOnScreen(second);
|
|
||||||
if (pos2) {
|
|
||||||
await this.debug.gameController.leftClickAt(pos2.x, pos2.y);
|
|
||||||
this.broadcastLog('info', `Clicked "${second}" at (${pos2.x}, ${pos2.y})`);
|
|
||||||
res.json({ ok: true, found: true, position: pos2 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.broadcastLog('warn', `"${second}" not found after clicking "${first}" (timed out)`);
|
|
||||||
res.json({ ok: true, found: false, step: 'second' });
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, 'Debug click-then-click failed');
|
|
||||||
res.status(500).json({ error: 'Click-then-click failed' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Grid scan with calibrated positions
|
|
||||||
this.app.post('/api/debug/grid-scan', async (req, res) => {
|
|
||||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
|
||||||
const { layout: layoutName, targetRow, targetCol } = req.body as { layout: string; targetRow?: number; targetCol?: number };
|
|
||||||
if (!GRID_LAYOUTS[layoutName]) { res.status(400).json({ error: `Unknown grid layout: ${layoutName}` }); return; }
|
|
||||||
try {
|
|
||||||
const result = await this.debug.screenReader.grid.scan(layoutName, undefined, targetRow, targetCol);
|
|
||||||
const imageBuffer = await this.debug.screenReader.captureRegion(result.layout.region);
|
|
||||||
const imageBase64 = imageBuffer.toString('base64');
|
|
||||||
const r = result.layout.region;
|
|
||||||
const matchInfo = result.matches ? `, ${result.matches.length} matches` : '';
|
|
||||||
this.broadcastLog('info',
|
|
||||||
`Grid scan (${layoutName}): ${result.layout.cols}x${result.layout.rows} at (${r.x},${r.y}) ${r.width}x${r.height} — ${result.occupied.length} occupied cells${matchInfo}`);
|
|
||||||
res.json({
|
|
||||||
ok: true,
|
|
||||||
occupied: result.occupied,
|
|
||||||
items: result.items,
|
|
||||||
matches: result.matches,
|
|
||||||
cols: result.layout.cols,
|
|
||||||
rows: result.layout.rows,
|
|
||||||
image: imageBase64,
|
|
||||||
region: result.layout.region,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, 'Debug grid-scan failed');
|
|
||||||
res.status(500).json({ error: 'Grid scan 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, fuzzy } = req.body as { text: string; fuzzy?: boolean };
|
|
||||||
if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; }
|
|
||||||
try {
|
|
||||||
const pos = await this.debug.screenReader.debugFindTextOnScreen(text, !!fuzzy);
|
|
||||||
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 [${this.debug.screenReader.debugOcrEngine}]`);
|
|
||||||
res.json({ ok: true, found: true, position: pos });
|
|
||||||
} else {
|
|
||||||
this.broadcastLog('warn', `"${text}" not found on screen [${this.debug.screenReader.debugOcrEngine}]`);
|
|
||||||
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' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test: scan grid, find matches for target cell, hover over each for 1s
|
|
||||||
this.app.post('/api/debug/test-match-hover', async (req, res) => {
|
|
||||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
|
||||||
const { layout: layoutName, targetRow, targetCol } = req.body as { layout: string; targetRow: number; targetCol: number };
|
|
||||||
if (!GRID_LAYOUTS[layoutName]) { res.status(400).json({ error: `Unknown layout: ${layoutName}` }); return; }
|
|
||||||
if (targetRow == null || targetCol == null) { res.status(400).json({ error: 'Missing targetRow/targetCol' }); return; }
|
|
||||||
try {
|
|
||||||
// Scan with match target
|
|
||||||
this.broadcastLog('info', `Scanning ${layoutName} with target (${targetRow},${targetCol})...`);
|
|
||||||
const result = await this.debug.screenReader.grid.scan(layoutName, undefined, targetRow, targetCol);
|
|
||||||
const matches = result.matches ?? [];
|
|
||||||
const items = result.items ?? [];
|
|
||||||
|
|
||||||
// Find the item dimensions at target cell
|
|
||||||
const targetItem = items.find(i => targetRow >= i.row && targetRow < i.row + i.h && targetCol >= i.col && targetCol < i.col + i.w);
|
|
||||||
const itemSize = targetItem ? `${targetItem.w}x${targetItem.h}` : '1x1';
|
|
||||||
this.broadcastLog('info', `Target (${targetRow},${targetCol}) is ${itemSize}, found ${matches.length} matches`);
|
|
||||||
|
|
||||||
// Build list of cells to hover: target first, then matches
|
|
||||||
const hoverCells = [
|
|
||||||
{ row: targetRow, col: targetCol, label: 'TARGET' },
|
|
||||||
...matches.map(m => ({ row: m.row, col: m.col, label: `MATCH ${(m.similarity * 100).toFixed(0)}%` })),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Focus game, take one snapshot with mouse on empty space
|
|
||||||
await this.debug.gameController.focusGame();
|
|
||||||
await mkdir('items', { recursive: true });
|
|
||||||
const tooltips: Array<{ row: number; col: number; label: string; text: string }> = [];
|
|
||||||
const ts = Date.now();
|
|
||||||
const reg = result.layout.region;
|
|
||||||
const cellW = reg.width / result.layout.cols;
|
|
||||||
const cellH = reg.height / result.layout.rows;
|
|
||||||
|
|
||||||
// Move mouse to empty space and take a single reference snapshot
|
|
||||||
this.debug.gameController.moveMouseInstant(reg.x + reg.width + 50, reg.y + reg.height / 2);
|
|
||||||
await sleep(50);
|
|
||||||
await this.debug.screenReader.snapshot();
|
|
||||||
await this.debug.screenReader.saveScreenshot(`items/${ts}_snapshot.png`);
|
|
||||||
await sleep(200); // Let game settle before first hover
|
|
||||||
|
|
||||||
for (const cell of hoverCells) {
|
|
||||||
const cellStart = performance.now();
|
|
||||||
const x = Math.round(reg.x + cell.col * cellW + cellW / 2);
|
|
||||||
const y = Math.round(reg.y + cell.row * cellH + cellH / 2);
|
|
||||||
|
|
||||||
// Quick Bézier move to the cell — tooltip appears on hover
|
|
||||||
await this.debug.gameController.moveMouseFast(x, y);
|
|
||||||
await sleep(50);
|
|
||||||
const afterMove = performance.now();
|
|
||||||
|
|
||||||
// Diff-OCR: finds tooltip by row/column density of darkened pixels
|
|
||||||
const imgPath = `items/${ts}_${cell.row}-${cell.col}.png`;
|
|
||||||
const diff = await this.debug.screenReader.diffOcr(imgPath);
|
|
||||||
const afterOcr = performance.now();
|
|
||||||
const text = diff.text.trim();
|
|
||||||
|
|
||||||
const regionInfo = diff.region ? ` at (${diff.region.x},${diff.region.y}) ${diff.region.width}x${diff.region.height}` : '';
|
|
||||||
tooltips.push({ row: cell.row, col: cell.col, label: cell.label, text });
|
|
||||||
|
|
||||||
this.broadcastLog('info',
|
|
||||||
`${cell.label} (${cell.row},${cell.col}) [move: ${(afterMove - cellStart).toFixed(0)}ms, ocr: ${(afterOcr - afterMove).toFixed(0)}ms, total: ${(afterOcr - cellStart).toFixed(0)}ms]${regionInfo}:`);
|
|
||||||
if (diff.lines.length > 0) {
|
|
||||||
for (const line of diff.lines) {
|
|
||||||
this.broadcastLog('info', ` ${line.text}`);
|
|
||||||
}
|
|
||||||
} else if (text) {
|
|
||||||
for (const line of text.split('\n')) {
|
|
||||||
if (line.trim()) this.broadcastLog('info', ` ${line.trim()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.broadcastLog('info', `Done — hovered ${hoverCells.length} cells, read ${tooltips.filter(t => t.text).length} tooltips`);
|
|
||||||
res.json({ ok: true, itemSize, matchCount: matches.length, hoveredCount: hoverCells.length, tooltips });
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, 'Debug test-match-hover failed');
|
|
||||||
res.status(500).json({ error: 'Test match hover 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());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,6 +18,7 @@ export class ScrapExecutor {
|
||||||
private tradeMonitor: TradeMonitor;
|
private tradeMonitor: TradeMonitor;
|
||||||
private inventoryManager: InventoryManager;
|
private inventoryManager: InventoryManager;
|
||||||
private config: Config;
|
private config: Config;
|
||||||
|
private _onStateChange?: (state: string) => void;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
gameController: GameController,
|
gameController: GameController,
|
||||||
|
|
@ -33,10 +34,19 @@ export class ScrapExecutor {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set onStateChange(cb: (state: string) => void) {
|
||||||
|
this._onStateChange = cb;
|
||||||
|
}
|
||||||
|
|
||||||
getState(): ScrapState {
|
getState(): ScrapState {
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setState(s: ScrapState): void {
|
||||||
|
this.state = s;
|
||||||
|
this._onStateChange?.(s);
|
||||||
|
}
|
||||||
|
|
||||||
/** Stop the scrap loop gracefully. */
|
/** Stop the scrap loop gracefully. */
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
|
|
@ -44,7 +54,7 @@ export class ScrapExecutor {
|
||||||
try { await this.activePage.close(); } catch { /* best-effort */ }
|
try { await this.activePage.close(); } catch { /* best-effort */ }
|
||||||
this.activePage = null;
|
this.activePage = null;
|
||||||
}
|
}
|
||||||
this.state = 'IDLE';
|
this.setState('IDLE');
|
||||||
logger.info('Scrap executor stopped');
|
logger.info('Scrap executor stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,7 +91,7 @@ export class ScrapExecutor {
|
||||||
// Check if process succeeded (state is IDLE on success, FAILED otherwise)
|
// Check if process succeeded (state is IDLE on success, FAILED otherwise)
|
||||||
if (this.state === 'FAILED') {
|
if (this.state === 'FAILED') {
|
||||||
salvageFailed = true;
|
salvageFailed = true;
|
||||||
this.state = 'IDLE';
|
this.setState('IDLE');
|
||||||
logger.warn('Process cycle failed, skipping remaining items that do not fit');
|
logger.warn('Process cycle failed, skipping remaining items that do not fit');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -121,7 +131,7 @@ export class ScrapExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activePage = null;
|
this.activePage = null;
|
||||||
this.state = 'IDLE';
|
this.setState('IDLE');
|
||||||
logger.info('Scrap loop ended');
|
logger.info('Scrap loop ended');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,7 +145,7 @@ export class ScrapExecutor {
|
||||||
if (alreadyAtSeller) {
|
if (alreadyAtSeller) {
|
||||||
logger.info({ itemId: item.id, account: item.account }, 'Already at seller hideout, skipping travel');
|
logger.info({ itemId: item.id, account: item.account }, 'Already at seller hideout, skipping travel');
|
||||||
} else {
|
} else {
|
||||||
this.state = 'TRAVELING';
|
this.setState('TRAVELING');
|
||||||
|
|
||||||
// Register listener BEFORE clicking, then click inside the callback
|
// Register listener BEFORE clicking, then click inside the callback
|
||||||
const arrived = await this.inventoryManager.waitForAreaTransition(
|
const arrived = await this.inventoryManager.waitForAreaTransition(
|
||||||
|
|
@ -149,7 +159,7 @@ export class ScrapExecutor {
|
||||||
);
|
);
|
||||||
if (!arrived) {
|
if (!arrived) {
|
||||||
logger.error({ itemId: item.id }, 'Timed out waiting for hideout arrival');
|
logger.error({ itemId: item.id }, 'Timed out waiting for hideout arrival');
|
||||||
this.state = 'FAILED';
|
this.setState('FAILED');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,7 +168,7 @@ export class ScrapExecutor {
|
||||||
await sleep(1500); // Wait for hideout to render
|
await sleep(1500); // Wait for hideout to render
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state = 'BUYING';
|
this.setState('BUYING');
|
||||||
|
|
||||||
// CTRL+Click at seller stash position
|
// CTRL+Click at seller stash position
|
||||||
const sellerLayout = GRID_LAYOUTS.seller;
|
const sellerLayout = GRID_LAYOUTS.seller;
|
||||||
|
|
@ -175,11 +185,11 @@ export class ScrapExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info({ itemId: item.id, free: this.inventoryManager.tracker.freeCells }, 'Item bought successfully');
|
logger.info({ itemId: item.id, free: this.inventoryManager.tracker.freeCells }, 'Item bought successfully');
|
||||||
this.state = 'IDLE';
|
this.setState('IDLE');
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err, itemId: item.id }, 'Error buying item');
|
logger.error({ err, itemId: item.id }, 'Error buying item');
|
||||||
this.state = 'FAILED';
|
this.setState('FAILED');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -187,13 +197,13 @@ export class ScrapExecutor {
|
||||||
/** Process inventory: salvage/stash cycle via InventoryManager. */
|
/** Process inventory: salvage/stash cycle via InventoryManager. */
|
||||||
private async processItems(): Promise<void> {
|
private async processItems(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.state = 'SALVAGING';
|
this.setState('SALVAGING');
|
||||||
await this.inventoryManager.processInventory();
|
await this.inventoryManager.processInventory();
|
||||||
this.state = 'IDLE';
|
this.setState('IDLE');
|
||||||
logger.info('Process cycle complete');
|
logger.info('Process cycle complete');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, 'Process cycle failed');
|
logger.error({ err }, 'Process cycle failed');
|
||||||
this.state = 'FAILED';
|
this.setState('FAILED');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export class TradeExecutor {
|
||||||
private tradeMonitor: TradeMonitor;
|
private tradeMonitor: TradeMonitor;
|
||||||
private inventoryManager: InventoryManager;
|
private inventoryManager: InventoryManager;
|
||||||
private config: Config;
|
private config: Config;
|
||||||
|
private _onStateChange?: (state: string) => void;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
gameController: GameController,
|
gameController: GameController,
|
||||||
|
|
@ -38,16 +39,25 @@ export class TradeExecutor {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set onStateChange(cb: (state: string) => void) {
|
||||||
|
this._onStateChange = cb;
|
||||||
|
}
|
||||||
|
|
||||||
getState(): TradeState {
|
getState(): TradeState {
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setState(s: TradeState): void {
|
||||||
|
this.state = s;
|
||||||
|
this._onStateChange?.(s);
|
||||||
|
}
|
||||||
|
|
||||||
async executeTrade(trade: TradeInfo): Promise<boolean> {
|
async executeTrade(trade: TradeInfo): Promise<boolean> {
|
||||||
const page = trade.page as Page;
|
const page = trade.page as Page;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Click "Travel to Hideout" on the trade website
|
// Step 1: Click "Travel to Hideout" on the trade website
|
||||||
this.state = 'TRAVELING';
|
this.setState('TRAVELING');
|
||||||
logger.info({ searchId: trade.searchId }, 'Clicking Travel to Hideout...');
|
logger.info({ searchId: trade.searchId }, 'Clicking Travel to Hideout...');
|
||||||
|
|
||||||
// Register listener BEFORE clicking, then click inside the callback
|
// Register listener BEFORE clicking, then click inside the callback
|
||||||
|
|
@ -65,11 +75,11 @@ export class TradeExecutor {
|
||||||
);
|
);
|
||||||
if (!arrived) {
|
if (!arrived) {
|
||||||
logger.error('Timed out waiting for hideout arrival');
|
logger.error('Timed out waiting for hideout arrival');
|
||||||
this.state = 'FAILED';
|
this.setState('FAILED');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state = 'IN_SELLERS_HIDEOUT';
|
this.setState('IN_SELLERS_HIDEOUT');
|
||||||
this.inventoryManager.setLocation(false);
|
this.inventoryManager.setLocation(false);
|
||||||
logger.info('Arrived at seller hideout');
|
logger.info('Arrived at seller hideout');
|
||||||
|
|
||||||
|
|
@ -89,19 +99,19 @@ export class TradeExecutor {
|
||||||
const stashPos = await this.inventoryManager.findAndClickNameplate('Stash');
|
const stashPos = await this.inventoryManager.findAndClickNameplate('Stash');
|
||||||
if (!stashPos) {
|
if (!stashPos) {
|
||||||
logger.error('Could not find Stash nameplate in seller hideout');
|
logger.error('Could not find Stash nameplate in seller hideout');
|
||||||
this.state = 'FAILED';
|
this.setState('FAILED');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await sleep(1000); // Wait for stash to open
|
await sleep(1000); // Wait for stash to open
|
||||||
|
|
||||||
// Step 4: Scan stash and buy items
|
// Step 4: Scan stash and buy items
|
||||||
this.state = 'SCANNING_STASH';
|
this.setState('SCANNING_STASH');
|
||||||
logger.info('Scanning stash for items...');
|
logger.info('Scanning stash for items...');
|
||||||
|
|
||||||
await this.scanAndBuyItems();
|
await this.scanAndBuyItems();
|
||||||
|
|
||||||
// Step 5: Wait for more items
|
// Step 5: Wait for more items
|
||||||
this.state = 'WAITING_FOR_MORE';
|
this.setState('WAITING_FOR_MORE');
|
||||||
logger.info(
|
logger.info(
|
||||||
{ waitMs: this.config.waitForMoreItemsMs },
|
{ waitMs: this.config.waitForMoreItemsMs },
|
||||||
'Waiting for seller to add more items...',
|
'Waiting for seller to add more items...',
|
||||||
|
|
@ -112,7 +122,7 @@ export class TradeExecutor {
|
||||||
await this.scanAndBuyItems();
|
await this.scanAndBuyItems();
|
||||||
|
|
||||||
// Step 6: Go back to own hideout
|
// Step 6: Go back to own hideout
|
||||||
this.state = 'GOING_HOME';
|
this.setState('GOING_HOME');
|
||||||
logger.info('Traveling to own hideout...');
|
logger.info('Traveling to own hideout...');
|
||||||
await this.gameController.focusGame();
|
await this.gameController.focusGame();
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
|
@ -128,15 +138,15 @@ export class TradeExecutor {
|
||||||
this.inventoryManager.setLocation(true);
|
this.inventoryManager.setLocation(true);
|
||||||
|
|
||||||
// Step 7: Store items in stash
|
// Step 7: Store items in stash
|
||||||
this.state = 'IN_HIDEOUT';
|
this.setState('IN_HIDEOUT');
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
await this.storeItems();
|
await this.storeItems();
|
||||||
|
|
||||||
this.state = 'IDLE';
|
this.setState('IDLE');
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, 'Trade execution failed');
|
logger.error({ err }, 'Trade execution failed');
|
||||||
this.state = 'FAILED';
|
this.setState('FAILED');
|
||||||
|
|
||||||
// Try to recover by going home
|
// Try to recover by going home
|
||||||
try {
|
try {
|
||||||
|
|
@ -148,7 +158,7 @@ export class TradeExecutor {
|
||||||
// Best-effort recovery
|
// Best-effort recovery
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state = 'IDLE';
|
this.setState('IDLE');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -165,7 +175,7 @@ export class TradeExecutor {
|
||||||
// TODO: Implement item matching logic based on OCR text
|
// TODO: Implement item matching logic based on OCR text
|
||||||
// For now, we'll Ctrl+right-click at known grid positions
|
// For now, we'll Ctrl+right-click at known grid positions
|
||||||
|
|
||||||
this.state = 'BUYING';
|
this.setState('BUYING');
|
||||||
|
|
||||||
// Check for price warning dialog after each buy
|
// Check for price warning dialog after each buy
|
||||||
await this.checkPriceWarning();
|
await this.checkPriceWarning();
|
||||||
|
|
|
||||||
269
src/index.ts
269
src/index.ts
|
|
@ -1,19 +1,9 @@
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { loadConfig } from './config.js';
|
import { loadConfig } from './config.js';
|
||||||
import { TradeMonitor } from './trade/TradeMonitor.js';
|
import { Bot } from './bot/Bot.js';
|
||||||
import { GameController } from './game/GameController.js';
|
import { Server } from './server/Server.js';
|
||||||
import { ScreenReader } from './game/ScreenReader.js';
|
import { ConfigStore } from './bot/ConfigStore.js';
|
||||||
import { ClientLogWatcher } from './log/ClientLogWatcher.js';
|
|
||||||
import { TradeExecutor } from './executor/TradeExecutor.js';
|
|
||||||
import { ScrapExecutor } from './executor/ScrapExecutor.js';
|
|
||||||
import { TradeQueue } from './executor/TradeQueue.js';
|
|
||||||
import { InventoryManager } from './inventory/InventoryManager.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 { logger } from './util/logger.js';
|
||||||
import type { TradeLink } from './dashboard/BotController.js';
|
|
||||||
import type { Page } from 'playwright';
|
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
|
|
@ -25,15 +15,12 @@ program
|
||||||
.option('-p, --port <number>', 'Dashboard port')
|
.option('-p, --port <number>', 'Dashboard port')
|
||||||
.option('-c, --config <path>', 'Path to config.json', 'config.json')
|
.option('-c, --config <path>', 'Path to config.json', 'config.json')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
// Load persisted config
|
|
||||||
const store = new ConfigStore(options.config);
|
const store = new ConfigStore(options.config);
|
||||||
const saved = store.settings;
|
const saved = store.settings;
|
||||||
|
|
||||||
// CLI/env overrides persisted values
|
|
||||||
const envConfig = loadConfig(options.url);
|
const envConfig = loadConfig(options.url);
|
||||||
if (options.logPath) envConfig.poe2LogPath = options.logPath;
|
if (options.logPath) envConfig.poe2LogPath = options.logPath;
|
||||||
|
|
||||||
// Merge: CLI args > .env > config.json defaults
|
|
||||||
const config = {
|
const config = {
|
||||||
...envConfig,
|
...envConfig,
|
||||||
poe2LogPath: options.logPath || saved.poe2LogPath,
|
poe2LogPath: options.logPath || saved.poe2LogPath,
|
||||||
|
|
@ -47,250 +34,15 @@ program
|
||||||
|
|
||||||
const port = parseInt(options.port, 10) || saved.dashboardPort;
|
const port = parseInt(options.port, 10) || saved.dashboardPort;
|
||||||
|
|
||||||
// Collect all URLs: CLI args + saved links (deduped)
|
const bot = new Bot(store, config);
|
||||||
const allUrls = new Set<string>([
|
const server = new Server(bot, port);
|
||||||
...config.tradeUrls,
|
await server.start();
|
||||||
...saved.links.map((l) => l.url),
|
await bot.start(config.tradeUrls, port);
|
||||||
]);
|
|
||||||
|
|
||||||
// Initialize bot controller with config store
|
|
||||||
const bot = new BotController(store);
|
|
||||||
|
|
||||||
// 1. Start dashboard
|
|
||||||
const dashboard = new DashboardServer(bot, port);
|
|
||||||
await dashboard.start();
|
|
||||||
|
|
||||||
// 2. Create game components
|
|
||||||
const screenReader = new ScreenReader();
|
|
||||||
const gameController = new GameController(config);
|
|
||||||
dashboard.setDebugDeps({ screenReader, gameController });
|
|
||||||
|
|
||||||
// 3. Start logWatcher BEFORE /hideout so we can wait for area transition
|
|
||||||
const logWatcher = new ClientLogWatcher(config.poe2LogPath);
|
|
||||||
await logWatcher.start();
|
|
||||||
dashboard.broadcastLog('info', 'Watching Client.txt for game events');
|
|
||||||
|
|
||||||
// 4. Start tradeMonitor
|
|
||||||
const tradeMonitor = new TradeMonitor(config);
|
|
||||||
await tradeMonitor.start(`http://localhost:${port}`);
|
|
||||||
dashboard.broadcastLog('info', 'Browser launched');
|
|
||||||
|
|
||||||
// 5. Create InventoryManager
|
|
||||||
const inventoryManager = new InventoryManager(gameController, screenReader, logWatcher, config);
|
|
||||||
|
|
||||||
// 6. /hideout + waitForAreaTransition
|
|
||||||
dashboard.broadcastLog('info', 'Sending /hideout command...');
|
|
||||||
await gameController.focusGame();
|
|
||||||
const arrivedHome = await inventoryManager.waitForAreaTransition(
|
|
||||||
config.travelTimeoutMs,
|
|
||||||
() => gameController.goToHideout(),
|
|
||||||
);
|
|
||||||
if (arrivedHome) {
|
|
||||||
inventoryManager.setLocation(true);
|
|
||||||
logWatcher.currentArea = 'Hideout';
|
|
||||||
} else {
|
|
||||||
// Assume we're already in hideout if timeout (e.g. already there)
|
|
||||||
inventoryManager.setLocation(true);
|
|
||||||
logWatcher.currentArea = 'Hideout';
|
|
||||||
logger.warn('Timed out waiting for hideout transition on startup (may already be in hideout)');
|
|
||||||
}
|
|
||||||
bot.state = 'IN_HIDEOUT';
|
|
||||||
dashboard.broadcastStatus();
|
|
||||||
dashboard.broadcastLog('info', 'In hideout, ready to trade');
|
|
||||||
|
|
||||||
// 7. Clear leftover inventory items to stash
|
|
||||||
dashboard.broadcastLog('info', 'Checking inventory for leftover items...');
|
|
||||||
await inventoryManager.clearToStash();
|
|
||||||
dashboard.broadcastLog('info', 'Inventory cleared');
|
|
||||||
|
|
||||||
// 8. Create executors with shared InventoryManager
|
|
||||||
const executor = new TradeExecutor(
|
|
||||||
gameController,
|
|
||||||
screenReader,
|
|
||||||
tradeMonitor,
|
|
||||||
inventoryManager,
|
|
||||||
config,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 9. Create tradeQueue
|
|
||||||
const tradeQueue = new TradeQueue(executor, config);
|
|
||||||
|
|
||||||
// Track running scrap executors per link ID
|
|
||||||
const scrapExecutors = new Map<string, ScrapExecutor>();
|
|
||||||
|
|
||||||
// 10. Activate a link based on its mode
|
|
||||||
const activateLink = async (link: TradeLink) => {
|
|
||||||
try {
|
|
||||||
if (link.mode === 'scrap') {
|
|
||||||
// Start scrap loop for this link
|
|
||||||
const scrapExec = new ScrapExecutor(
|
|
||||||
gameController,
|
|
||||||
screenReader,
|
|
||||||
tradeMonitor,
|
|
||||||
inventoryManager,
|
|
||||||
config,
|
|
||||||
);
|
|
||||||
scrapExecutors.set(link.id, scrapExec);
|
|
||||||
dashboard.broadcastLog('info', `Scrap loop started: ${link.name || link.label}`);
|
|
||||||
dashboard.broadcastStatus();
|
|
||||||
// Run in background (don't await — it's an infinite loop)
|
|
||||||
scrapExec.runScrapLoop(link.url, link.postAction).catch((err) => {
|
|
||||||
logger.error({ err, linkId: link.id }, 'Scrap loop error');
|
|
||||||
dashboard.broadcastLog('error', `Scrap loop failed: ${link.name || link.label}`);
|
|
||||||
scrapExecutors.delete(link.id);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Live search mode
|
|
||||||
await tradeMonitor.addSearch(link.url);
|
|
||||||
dashboard.broadcastLog('info', `Monitoring: ${link.name || link.label}`);
|
|
||||||
dashboard.broadcastStatus();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err, url: link.url }, 'Failed to activate link');
|
|
||||||
dashboard.broadcastLog('error', `Failed to activate: ${link.name || link.label}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Deactivate a link based on its mode
|
|
||||||
const deactivateLink = async (id: string) => {
|
|
||||||
// Stop scrap executor if running
|
|
||||||
const scrapExec = scrapExecutors.get(id);
|
|
||||||
if (scrapExec) {
|
|
||||||
await scrapExec.stop();
|
|
||||||
scrapExecutors.delete(id);
|
|
||||||
}
|
|
||||||
// Pause live search if active
|
|
||||||
await tradeMonitor.pauseSearch(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 11. 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(link);
|
|
||||||
} 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
|
|
||||||
bot.on('link-added', async (link: TradeLink) => {
|
|
||||||
if (link.active) {
|
|
||||||
await activateLink(link);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// When dashboard removes a link, deactivate it
|
|
||||||
bot.on('link-removed', async (id: string) => {
|
|
||||||
await deactivateLink(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: TradeLink }) => {
|
|
||||||
if (data.active) {
|
|
||||||
await activateLink(data.link);
|
|
||||||
dashboard.broadcastLog('info', `Activated: ${data.link.name || data.id}`);
|
|
||||||
} else {
|
|
||||||
await deactivateLink(data.id);
|
|
||||||
dashboard.broadcastLog('info', `Deactivated: ${data.link.name || data.id}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// When link mode changes, restart with new mode if active
|
|
||||||
bot.on('link-mode-changed', async (data: { id: string; mode: string; link: TradeLink }) => {
|
|
||||||
if (data.link.active) {
|
|
||||||
await deactivateLink(data.id);
|
|
||||||
await activateLink(data.link);
|
|
||||||
dashboard.broadcastLog('info', `Mode changed to ${data.mode}: ${data.link.name || data.id}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// When link postAction changes, restart executor if active
|
|
||||||
bot.on('link-postaction-changed', async (data: { id: string; postAction: string; link: TradeLink }) => {
|
|
||||||
if (data.link.active) {
|
|
||||||
await deactivateLink(data.id);
|
|
||||||
await activateLink(data.link);
|
|
||||||
dashboard.broadcastLog('info', `Post-action changed to ${data.postAction}: ${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(() => {
|
|
||||||
// Feed inventory state from shared InventoryManager
|
|
||||||
bot.setInventory(inventoryManager.getInventoryState());
|
|
||||||
|
|
||||||
// Check live trade executor state
|
|
||||||
const execState = executor.getState();
|
|
||||||
if (execState !== 'IDLE') {
|
|
||||||
if (bot.state !== execState) {
|
|
||||||
bot.state = execState;
|
|
||||||
dashboard.broadcastStatus();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check scrap executor states
|
|
||||||
for (const [, scrapExec] of scrapExecutors) {
|
|
||||||
const scrapState = scrapExec.getState();
|
|
||||||
if (scrapState !== 'IDLE') {
|
|
||||||
if (bot.state !== scrapState) {
|
|
||||||
bot.state = scrapState;
|
|
||||||
dashboard.broadcastStatus();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All idle
|
|
||||||
if (bot.state !== 'IDLE') {
|
|
||||||
bot.state = 'IDLE';
|
|
||||||
dashboard.broadcastStatus();
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
logger.info('Shutting down...');
|
logger.info('Shutting down...');
|
||||||
clearInterval(stateInterval);
|
await bot.stop();
|
||||||
for (const [, scrapExec] of scrapExecutors) {
|
await server.stop();
|
||||||
await scrapExec.stop();
|
|
||||||
}
|
|
||||||
await screenReader.dispose();
|
|
||||||
await dashboard.stop();
|
|
||||||
await tradeMonitor.stop();
|
|
||||||
await logWatcher.stop();
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -298,9 +50,6 @@ program
|
||||||
process.on('SIGTERM', shutdown);
|
process.on('SIGTERM', shutdown);
|
||||||
|
|
||||||
logger.info(`Dashboard: http://localhost:${port}`);
|
logger.info(`Dashboard: http://localhost:${port}`);
|
||||||
logger.info(
|
|
||||||
`Monitoring ${allUrls.size} trade search(es). Press Ctrl+C to stop.`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
program.parse();
|
program.parse();
|
||||||
|
|
|
||||||
97
src/server/Server.ts
Normal file
97
src/server/Server.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import express from 'express';
|
||||||
|
import http from 'http';
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { logger } from '../util/logger.js';
|
||||||
|
import { statusRoutes } from './routes/status.js';
|
||||||
|
import { controlRoutes } from './routes/control.js';
|
||||||
|
import { linkRoutes } from './routes/links.js';
|
||||||
|
import { debugRoutes } from './routes/debug.js';
|
||||||
|
import type { Bot } from '../bot/Bot.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export class Server {
|
||||||
|
private app = express();
|
||||||
|
private server: http.Server;
|
||||||
|
private wss: WebSocketServer;
|
||||||
|
private clients: Set<WebSocket> = new Set();
|
||||||
|
private bot: Bot;
|
||||||
|
|
||||||
|
constructor(bot: Bot, private port: number = 3000) {
|
||||||
|
this.bot = bot;
|
||||||
|
this.app.use(express.json());
|
||||||
|
|
||||||
|
this.app.get('/', (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '..', '..', 'src', 'server', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mount routes
|
||||||
|
this.app.use('/api', statusRoutes(bot));
|
||||||
|
this.app.use('/api', controlRoutes(bot));
|
||||||
|
this.app.use('/api/links', linkRoutes(bot));
|
||||||
|
this.app.use('/api/debug', debugRoutes(bot, this));
|
||||||
|
|
||||||
|
this.server = http.createServer(this.app);
|
||||||
|
this.wss = new WebSocketServer({ server: this.server });
|
||||||
|
|
||||||
|
this.wss.on('connection', (ws) => {
|
||||||
|
this.clients.add(ws);
|
||||||
|
ws.send(JSON.stringify({ type: 'status', data: bot.getStatus() }));
|
||||||
|
ws.on('close', () => this.clients.delete(ws));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to bot events
|
||||||
|
bot.on('status-update', () => this.broadcastStatus());
|
||||||
|
bot.on('log', (level: string, message: string) => this.broadcastLog(level, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastStatus(): void {
|
||||||
|
const msg = JSON.stringify({ type: 'status', data: this.bot.getStatus() });
|
||||||
|
for (const client of this.clients) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastLog(level: string, message: string): void {
|
||||||
|
const msg = JSON.stringify({
|
||||||
|
type: 'log',
|
||||||
|
data: { level, message, time: new Date().toISOString() },
|
||||||
|
});
|
||||||
|
for (const client of this.clients) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastDebug(action: string, data: Record<string, unknown>): void {
|
||||||
|
const msg = JSON.stringify({ type: 'debug', data: { action, ...data } });
|
||||||
|
for (const client of this.clients) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.server.listen(this.port, () => {
|
||||||
|
logger.info({ port: this.port }, `Dashboard running at http://localhost:${this.port}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
for (const client of this.clients) {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.server.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -479,8 +479,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="debug-row">
|
<div class="debug-row">
|
||||||
<button onclick="debugGridScan('inventory')">Scan Inventory</button>
|
<button onclick="debugGridScan('inventory')">Scan Inventory</button>
|
||||||
<button onclick="debugGridScan('stash12')">Scan Stash 12×12</button>
|
<button onclick="debugGridScan('stash12')">Scan Stash 12x12</button>
|
||||||
<button onclick="debugGridScan('stash24')">Scan Stash 24×24</button>
|
<button onclick="debugGridScan('stash24')">Scan Stash 24x24</button>
|
||||||
<button onclick="debugGridScan('seller')">Scan Seller</button>
|
<button onclick="debugGridScan('seller')">Scan Seller</button>
|
||||||
<button onclick="debugGridScan('shop')">Scan Shop</button>
|
<button onclick="debugGridScan('shop')">Scan Shop</button>
|
||||||
<button onclick="debugGridScan('vendor')">Scan Vendor</button>
|
<button onclick="debugGridScan('vendor')">Scan Vendor</button>
|
||||||
|
|
@ -489,8 +489,8 @@
|
||||||
<select id="matchLayout" style="padding:6px 10px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e6edf3;font-size:13px">
|
<select id="matchLayout" style="padding:6px 10px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e6edf3;font-size:13px">
|
||||||
<option value="shop">Shop</option>
|
<option value="shop">Shop</option>
|
||||||
<option value="seller">Seller</option>
|
<option value="seller">Seller</option>
|
||||||
<option value="stash12">Stash 12×12</option>
|
<option value="stash12">Stash 12x12</option>
|
||||||
<option value="stash12_folder">Stash 12×12 (folder)</option>
|
<option value="stash12_folder">Stash 12x12 (folder)</option>
|
||||||
<option value="inventory">Inventory</option>
|
<option value="inventory">Inventory</option>
|
||||||
<option value="vendor">Vendor</option>
|
<option value="vendor">Vendor</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -570,6 +570,8 @@
|
||||||
render();
|
render();
|
||||||
} else if (msg.type === 'log') {
|
} else if (msg.type === 'log') {
|
||||||
addLog(msg.data);
|
addLog(msg.data);
|
||||||
|
} else if (msg.type === 'debug') {
|
||||||
|
handleDebugResult(msg.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
|
@ -680,6 +682,125 @@
|
||||||
panel.scrollTop = panel.scrollHeight;
|
panel.scrollTop = panel.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Handle debug results arriving via WebSocket ---
|
||||||
|
|
||||||
|
function handleDebugResult(data) {
|
||||||
|
switch (data.action) {
|
||||||
|
case 'screenshot':
|
||||||
|
if (data.error) { showDebugResult(`Error: ${data.error}`); break; }
|
||||||
|
showDebugResult(`Screenshots saved: ${(data.files || []).map(f => f.split(/[\\/]/).pop()).join(', ')}`);
|
||||||
|
break;
|
||||||
|
case 'ocr':
|
||||||
|
showDebugResult(data.error ? `Error: ${data.error}` : data.text);
|
||||||
|
break;
|
||||||
|
case 'find-text':
|
||||||
|
if (data.error) { showDebugResult(`Error: ${data.error}`); break; }
|
||||||
|
if (data.found) {
|
||||||
|
showDebugResult(`Found "${data.searchText}" at (${data.position.x}, ${data.position.y})`);
|
||||||
|
document.getElementById('debugClickX').value = data.position.x;
|
||||||
|
document.getElementById('debugClickY').value = data.position.y;
|
||||||
|
} else {
|
||||||
|
showDebugResult(`"${data.searchText}" not found on screen`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'find-and-click':
|
||||||
|
if (data.error) { showDebugResult(`Error: ${data.error}`); break; }
|
||||||
|
showDebugResult(data.found
|
||||||
|
? `Clicked "${data.searchText}" at (${data.position.x}, ${data.position.y})`
|
||||||
|
: `"${data.searchText}" not found on screen`);
|
||||||
|
break;
|
||||||
|
case 'click':
|
||||||
|
showDebugResult(data.error ? `Error: ${data.error}` : `Clicked at (${data.x}, ${data.y})`);
|
||||||
|
break;
|
||||||
|
case 'hideout':
|
||||||
|
showDebugResult(data.error ? `Error: ${data.error}` : 'Sent /hideout command');
|
||||||
|
break;
|
||||||
|
case 'click-then-click':
|
||||||
|
if (data.error) { showDebugResult(`Error: ${data.error}`); break; }
|
||||||
|
if (!data.found && data.step === 'first') showDebugResult(`"${data.first}" not found on screen`);
|
||||||
|
else if (!data.found) showDebugResult(`"${data.second}" not found after clicking "${data.first}" (timed out)`);
|
||||||
|
else showDebugResult(`Clicked "${data.second}" at (${data.position.x}, ${data.position.y})`);
|
||||||
|
break;
|
||||||
|
case 'grid-scan':
|
||||||
|
if (data.error) { showDebugResult(`Error: ${data.error}`); break; }
|
||||||
|
renderGridScanResult(data);
|
||||||
|
break;
|
||||||
|
case 'test-match-hover':
|
||||||
|
if (data.error) { showDebugResult(`Error: ${data.error}`); break; }
|
||||||
|
showDebugResult(`Done: ${data.itemSize} item, ${data.matchCount} matches, hovered ${data.hoveredCount} cells`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGridScanResult(data) {
|
||||||
|
const layout = data.layout;
|
||||||
|
const hasTarget = data.targetRow != null && data.targetCol != null;
|
||||||
|
const targetRow = data.targetRow;
|
||||||
|
const targetCol = data.targetCol;
|
||||||
|
const el = document.getElementById('debugResult');
|
||||||
|
const count = data.occupied.length;
|
||||||
|
const items = data.items || [];
|
||||||
|
const matches = data.matches || [];
|
||||||
|
const r = data.region;
|
||||||
|
let html = `<b>${layout}</b> ${data.cols}x${data.rows}`;
|
||||||
|
html += ` — ${count} occupied, ${items.length} items`;
|
||||||
|
if (matches.length > 0) html += `, <span style="color:#f0883e;font-weight:bold">${matches.length} matches</span>`;
|
||||||
|
if (r) html += `<br><span style="color:#484f58">Region: (${r.x}, ${r.y}) ${r.width}x${r.height}</span>`;
|
||||||
|
if (items.length > 0) {
|
||||||
|
const sizes = {};
|
||||||
|
items.forEach(i => { const k = i.w + 'x' + i.h; sizes[k] = (sizes[k]||0) + 1; });
|
||||||
|
html += `<br><span style="color:#58a6ff">` + Object.entries(sizes).map(([k,v]) => `${v}x ${k}`).join(', ') + `</span>`;
|
||||||
|
}
|
||||||
|
if (hasTarget) {
|
||||||
|
html += `<br><span style="color:#f0883e">Target: (${targetRow},${targetCol})`;
|
||||||
|
if (matches.length > 0) html += ` → ${matches.map(m => `(${m.row},${m.col}) ${(m.similarity*100).toFixed(0)}%`).join(', ')}`;
|
||||||
|
html += `</span>`;
|
||||||
|
} else {
|
||||||
|
html += `<br><span style="color:#484f58">Click a cell to find matching items</span>`;
|
||||||
|
}
|
||||||
|
html += '<div class="grid-debug">';
|
||||||
|
if (data.image) {
|
||||||
|
html += `<img src="data:image/png;base64,${data.image}" alt="Grid capture" />`;
|
||||||
|
}
|
||||||
|
const matchSet = new Set(matches.map(m => m.row + ',' + m.col));
|
||||||
|
const targetKey = hasTarget ? targetRow + ',' + targetCol : null;
|
||||||
|
const itemMap = {};
|
||||||
|
const colors = ['#238636','#1f6feb','#8957e5','#da3633','#d29922','#3fb950','#388bfd','#a371f7','#f85149','#e3b341'];
|
||||||
|
items.forEach((item, idx) => {
|
||||||
|
const color = colors[idx % colors.length];
|
||||||
|
for (let dr = 0; dr < item.h; dr++)
|
||||||
|
for (let dc = 0; dc < item.w; dc++)
|
||||||
|
itemMap[(item.row+dr)+','+(item.col+dc)] = { item, color, isOrigin: dr===0 && dc===0 };
|
||||||
|
});
|
||||||
|
html += `<div class="grid-view" style="grid-template-columns:repeat(${data.cols},12px)">`;
|
||||||
|
const set = new Set(data.occupied.map(c => c.row + ',' + c.col));
|
||||||
|
for (let gr = 0; gr < data.rows; gr++) {
|
||||||
|
for (let gc = 0; gc < data.cols; gc++) {
|
||||||
|
const key = gr+','+gc;
|
||||||
|
const isTarget = key === targetKey;
|
||||||
|
const isMatch = matchSet.has(key);
|
||||||
|
const info = itemMap[key];
|
||||||
|
let bg;
|
||||||
|
if (isTarget) bg = '#f0883e';
|
||||||
|
else if (isMatch) bg = '#d29922';
|
||||||
|
else if (info) bg = info.color;
|
||||||
|
else if (set.has(key)) bg = '#238636';
|
||||||
|
else bg = '';
|
||||||
|
const outline = (isTarget || isMatch) ? 'outline:2px solid #f0883e;z-index:1;' : '';
|
||||||
|
const cursor = set.has(key) ? 'cursor:pointer;' : '';
|
||||||
|
const bgStyle = bg ? `background:${bg};` : '';
|
||||||
|
const style = (bgStyle || outline || cursor) ? ` style="${bgStyle}${outline}${cursor}"` : '';
|
||||||
|
let title = info ? `(${gr},${gc}) ${info.item.w}x${info.item.h}` : `(${gr},${gc})`;
|
||||||
|
if (isTarget) title += ' [TARGET]';
|
||||||
|
if (isMatch) { const m = matches.find(m => m.row===gr && m.col===gc); title += ` [MATCH ${(m.similarity*100).toFixed(0)}%]`; }
|
||||||
|
const onclick = set.has(key) ? ` onclick="debugGridScan('${layout}',${gr},${gc})"` : '';
|
||||||
|
html += `<div class="grid-cell${set.has(key) ? ' occupied' : ''}"${style}${onclick} title="${title}"></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += '</div></div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
async function togglePause() {
|
async function togglePause() {
|
||||||
const endpoint = status.paused ? '/api/resume' : '/api/pause';
|
const endpoint = status.paused ? '/api/resume' : '/api/pause';
|
||||||
await fetch(endpoint, { method: 'POST' });
|
await fetch(endpoint, { method: 'POST' });
|
||||||
|
|
@ -750,7 +871,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
// Re-populate from latest status in case it changed
|
|
||||||
if (status.settings) {
|
if (status.settings) {
|
||||||
const s = status.settings;
|
const s = status.settings;
|
||||||
document.getElementById('settLogPath').value = s.poe2LogPath || '';
|
document.getElementById('settLogPath').value = s.poe2LogPath || '';
|
||||||
|
|
@ -766,12 +886,10 @@
|
||||||
document.getElementById('settingsModal').classList.remove('open');
|
document.getElementById('settingsModal').classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal on overlay click
|
|
||||||
document.getElementById('settingsModal').addEventListener('click', (e) => {
|
document.getElementById('settingsModal').addEventListener('click', (e) => {
|
||||||
if (e.target === e.currentTarget) closeSettings();
|
if (e.target === e.currentTarget) closeSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modal on Escape
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') closeSettings();
|
if (e.key === 'Escape') closeSettings();
|
||||||
});
|
});
|
||||||
|
|
@ -794,204 +912,105 @@
|
||||||
setTimeout(() => badge.classList.remove('show'), 2000);
|
setTimeout(() => badge.classList.remove('show'), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug functions
|
// --- Debug functions (fire-and-forget: POST returns instantly, results arrive via WS) ---
|
||||||
let lastGridLayout = null;
|
|
||||||
|
|
||||||
async function debugGridScan(layout, targetRow, targetCol) {
|
function debugScreenshot() {
|
||||||
lastGridLayout = layout;
|
showDebugResult('Taking screenshots...');
|
||||||
const hasTarget = targetRow != null && targetCol != null;
|
fetch('/api/debug/screenshot', { method: 'POST' });
|
||||||
showDebugResult(hasTarget ? `Matching (${targetRow},${targetCol}) in ${layout}...` : `Scanning ${layout}...`);
|
|
||||||
const body = { layout };
|
|
||||||
if (hasTarget) { body.targetRow = targetRow; body.targetCol = targetCol; }
|
|
||||||
const res = await fetch('/api/debug/grid-scan', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!data.ok) { showDebugResult(`Error: ${data.error}`); return; }
|
|
||||||
const el = document.getElementById('debugResult');
|
|
||||||
const count = data.occupied.length;
|
|
||||||
const items = data.items || [];
|
|
||||||
const matches = data.matches || [];
|
|
||||||
const r = data.region;
|
|
||||||
let html = `<b>${layout}</b> ${data.cols}x${data.rows}`;
|
|
||||||
html += ` — ${count} occupied, ${items.length} items`;
|
|
||||||
if (matches.length > 0) html += `, <span style="color:#f0883e;font-weight:bold">${matches.length} matches</span>`;
|
|
||||||
if (r) html += `<br><span style="color:#484f58">Region: (${r.x}, ${r.y}) ${r.width}x${r.height}</span>`;
|
|
||||||
if (items.length > 0) {
|
|
||||||
const sizes = {};
|
|
||||||
items.forEach(i => { const k = i.w + 'x' + i.h; sizes[k] = (sizes[k]||0) + 1; });
|
|
||||||
html += `<br><span style="color:#58a6ff">` + Object.entries(sizes).map(([k,v]) => `${v}x ${k}`).join(', ') + `</span>`;
|
|
||||||
}
|
|
||||||
if (hasTarget) {
|
|
||||||
html += `<br><span style="color:#f0883e">Target: (${targetRow},${targetCol})`;
|
|
||||||
if (matches.length > 0) html += ` → ${matches.map(m => `(${m.row},${m.col}) ${(m.similarity*100).toFixed(0)}%`).join(', ')}`;
|
|
||||||
html += `</span>`;
|
|
||||||
} else {
|
|
||||||
html += `<br><span style="color:#484f58">Click a cell to find matching items</span>`;
|
|
||||||
}
|
|
||||||
html += '<div class="grid-debug">';
|
|
||||||
if (data.image) {
|
|
||||||
html += `<img src="data:image/png;base64,${data.image}" alt="Grid capture" />`;
|
|
||||||
}
|
|
||||||
// Build match set for highlighting
|
|
||||||
const matchSet = new Set(matches.map(m => m.row + ',' + m.col));
|
|
||||||
const targetKey = hasTarget ? targetRow + ',' + targetCol : null;
|
|
||||||
// Build item map: cell → item info
|
|
||||||
const itemMap = {};
|
|
||||||
const colors = ['#238636','#1f6feb','#8957e5','#da3633','#d29922','#3fb950','#388bfd','#a371f7','#f85149','#e3b341'];
|
|
||||||
items.forEach((item, idx) => {
|
|
||||||
const color = colors[idx % colors.length];
|
|
||||||
for (let dr = 0; dr < item.h; dr++)
|
|
||||||
for (let dc = 0; dc < item.w; dc++)
|
|
||||||
itemMap[(item.row+dr)+','+(item.col+dc)] = { item, color, isOrigin: dr===0 && dc===0 };
|
|
||||||
});
|
|
||||||
html += `<div class="grid-view" style="grid-template-columns:repeat(${data.cols},12px)">`;
|
|
||||||
const set = new Set(data.occupied.map(c => c.row + ',' + c.col));
|
|
||||||
for (let r = 0; r < data.rows; r++) {
|
|
||||||
for (let c = 0; c < data.cols; c++) {
|
|
||||||
const key = r+','+c;
|
|
||||||
const isTarget = key === targetKey;
|
|
||||||
const isMatch = matchSet.has(key);
|
|
||||||
const info = itemMap[key];
|
|
||||||
let bg;
|
|
||||||
if (isTarget) bg = '#f0883e';
|
|
||||||
else if (isMatch) bg = '#d29922';
|
|
||||||
else if (info) bg = info.color;
|
|
||||||
else if (set.has(key)) bg = '#238636';
|
|
||||||
else bg = '';
|
|
||||||
const outline = (isTarget || isMatch) ? 'outline:2px solid #f0883e;z-index:1;' : '';
|
|
||||||
const cursor = set.has(key) ? 'cursor:pointer;' : '';
|
|
||||||
const bgStyle = bg ? `background:${bg};` : '';
|
|
||||||
const style = (bgStyle || outline || cursor) ? ` style="${bgStyle}${outline}${cursor}"` : '';
|
|
||||||
let title = info ? `(${r},${c}) ${info.item.w}x${info.item.h}` : `(${r},${c})`;
|
|
||||||
if (isTarget) title += ' [TARGET]';
|
|
||||||
if (isMatch) { const m = matches.find(m => m.row===r && m.col===c); title += ` [MATCH ${(m.similarity*100).toFixed(0)}%]`; }
|
|
||||||
const onclick = set.has(key) ? ` onclick="debugGridScan('${layout}',${r},${c})"` : '';
|
|
||||||
html += `<div class="grid-cell${set.has(key) ? ' occupied' : ''}"${style}${onclick} title="${title}"></div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
html += '</div></div>';
|
|
||||||
el.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function debugAngeOption(option) {
|
function debugOcr() {
|
||||||
showDebugResult(`Clicking ANGE → ${option}...`);
|
|
||||||
const res = await fetch('/api/debug/click-then-click', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ first: 'ANGE', second: option }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!data.found && data.step === 'first') {
|
|
||||||
showDebugResult('ANGE not found on screen');
|
|
||||||
} else if (!data.found) {
|
|
||||||
showDebugResult(`"${option}" not found in ANGE menu (timed out)`);
|
|
||||||
} else {
|
|
||||||
showDebugResult(`Clicked "${option}" at (${data.position.x}, ${data.position.y})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function debugHideout() {
|
|
||||||
showDebugResult('Sending /hideout...');
|
|
||||||
const res = await fetch('/api/debug/hideout', { method: 'POST' });
|
|
||||||
const data = await res.json();
|
|
||||||
showDebugResult(data.ok ? 'Sent /hideout command' : `Error: ${data.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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...');
|
showDebugResult('Running OCR...');
|
||||||
const res = await fetch('/api/debug/ocr', { method: 'POST' });
|
fetch('/api/debug/ocr', { method: 'POST' });
|
||||||
const data = await res.json();
|
|
||||||
showDebugResult(data.ok ? data.text : `Error: ${data.error}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function debugFindText() {
|
function debugHideout() {
|
||||||
|
showDebugResult('Sending /hideout...');
|
||||||
|
fetch('/api/debug/hideout', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugFindText() {
|
||||||
const text = document.getElementById('debugTextInput').value.trim();
|
const text = document.getElementById('debugTextInput').value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
showDebugResult(`Searching for "${text}"...`);
|
showDebugResult(`Searching for "${text}"...`);
|
||||||
const res = await fetch('/api/debug/find-text', {
|
fetch('/api/debug/find-text', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text }),
|
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(directText, fuzzy) {
|
function debugFindAndClick(directText, fuzzy) {
|
||||||
const text = directText || document.getElementById('debugTextInput').value.trim();
|
const text = directText || document.getElementById('debugTextInput').value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
showDebugResult(`Finding and clicking "${text}"${fuzzy ? ' (fuzzy)' : ''}...`);
|
showDebugResult(`Finding and clicking "${text}"${fuzzy ? ' (fuzzy)' : ''}...`);
|
||||||
const res = await fetch('/api/debug/find-and-click', {
|
fetch('/api/debug/find-and-click', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text, fuzzy: !!fuzzy }),
|
body: JSON.stringify({ text, fuzzy: !!fuzzy }),
|
||||||
});
|
});
|
||||||
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() {
|
function debugClick() {
|
||||||
const x = parseInt(document.getElementById('debugClickX').value);
|
const x = parseInt(document.getElementById('debugClickX').value);
|
||||||
const y = parseInt(document.getElementById('debugClickY').value);
|
const y = parseInt(document.getElementById('debugClickY').value);
|
||||||
if (isNaN(x) || isNaN(y)) return;
|
if (isNaN(x) || isNaN(y)) return;
|
||||||
const res = await fetch('/api/debug/click', {
|
showDebugResult(`Clicking at (${x}, ${y})...`);
|
||||||
|
fetch('/api/debug/click', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ x, y }),
|
body: JSON.stringify({ x, y }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
|
||||||
showDebugResult(data.ok ? `Clicked at (${x}, ${y})` : `Error: ${data.error}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function debugTestMatchHover() {
|
function debugAngeOption(option) {
|
||||||
|
showDebugResult(`Clicking ANGE → ${option}...`);
|
||||||
|
fetch('/api/debug/click-then-click', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ first: 'ANGE', second: option }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugGridScan(layout, targetRow, targetCol) {
|
||||||
|
const hasTarget = targetRow != null && targetCol != null;
|
||||||
|
showDebugResult(hasTarget ? `Matching (${targetRow},${targetCol}) in ${layout}...` : `Scanning ${layout}...`);
|
||||||
|
const body = { layout };
|
||||||
|
if (hasTarget) { body.targetRow = targetRow; body.targetCol = targetCol; }
|
||||||
|
fetch('/api/debug/grid-scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugTestMatchHover() {
|
||||||
const layout = document.getElementById('matchLayout').value;
|
const layout = document.getElementById('matchLayout').value;
|
||||||
const targetRow = parseInt(document.getElementById('matchRow').value);
|
const targetRow = parseInt(document.getElementById('matchRow').value);
|
||||||
const targetCol = parseInt(document.getElementById('matchCol').value);
|
const targetCol = parseInt(document.getElementById('matchCol').value);
|
||||||
if (isNaN(targetRow) || isNaN(targetCol)) { showDebugResult('Invalid row/col'); return; }
|
if (isNaN(targetRow) || isNaN(targetCol)) { showDebugResult('Invalid row/col'); return; }
|
||||||
showDebugResult(`Scanning ${layout} and matching (${targetRow},${targetCol})...`);
|
showDebugResult(`Scanning ${layout} and matching (${targetRow},${targetCol})...`);
|
||||||
const res = await fetch('/api/debug/test-match-hover', {
|
fetch('/api/debug/test-match-hover', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ layout, targetRow, targetCol }),
|
body: JSON.stringify({ layout, targetRow, targetCol }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
|
||||||
if (!data.ok) { showDebugResult(`Error: ${data.error}`); return; }
|
|
||||||
showDebugResult(`Done: ${data.itemSize} item, ${data.matchCount} matches, hovered ${data.hoveredCount} cells`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDebugResult(text) {
|
function showDebugResult(text) {
|
||||||
document.getElementById('debugResult').textContent = text;
|
document.getElementById('debugResult').textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter key in debug text input
|
// Enter key handlers
|
||||||
document.getElementById('debugTextInput').addEventListener('keydown', (e) => {
|
document.getElementById('debugTextInput').addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') debugFindText();
|
if (e.key === 'Enter') debugFindText();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enter key in URL input
|
|
||||||
document.getElementById('urlInput').addEventListener('keydown', (e) => {
|
document.getElementById('urlInput').addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') addLink();
|
if (e.key === 'Enter') addLink();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// OCR engine/preprocess (sync — these are instant)
|
||||||
async function setOcrEngine(engine) {
|
async function setOcrEngine(engine) {
|
||||||
await fetch('/api/debug/ocr-engine', {
|
await fetch('/api/debug/ocr-engine', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
23
src/server/routes/control.ts
Normal file
23
src/server/routes/control.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import type { Bot } from '../../bot/Bot.js';
|
||||||
|
|
||||||
|
export function controlRoutes(bot: Bot): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/pause', (_req, res) => {
|
||||||
|
bot.pause();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/resume', (_req, res) => {
|
||||||
|
bot.resume();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/settings', (req, res) => {
|
||||||
|
bot.updateSettings(req.body);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
295
src/server/routes/debug.ts
Normal file
295
src/server/routes/debug.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { mkdir } from 'fs/promises';
|
||||||
|
import { logger } from '../../util/logger.js';
|
||||||
|
import { sleep } from '../../util/sleep.js';
|
||||||
|
import { GRID_LAYOUTS } from '../../game/GridReader.js';
|
||||||
|
import type { Bot } from '../../bot/Bot.js';
|
||||||
|
import type { Server } from '../Server.js';
|
||||||
|
import type { OcrEngine, OcrPreprocess } from '../../game/OcrDaemon.js';
|
||||||
|
|
||||||
|
export function debugRoutes(bot: Bot, server: Server): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const notReady = (_req: any, res: any): boolean => {
|
||||||
|
if (!bot.isReady) { res.status(503).json({ error: 'Not ready' }); return true; }
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Sync: OCR engine/preprocess selection ---
|
||||||
|
|
||||||
|
router.get('/ocr-engine', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
res.json({ ok: true, engine: bot.screenReader.debugOcrEngine });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/ocr-engine', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
const { engine } = req.body as { engine: string };
|
||||||
|
if (!['tesseract', 'easyocr', 'paddleocr'].includes(engine)) {
|
||||||
|
res.status(400).json({ error: 'Invalid engine. Must be tesseract, easyocr, or paddleocr.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bot.screenReader.debugOcrEngine = engine as OcrEngine;
|
||||||
|
server.broadcastLog('info', `OCR engine set to: ${engine}`);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/ocr-preprocess', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
res.json({ ok: true, preprocess: bot.screenReader.debugPreprocess });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/ocr-preprocess', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
const { preprocess } = req.body as { preprocess: string };
|
||||||
|
if (!['none', 'bgsub', 'tophat'].includes(preprocess)) {
|
||||||
|
res.status(400).json({ error: 'Invalid preprocess. Must be none, bgsub, or tophat.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bot.screenReader.debugPreprocess = preprocess as OcrPreprocess;
|
||||||
|
server.broadcastLog('info', `OCR preprocess set to: ${preprocess}`);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Fire-and-forget: slow debug operations ---
|
||||||
|
|
||||||
|
router.post('/screenshot', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
res.json({ ok: true });
|
||||||
|
bot.screenReader.saveDebugScreenshots('debug-screenshots').then(files => {
|
||||||
|
server.broadcastLog('info', `Debug screenshots saved: ${files.map(f => f.split(/[\\/]/).pop()).join(', ')}`);
|
||||||
|
server.broadcastDebug('screenshot', { files });
|
||||||
|
}).catch(err => {
|
||||||
|
logger.error({ err }, 'Debug screenshot failed');
|
||||||
|
server.broadcastDebug('screenshot', { error: 'Screenshot failed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/ocr', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
res.json({ ok: true });
|
||||||
|
bot.screenReader.debugReadFullScreen().then(text => {
|
||||||
|
server.broadcastLog('info', `OCR [${bot.screenReader.debugOcrEngine}] (${text.length} chars): ${text.substring(0, 200)}`);
|
||||||
|
server.broadcastDebug('ocr', { text });
|
||||||
|
}).catch(err => {
|
||||||
|
logger.error({ err }, 'Debug OCR failed');
|
||||||
|
server.broadcastDebug('ocr', { error: 'OCR failed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/find-text', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
const { text } = req.body as { text: string };
|
||||||
|
if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; }
|
||||||
|
res.json({ ok: true });
|
||||||
|
bot.screenReader.debugFindTextOnScreen(text).then(pos => {
|
||||||
|
if (pos) {
|
||||||
|
server.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) [${bot.screenReader.debugOcrEngine}]`);
|
||||||
|
} else {
|
||||||
|
server.broadcastLog('warn', `"${text}" not found on screen [${bot.screenReader.debugOcrEngine}]`);
|
||||||
|
}
|
||||||
|
server.broadcastDebug('find-text', { searchText: text, found: !!pos, position: pos });
|
||||||
|
}).catch(err => {
|
||||||
|
logger.error({ err }, 'Debug find-text failed');
|
||||||
|
server.broadcastDebug('find-text', { searchText: text, error: 'Find text failed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/find-and-click', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
const { text, fuzzy } = req.body as { text: string; fuzzy?: boolean };
|
||||||
|
if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; }
|
||||||
|
res.json({ ok: true });
|
||||||
|
(async () => {
|
||||||
|
const pos = await bot.screenReader.debugFindTextOnScreen(text, !!fuzzy);
|
||||||
|
if (pos) {
|
||||||
|
await bot.gameController.focusGame();
|
||||||
|
await bot.gameController.leftClickAt(pos.x, pos.y);
|
||||||
|
server.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) and clicked [${bot.screenReader.debugOcrEngine}]`);
|
||||||
|
server.broadcastDebug('find-and-click', { searchText: text, found: true, position: pos });
|
||||||
|
} else {
|
||||||
|
server.broadcastLog('warn', `"${text}" not found on screen [${bot.screenReader.debugOcrEngine}]`);
|
||||||
|
server.broadcastDebug('find-and-click', { searchText: text, found: false, position: null });
|
||||||
|
}
|
||||||
|
})().catch(err => {
|
||||||
|
logger.error({ err }, 'Debug find-and-click failed');
|
||||||
|
server.broadcastDebug('find-and-click', { searchText: text, error: 'Find and click failed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/click', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
const { x, y } = req.body as { x: number; y: number };
|
||||||
|
if (x == null || y == null) { res.status(400).json({ error: 'Missing x/y' }); return; }
|
||||||
|
res.json({ ok: true });
|
||||||
|
(async () => {
|
||||||
|
await bot.gameController.focusGame();
|
||||||
|
await bot.gameController.leftClickAt(x, y);
|
||||||
|
server.broadcastLog('info', `Clicked at (${x}, ${y})`);
|
||||||
|
server.broadcastDebug('click', { x, y });
|
||||||
|
})().catch(err => {
|
||||||
|
logger.error({ err }, 'Debug click failed');
|
||||||
|
server.broadcastDebug('click', { x, y, error: 'Click failed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/hideout', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
res.json({ ok: true });
|
||||||
|
(async () => {
|
||||||
|
await bot.gameController.focusGame();
|
||||||
|
await bot.gameController.goToHideout();
|
||||||
|
server.broadcastLog('info', 'Sent /hideout command');
|
||||||
|
server.broadcastDebug('hideout', {});
|
||||||
|
})().catch(err => {
|
||||||
|
logger.error({ err }, 'Debug hideout failed');
|
||||||
|
server.broadcastDebug('hideout', { error: 'Hideout command failed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/click-then-click', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
const { first, second, timeout = 5000 } = req.body as { first: string; second: string; timeout?: number };
|
||||||
|
if (!first || !second) { res.status(400).json({ error: 'Missing first/second' }); return; }
|
||||||
|
res.json({ ok: true });
|
||||||
|
(async () => {
|
||||||
|
const pos1 = await bot.screenReader.findTextOnScreen(first);
|
||||||
|
if (!pos1) {
|
||||||
|
server.broadcastLog('warn', `"${first}" not found on screen`);
|
||||||
|
server.broadcastDebug('click-then-click', { first, second, found: false, step: 'first' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bot.gameController.focusGame();
|
||||||
|
await bot.gameController.leftClickAt(pos1.x, pos1.y);
|
||||||
|
server.broadcastLog('info', `Clicked "${first}" at (${pos1.x}, ${pos1.y}), waiting for "${second}"...`);
|
||||||
|
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const pos2 = await bot.screenReader.findTextOnScreen(second);
|
||||||
|
if (pos2) {
|
||||||
|
await bot.gameController.leftClickAt(pos2.x, pos2.y);
|
||||||
|
server.broadcastLog('info', `Clicked "${second}" at (${pos2.x}, ${pos2.y})`);
|
||||||
|
server.broadcastDebug('click-then-click', { first, second, found: true, position: pos2 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.broadcastLog('warn', `"${second}" not found after clicking "${first}" (timed out)`);
|
||||||
|
server.broadcastDebug('click-then-click', { first, second, found: false, step: 'second' });
|
||||||
|
})().catch(err => {
|
||||||
|
logger.error({ err }, 'Debug click-then-click failed');
|
||||||
|
server.broadcastDebug('click-then-click', { first, second, error: 'Click-then-click failed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/grid-scan', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
const { layout: layoutName, targetRow, targetCol } = req.body as { layout: string; targetRow?: number; targetCol?: number };
|
||||||
|
if (!GRID_LAYOUTS[layoutName]) { res.status(400).json({ error: `Unknown grid layout: ${layoutName}` }); return; }
|
||||||
|
res.json({ ok: true });
|
||||||
|
(async () => {
|
||||||
|
const result = await bot.screenReader.grid.scan(layoutName, undefined, targetRow, targetCol);
|
||||||
|
const imageBuffer = await bot.screenReader.captureRegion(result.layout.region);
|
||||||
|
const imageBase64 = imageBuffer.toString('base64');
|
||||||
|
const r = result.layout.region;
|
||||||
|
const matchInfo = result.matches ? `, ${result.matches.length} matches` : '';
|
||||||
|
server.broadcastLog('info',
|
||||||
|
`Grid scan (${layoutName}): ${result.layout.cols}x${result.layout.rows} at (${r.x},${r.y}) ${r.width}x${r.height} — ${result.occupied.length} occupied cells${matchInfo}`);
|
||||||
|
server.broadcastDebug('grid-scan', {
|
||||||
|
layout: layoutName,
|
||||||
|
occupied: result.occupied,
|
||||||
|
items: result.items,
|
||||||
|
matches: result.matches,
|
||||||
|
cols: result.layout.cols,
|
||||||
|
rows: result.layout.rows,
|
||||||
|
image: imageBase64,
|
||||||
|
region: result.layout.region,
|
||||||
|
targetRow,
|
||||||
|
targetCol,
|
||||||
|
});
|
||||||
|
})().catch(err => {
|
||||||
|
logger.error({ err }, 'Debug grid-scan failed');
|
||||||
|
server.broadcastDebug('grid-scan', { layout: layoutName, error: 'Grid scan failed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/test-match-hover', (req, res) => {
|
||||||
|
if (notReady(req, res)) return;
|
||||||
|
const { layout: layoutName, targetRow, targetCol } = req.body as { layout: string; targetRow: number; targetCol: number };
|
||||||
|
if (!GRID_LAYOUTS[layoutName]) { res.status(400).json({ error: `Unknown layout: ${layoutName}` }); return; }
|
||||||
|
if (targetRow == null || targetCol == null) { res.status(400).json({ error: 'Missing targetRow/targetCol' }); return; }
|
||||||
|
res.json({ ok: true });
|
||||||
|
(async () => {
|
||||||
|
server.broadcastLog('info', `Scanning ${layoutName} with target (${targetRow},${targetCol})...`);
|
||||||
|
const result = await bot.screenReader.grid.scan(layoutName, undefined, targetRow, targetCol);
|
||||||
|
const matches = result.matches ?? [];
|
||||||
|
const items = result.items ?? [];
|
||||||
|
|
||||||
|
const targetItem = items.find(i => targetRow >= i.row && targetRow < i.row + i.h && targetCol >= i.col && targetCol < i.col + i.w);
|
||||||
|
const itemSize = targetItem ? `${targetItem.w}x${targetItem.h}` : '1x1';
|
||||||
|
server.broadcastLog('info', `Target (${targetRow},${targetCol}) is ${itemSize}, found ${matches.length} matches`);
|
||||||
|
|
||||||
|
const hoverCells = [
|
||||||
|
{ row: targetRow, col: targetCol, label: 'TARGET' },
|
||||||
|
...matches.map(m => ({ row: m.row, col: m.col, label: `MATCH ${(m.similarity * 100).toFixed(0)}%` })),
|
||||||
|
];
|
||||||
|
|
||||||
|
await bot.gameController.focusGame();
|
||||||
|
await mkdir('items', { recursive: true });
|
||||||
|
const tooltips: Array<{ row: number; col: number; label: string; text: string }> = [];
|
||||||
|
const ts = Date.now();
|
||||||
|
const reg = result.layout.region;
|
||||||
|
const cellW = reg.width / result.layout.cols;
|
||||||
|
const cellH = reg.height / result.layout.rows;
|
||||||
|
|
||||||
|
// Move mouse to empty space and take reference snapshot
|
||||||
|
bot.gameController.moveMouseInstant(reg.x + reg.width + 50, reg.y + reg.height / 2);
|
||||||
|
await sleep(50);
|
||||||
|
await bot.screenReader.snapshot();
|
||||||
|
await bot.screenReader.saveScreenshot(`items/${ts}_snapshot.png`);
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
for (const cell of hoverCells) {
|
||||||
|
const cellStart = performance.now();
|
||||||
|
const x = Math.round(reg.x + cell.col * cellW + cellW / 2);
|
||||||
|
const y = Math.round(reg.y + cell.row * cellH + cellH / 2);
|
||||||
|
|
||||||
|
await bot.gameController.moveMouseFast(x, y);
|
||||||
|
await sleep(50);
|
||||||
|
const afterMove = performance.now();
|
||||||
|
|
||||||
|
const imgPath = `items/${ts}_${cell.row}-${cell.col}.png`;
|
||||||
|
const diff = await bot.screenReader.diffOcr(imgPath);
|
||||||
|
const afterOcr = performance.now();
|
||||||
|
const text = diff.text.trim();
|
||||||
|
|
||||||
|
const regionInfo = diff.region ? ` at (${diff.region.x},${diff.region.y}) ${diff.region.width}x${diff.region.height}` : '';
|
||||||
|
tooltips.push({ row: cell.row, col: cell.col, label: cell.label, text });
|
||||||
|
|
||||||
|
server.broadcastLog('info',
|
||||||
|
`${cell.label} (${cell.row},${cell.col}) [move: ${(afterMove - cellStart).toFixed(0)}ms, ocr: ${(afterOcr - afterMove).toFixed(0)}ms, total: ${(afterOcr - cellStart).toFixed(0)}ms]${regionInfo}:`);
|
||||||
|
if (diff.lines.length > 0) {
|
||||||
|
for (const line of diff.lines) {
|
||||||
|
server.broadcastLog('info', ` ${line.text}`);
|
||||||
|
}
|
||||||
|
} else if (text) {
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if (line.trim()) server.broadcastLog('info', ` ${line.trim()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.broadcastLog('info', `Done — hovered ${hoverCells.length} cells, read ${tooltips.filter(t => t.text).length} tooltips`);
|
||||||
|
server.broadcastDebug('test-match-hover', {
|
||||||
|
itemSize,
|
||||||
|
matchCount: matches.length,
|
||||||
|
hoveredCount: hoverCells.length,
|
||||||
|
tooltips,
|
||||||
|
});
|
||||||
|
})().catch(err => {
|
||||||
|
logger.error({ err }, 'Debug test-match-hover failed');
|
||||||
|
server.broadcastDebug('test-match-hover', { error: 'Test match hover failed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
56
src/server/routes/links.ts
Normal file
56
src/server/routes/links.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import type { Bot } from '../../bot/Bot.js';
|
||||||
|
|
||||||
|
export function linkRoutes(bot: Bot): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
const { url, name, mode } = req.body as { url: string; name?: string; mode?: string };
|
||||||
|
if (!url || !url.includes('pathofexile.com/trade')) {
|
||||||
|
res.status(400).json({ error: 'Invalid trade URL' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const linkMode = mode === 'scrap' ? 'scrap' : 'live';
|
||||||
|
bot.addLink(url, name || '', linkMode);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
bot.removeLink(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/toggle', (req, res) => {
|
||||||
|
const { active } = req.body as { active: boolean };
|
||||||
|
bot.toggleLink(req.params.id, active);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/name', (req, res) => {
|
||||||
|
const { name } = req.body as { name: string };
|
||||||
|
bot.updateLinkName(req.params.id, name);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/mode', (req, res) => {
|
||||||
|
const { mode } = req.body as { mode: string };
|
||||||
|
if (mode !== 'live' && mode !== 'scrap') {
|
||||||
|
res.status(400).json({ error: 'Invalid mode. Must be "live" or "scrap".' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bot.updateLinkMode(req.params.id, mode);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/post-action', (req, res) => {
|
||||||
|
const { postAction } = req.body as { postAction: string };
|
||||||
|
if (postAction !== 'stash' && postAction !== 'salvage') {
|
||||||
|
res.status(400).json({ error: 'Invalid postAction. Must be "stash" or "salvage".' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bot.updateLinkPostAction(req.params.id, postAction);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
12
src/server/routes/status.ts
Normal file
12
src/server/routes/status.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import type { Bot } from '../../bot/Bot.js';
|
||||||
|
|
||||||
|
export function statusRoutes(bot: Bot): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/status', (_req, res) => {
|
||||||
|
res.json(bot.getStatus());
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue