added easyOCR

This commit is contained in:
Boki 2026-02-12 01:04:19 -05:00
parent 37d6678577
commit 9f208b0606
27 changed files with 1780 additions and 112 deletions

View file

@ -1,5 +1,6 @@
import { EventEmitter } from 'events';
import { logger } from '../util/logger.js';
import type { LinkMode } from '../types.js';
import type { ConfigStore, SavedLink } from './ConfigStore.js';
export interface TradeLink {
@ -8,6 +9,7 @@ export interface TradeLink {
name: string;
label: string;
active: boolean;
mode: LinkMode;
addedAt: string;
}
@ -25,6 +27,11 @@ export interface BotStatus {
waitForMoreItemsMs: number;
betweenTradesDelayMs: number;
};
inventory?: {
grid: boolean[][];
items: { row: number; col: number; w: number; h: number }[];
free: number;
};
}
export class BotController extends EventEmitter {
@ -35,6 +42,7 @@ export class BotController extends EventEmitter {
private tradesFailed = 0;
private startTime = Date.now();
private store: ConfigStore;
private _inventory: BotStatus['inventory'] = undefined;
constructor(store: ConfigStore) {
super();
@ -69,7 +77,7 @@ export class BotController extends EventEmitter {
this.emit('resumed');
}
addLink(url: string, name: string = ''): TradeLink {
addLink(url: string, name: string = '', mode?: LinkMode): TradeLink {
url = this.stripLive(url);
const id = this.extractId(url);
const label = this.extractLabel(url);
@ -81,11 +89,12 @@ export class BotController extends EventEmitter {
name: name || savedLink?.name || '',
label,
active: savedLink?.active !== undefined ? savedLink.active : true,
mode: mode || savedLink?.mode || 'live',
addedAt: new Date().toISOString(),
};
this.links.set(id, link);
this.store.addLink(url, link.name);
logger.info({ id, url, name: link.name, active: link.active }, 'Trade link added');
this.store.addLink(url, link.name, link.mode);
logger.info({ id, url, name: link.name, active: link.active, mode: link.mode }, 'Trade link added');
this.emit('link-added', link);
return link;
}
@ -118,6 +127,15 @@ export class BotController extends EventEmitter {
this.store.updateLinkById(id, { name });
}
updateLinkMode(id: string, mode: LinkMode): void {
const link = this.links.get(id);
if (!link) return;
link.mode = mode;
this.store.updateLinkById(id, { mode });
logger.info({ id, mode }, 'Trade link mode updated');
this.emit('link-mode-changed', { id, mode, link });
}
isLinkActive(searchId: string): boolean {
const link = this.links.get(searchId);
return link ? link.active : false;
@ -153,9 +171,14 @@ export class BotController extends EventEmitter {
waitForMoreItemsMs: s.waitForMoreItemsMs,
betweenTradesDelayMs: s.betweenTradesDelayMs,
},
inventory: this._inventory,
};
}
setInventory(inv: BotStatus['inventory']): void {
this._inventory = inv;
}
getStore(): ConfigStore {
return this.store;
}

View file

@ -1,11 +1,13 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import path from 'path';
import { logger } from '../util/logger.js';
import type { LinkMode } from '../types.js';
export interface SavedLink {
url: string;
name: string;
active: boolean;
mode: LinkMode;
addedAt: string;
}
@ -55,10 +57,11 @@ export class ConfigStore {
const parsed = JSON.parse(raw) as Partial<SavedSettings>;
const merged = { ...DEFAULTS, ...parsed };
// Migrate old links: add name/active fields, strip /live from URLs
merged.links = merged.links.map((l) => ({
merged.links = merged.links.map((l: any) => ({
url: l.url.replace(/\/live\/?$/, ''),
name: l.name || '',
active: l.active !== undefined ? l.active : true,
mode: l.mode || 'live',
addedAt: l.addedAt || new Date().toISOString(),
}));
logger.info({ path: this.filePath, linkCount: merged.links.length }, 'Loaded config.json');
@ -85,10 +88,10 @@ export class ConfigStore {
return this.data.links;
}
addLink(url: string, name: string = ''): void {
addLink(url: string, name: string = '', mode: LinkMode = 'live'): void {
url = url.replace(/\/live\/?$/, '');
if (this.data.links.some((l) => l.url === url)) return;
this.data.links.push({ url, name, active: true, addedAt: new Date().toISOString() });
this.data.links.push({ url, name, active: true, mode, addedAt: new Date().toISOString() });
this.save();
}
@ -105,7 +108,7 @@ export class ConfigStore {
this.save();
}
updateLinkById(id: string, updates: { name?: string; active?: boolean }): SavedLink | null {
updateLinkById(id: string, updates: { name?: string; active?: boolean; mode?: LinkMode }): SavedLink | null {
const link = this.data.links.find((l) => {
const parts = l.url.split('/');
return parts[parts.length - 1] === id;
@ -113,6 +116,7 @@ export class ConfigStore {
if (!link) return null;
if (updates.name !== undefined) link.name = updates.name;
if (updates.active !== undefined) link.active = updates.active;
if (updates.mode !== undefined) link.mode = updates.mode;
this.save();
return link;
}

View file

@ -8,6 +8,7 @@ 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 } from '../game/OcrDaemon.js';
import { GRID_LAYOUTS } from '../game/GridReader.js';
import type { GameController } from '../game/GameController.js';
@ -54,12 +55,13 @@ export class DashboardServer {
// Links CRUD
this.app.post('/api/links', (req, res) => {
const { url, name } = req.body as { url: string; name?: string };
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;
}
this.bot.addLink(url, name || '');
const linkMode = mode === 'scrap' ? 'scrap' : 'live';
this.bot.addLink(url, name || '', linkMode);
this.broadcastStatus();
res.json({ ok: true });
});
@ -86,6 +88,18 @@ export class DashboardServer {
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 });
});
// Settings
this.app.post('/api/settings', (req, res) => {
const updates = req.body as Record<string, unknown>;
@ -108,11 +122,29 @@ export class DashboardServer {
}
});
// 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'].includes(engine)) {
res.status(400).json({ error: 'Invalid engine. Must be tesseract or easyocr.' });
return;
}
this.debug.screenReader.debugOcrEngine = engine as OcrEngine;
this.broadcastLog('info', `OCR engine set to: ${engine}`);
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.readFullScreen();
this.broadcastLog('info', `OCR result (${text.length} chars): ${text.substring(0, 200)}`);
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');
@ -125,11 +157,11 @@ export class DashboardServer {
const { text } = req.body as { text: string };
if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; }
try {
const pos = await this.debug.screenReader.findTextOnScreen(text);
const pos = await this.debug.screenReader.debugFindTextOnScreen(text);
if (pos) {
this.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y})`);
this.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) [${this.debug.screenReader.debugOcrEngine}]`);
} else {
this.broadcastLog('warn', `"${text}" not found on screen`);
this.broadcastLog('warn', `"${text}" not found on screen [${this.debug.screenReader.debugOcrEngine}]`);
}
res.json({ ok: true, found: !!pos, position: pos });
} catch (err) {
@ -233,17 +265,17 @@ export class DashboardServer {
this.app.post('/api/debug/find-and-click', async (req, res) => {
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
const { text } = req.body as { text: string };
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.findTextOnScreen(text);
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.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.broadcastLog('warn', `"${text}" not found on screen [${this.debug.screenReader.debugOcrEngine}]`);
res.json({ ok: true, found: false, position: null });
}
} catch (err) {

View file

@ -129,6 +129,34 @@
.link-item button { padding: 4px 12px; font-size: 12px; }
.link-item.inactive { opacity: 0.5; }
.mode-badge {
display: inline-block;
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.mode-badge.live { background: #1f6feb; color: #fff; }
.mode-badge.live:hover { background: #388bfd; }
.mode-badge.scrap { background: #9e6a03; color: #fff; }
.mode-badge.scrap:hover { background: #d29922; }
.mode-select {
padding: 6px 10px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 13px;
outline: none;
}
.mode-select:focus { border-color: #58a6ff; }
/* Toggle switch */
.toggle { position: relative; width: 36px; height: 20px; cursor: pointer; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; }
@ -316,6 +344,41 @@
}
.detect-badge.ok { background: #238636; color: #fff; }
.detect-badge.fallback { background: #9e6a03; color: #fff; }
/* Inventory grid */
.inv-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.inv-free {
font-size: 12px;
color: #8b949e;
font-weight: 600;
}
.inventory-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 2px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 10px;
}
.inv-cell {
aspect-ratio: 1;
border-radius: 3px;
background: #0d1117;
min-width: 0;
}
.inv-cell.occupied {
background: #238636;
}
.inv-cell.item-top { border-top: 2px solid #3fb950; }
.inv-cell.item-bottom { border-bottom: 2px solid #3fb950; }
.inv-cell.item-left { border-left: 2px solid #3fb950; }
.inv-cell.item-right { border-right: 2px solid #3fb950; }
</style>
</head>
<body>
@ -359,11 +422,25 @@
<button class="warning" id="pauseBtn" onclick="togglePause()">Pause</button>
</div>
<div class="section">
<div class="inv-header">
<div class="section-title" style="margin-bottom:0">Inventory</div>
<span class="inv-free" id="invFreeCount"></span>
</div>
<div class="inventory-grid" id="inventoryGrid">
<div class="empty-state" style="grid-column:1/-1">No active scrap session</div>
</div>
</div>
<div class="section">
<div class="section-title">Trade Links</div>
<div class="add-link">
<input type="text" id="nameInput" placeholder="Name (optional)" style="max-width:180px" />
<input type="text" id="urlInput" placeholder="Paste trade URL..." />
<select id="modeInput" class="mode-select" style="width:90px">
<option value="live">Live</option>
<option value="scrap">Scrap</option>
</select>
<button class="primary" onclick="addLink()">Add</button>
</div>
<div class="links-list" id="linksList">
@ -375,6 +452,10 @@
<div class="section-title">Debug Tools</div>
<div class="debug-panel">
<div class="debug-row">
<select id="ocrEngineSelect" onchange="setOcrEngine(this.value)" style="padding:6px 10px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e6edf3;font-size:13px">
<option value="tesseract">Tesseract</option>
<option value="easyocr">EasyOCR</option>
</select>
<button onclick="debugScreenshot()">Screenshot</button>
<button onclick="debugOcr()">OCR Screen</button>
<button onclick="debugHideout()">Go Hideout</button>
@ -382,6 +463,7 @@
<div class="debug-row">
<button onclick="debugFindAndClick('ANGE')">ANGE</button>
<button onclick="debugFindAndClick('STASH')">STASH</button>
<button onclick="debugFindAndClick('SALVAGE BENCH', true)">SALVAGE</button>
</div>
<div class="debug-row">
<button onclick="debugAngeOption('Currency')">Currency Exchange</button>
@ -512,6 +594,9 @@
// Settings (populate once on first status)
if (status.settings) populateSettings(status.settings);
// Inventory grid
renderInventory();
// Active links count
document.getElementById('linksValue').textContent = status.links.filter(l => l.active).length;
@ -527,6 +612,7 @@
<input type="checkbox" ${link.active ? 'checked' : ''} onchange="toggleLink('${esc(link.id)}', this.checked)" />
<span class="slider"></span>
</label>
<span class="mode-badge ${link.mode || 'live'}" onclick="cycleMode('${esc(link.id)}', '${link.mode || 'live'}')" title="Click to change mode">${esc(link.mode || 'live')}</span>
<div class="link-info">
<div class="link-name" contenteditable="true" spellcheck="false"
onblur="renameLink('${esc(link.id)}', this.textContent)"
@ -542,6 +628,41 @@
}
}
function renderInventory() {
const container = document.getElementById('inventoryGrid');
const freeLabel = document.getElementById('invFreeCount');
if (!status.inventory) {
container.innerHTML = '<div class="empty-state" style="grid-column:1/-1">No active scrap session</div>';
freeLabel.textContent = '';
return;
}
const { grid, items, free } = status.inventory;
freeLabel.textContent = `${free}/60 free`;
let html = '';
for (let r = 0; r < 5; r++) {
for (let c = 0; c < 12; c++) {
const occupied = grid[r] && grid[r][c] ? 'occupied' : '';
html += `<div class="inv-cell ${occupied}" data-r="${r}" data-c="${c}"></div>`;
}
}
container.innerHTML = html;
for (const item of items) {
for (let r = item.row; r < item.row + item.h; r++) {
for (let c = item.col; c < item.col + item.w; c++) {
const cell = container.querySelector(`[data-r="${r}"][data-c="${c}"]`);
if (cell) {
if (r === item.row) cell.classList.add('item-top');
if (r === item.row + item.h - 1) cell.classList.add('item-bottom');
if (c === item.col) cell.classList.add('item-left');
if (c === item.col + item.w - 1) cell.classList.add('item-right');
}
}
}
}
}
function addLog(data) {
const panel = document.getElementById('logPanel');
const line = document.createElement('div');
@ -561,12 +682,13 @@
async function addLink() {
const urlEl = document.getElementById('urlInput');
const nameEl = document.getElementById('nameInput');
const modeEl = document.getElementById('modeInput');
const url = urlEl.value.trim();
if (!url) return;
await fetch('/api/links', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, name: nameEl.value.trim() }),
body: JSON.stringify({ url, name: nameEl.value.trim(), mode: modeEl.value }),
});
urlEl.value = '';
nameEl.value = '';
@ -596,6 +718,15 @@
}, 300);
}
async function cycleMode(id, currentMode) {
const newMode = currentMode === 'live' ? 'scrap' : 'live';
await fetch('/api/links/' + encodeURIComponent(id) + '/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: newMode }),
});
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
@ -795,14 +926,14 @@
}
}
async function debugFindAndClick(directText) {
async function debugFindAndClick(directText, fuzzy) {
const text = directText || document.getElementById('debugTextInput').value.trim();
if (!text) return;
showDebugResult(`Finding and clicking "${text}"...`);
showDebugResult(`Finding and clicking "${text}"${fuzzy ? ' (fuzzy)' : ''}...`);
const res = await fetch('/api/debug/find-and-click', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
body: JSON.stringify({ text, fuzzy: !!fuzzy }),
});
const data = await res.json();
if (data.found) {
@ -855,7 +986,26 @@
if (e.key === 'Enter') addLink();
});
async function setOcrEngine(engine) {
await fetch('/api/debug/ocr-engine', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ engine }),
});
}
async function loadOcrEngine() {
try {
const res = await fetch('/api/debug/ocr-engine');
const data = await res.json();
if (data.ok && data.engine) {
document.getElementById('ocrEngineSelect').value = data.engine;
}
} catch {}
}
connect();
loadOcrEngine();
</script>
</body>
</html>

View file

@ -0,0 +1,433 @@
import { join } from 'path';
import { GameController } from '../game/GameController.js';
import { ScreenReader } from '../game/ScreenReader.js';
import { GridReader, GRID_LAYOUTS } from '../game/GridReader.js';
import { ClientLogWatcher } from '../log/ClientLogWatcher.js';
import { TradeMonitor } from '../trade/TradeMonitor.js';
import { InventoryTracker } from '../inventory/InventoryTracker.js';
import { sleep, randomDelay } from '../util/sleep.js';
import { logger } from '../util/logger.js';
import type { Config, ScrapState, TradeItem } from '../types.js';
import type { Page } from 'playwright';
const SALVAGE_TEMPLATE = join('assets', 'salvage.png');
export class ScrapExecutor {
private inventory = new InventoryTracker();
private state: ScrapState = 'IDLE';
private stopped = false;
private atOwnHideout = true;
private currentSellerAccount = '';
private activePage: Page | null = null;
private gameController: GameController;
private screenReader: ScreenReader;
private logWatcher: ClientLogWatcher;
private tradeMonitor: TradeMonitor;
private config: Config;
constructor(
gameController: GameController,
screenReader: ScreenReader,
logWatcher: ClientLogWatcher,
tradeMonitor: TradeMonitor,
config: Config,
) {
this.gameController = gameController;
this.screenReader = screenReader;
this.logWatcher = logWatcher;
this.tradeMonitor = tradeMonitor;
this.config = config;
}
getState(): ScrapState {
return this.state;
}
getInventoryState(): { grid: boolean[][]; items: { row: number; col: number; w: number; h: number }[]; free: number } {
return {
grid: this.inventory.getGrid(),
items: this.inventory.getItems(),
free: this.inventory.freeCells,
};
}
/** Stop the scrap loop gracefully. */
async stop(): Promise<void> {
this.stopped = true;
if (this.activePage) {
try { await this.activePage.close(); } catch { /* best-effort */ }
this.activePage = null;
}
this.state = 'IDLE';
logger.info('Scrap executor stopped');
}
/** Main entry point — runs the full scrap loop. */
async runScrapLoop(tradeUrl: string): Promise<void> {
this.stopped = false;
logger.info({ tradeUrl }, 'Starting scrap loop');
// Scan real inventory to know current state
await this.scanInventory();
let { page, items } = await this.tradeMonitor.openScrapPage(tradeUrl);
this.activePage = page;
logger.info({ itemCount: items.length }, 'Trade page opened, items fetched');
while (!this.stopped) {
let salvageFailed = false;
for (const item of items) {
if (this.stopped) break;
// Check if this item fits before traveling
if (!this.inventory.canFit(item.w, item.h)) {
// If salvage already failed this page, don't retry — skip remaining items
if (salvageFailed) {
logger.info({ w: item.w, h: item.h }, 'Skipping item (salvage already failed this page)');
continue;
}
logger.info({ w: item.w, h: item.h, free: this.inventory.freeCells }, 'No room for item, running salvage cycle');
await this.salvageAndStore();
// Check if salvage succeeded (state is IDLE on success, FAILED otherwise)
if (this.state === 'FAILED') {
salvageFailed = true;
this.state = 'IDLE';
logger.warn('Salvage failed, skipping remaining items that do not fit');
continue;
}
// Re-scan inventory after salvage to get accurate state
await this.scanInventory();
}
// Still no room after salvage — skip this item
if (!this.inventory.canFit(item.w, item.h)) {
logger.warn({ w: item.w, h: item.h, free: this.inventory.freeCells }, 'Item still cannot fit after salvage, skipping');
continue;
}
const success = await this.buyItem(page, item);
if (!success) {
logger.warn({ itemId: item.id }, 'Failed to buy item, continuing');
continue;
}
await randomDelay(500, 1000);
}
if (this.stopped) break;
// Page exhausted — refresh and get new items
logger.info('Page exhausted, refreshing...');
items = await this.refreshPage(page);
logger.info({ itemCount: items.length }, 'Page refreshed');
if (items.length === 0) {
logger.info('No items after refresh, waiting before retry...');
await sleep(5000);
if (this.stopped) break;
items = await this.refreshPage(page);
}
}
this.activePage = null;
this.state = 'IDLE';
logger.info('Scrap loop ended');
}
/** Scan the real inventory via grid reader and initialize the tracker. */
private async scanInventory(): Promise<void> {
logger.info('Scanning inventory...');
await this.gameController.focusGame();
await sleep(300);
await this.gameController.openInventory();
const result = await this.screenReader.grid.scan('inventory');
// Build cells grid from occupied coords
const cells: boolean[][] = Array.from({ length: 5 }, () => Array(12).fill(false));
for (const cell of result.occupied) {
if (cell.row < 5 && cell.col < 12) {
cells[cell.row][cell.col] = true;
}
}
this.inventory.initFromScan(cells, result.items);
// Close inventory
await this.gameController.pressEscape();
await sleep(300);
}
/** Buy one item from a seller. */
private async buyItem(page: Page, item: TradeItem): Promise<boolean> {
try {
const alreadyAtSeller = !this.atOwnHideout
&& item.account
&& item.account === this.currentSellerAccount;
if (alreadyAtSeller) {
logger.info({ itemId: item.id, account: item.account }, 'Already at seller hideout, skipping travel');
} else {
this.state = 'TRAVELING';
// Register listener BEFORE clicking, then click inside the callback
const arrived = await this.waitForAreaTransition(
this.config.travelTimeoutMs,
async () => {
const clicked = await this.tradeMonitor.clickTravelToHideout(page, item.id);
if (!clicked) {
throw new Error('Failed to click Travel to Hideout');
}
},
);
if (!arrived) {
logger.error({ itemId: item.id }, 'Timed out waiting for hideout arrival');
this.state = 'FAILED';
return false;
}
this.atOwnHideout = false;
this.currentSellerAccount = item.account;
await this.gameController.focusGame();
await sleep(1500); // Wait for hideout to render
}
this.state = 'BUYING';
// CTRL+Click at seller stash position
const sellerLayout = GRID_LAYOUTS.seller;
const cellCenter = this.screenReader.grid.getCellCenter(sellerLayout, item.stashY, item.stashX);
logger.info({ itemId: item.id, stashX: item.stashX, stashY: item.stashY, screenX: cellCenter.x, screenY: cellCenter.y }, 'CTRL+clicking seller stash item');
await this.gameController.ctrlLeftClickAt(cellCenter.x, cellCenter.y);
await randomDelay(200, 400);
// Track in inventory
const placed = this.inventory.tryPlace(item.w, item.h);
if (!placed) {
logger.warn({ itemId: item.id, w: item.w, h: item.h }, 'Item bought but could not track in inventory');
}
logger.info({ itemId: item.id, free: this.inventory.freeCells }, 'Item bought successfully');
this.state = 'IDLE';
return true;
} catch (err) {
logger.error({ err, itemId: item.id }, 'Error buying item');
this.state = 'FAILED';
return false;
}
}
/** Salvage all items in inventory and store the materials. */
private async salvageAndStore(): Promise<void> {
try {
// Go to own hideout (skip if already there)
await this.gameController.focusGame();
await sleep(300);
if (this.atOwnHideout) {
logger.info('Already at own hideout, skipping /hideout');
} else {
this.state = 'TRAVELING';
// Register listener BEFORE sending /hideout command
const arrived = await this.waitForAreaTransition(
this.config.travelTimeoutMs,
() => this.gameController.goToHideout(),
);
if (!arrived) {
logger.error('Timed out going home for salvage');
this.state = 'FAILED';
return;
}
await sleep(1500); // Wait for hideout to render
}
this.atOwnHideout = true;
this.currentSellerAccount = '';
// Open salvage bench via nameplate OCR
this.state = 'SALVAGING';
const salvageNameplate = await this.findAndClickNameplate('SALVAGE BENCH');
if (!salvageNameplate) {
logger.error('Could not find Salvage nameplate');
this.state = 'FAILED';
return;
}
await sleep(1000); // Wait for salvage bench UI to open
// Template-match salvage.png to activate salvage mode within the bench UI
const salvageBtn = await this.screenReader.templateMatch(SALVAGE_TEMPLATE);
if (salvageBtn) {
await this.gameController.leftClickAt(salvageBtn.x, salvageBtn.y);
await sleep(500);
} else {
logger.warn('Could not find salvage button via template match, trying to proceed anyway');
}
// CTRL+Click each inventory item to salvage
const inventoryLayout = GRID_LAYOUTS.inventory;
const itemsToSalvage = this.inventory.getItems();
logger.info({ count: itemsToSalvage.length }, 'Salvaging inventory items');
await this.gameController.holdCtrl();
for (const item of itemsToSalvage) {
const center = this.screenReader.grid.getCellCenter(inventoryLayout, item.row, item.col);
await this.gameController.leftClickAt(center.x, center.y);
await sleep(150);
}
await this.gameController.releaseCtrl();
await sleep(500);
// Close salvage bench (Escape)
await this.gameController.pressEscape();
await sleep(500);
// Open stash to store salvaged materials
this.state = 'STORING';
const stashPos = await this.findAndClickNameplate('Stash');
if (!stashPos) {
logger.error('Could not find Stash nameplate');
this.state = 'FAILED';
return;
}
await sleep(1000); // Wait for stash to open
// CTRL+Click each remaining inventory item to store
await this.gameController.holdCtrl();
for (const item of itemsToSalvage) {
const center = this.screenReader.grid.getCellCenter(inventoryLayout, item.row, item.col);
await this.gameController.leftClickAt(center.x, center.y);
await sleep(150);
}
await this.gameController.releaseCtrl();
await sleep(500);
// Clear inventory tracker
this.inventory.clear();
this.state = 'IDLE';
logger.info('Salvage and store cycle complete');
} catch (err) {
logger.error({ err }, 'Salvage cycle failed');
// Try to recover UI state
try {
await this.gameController.pressEscape();
await sleep(300);
} catch {
// Best-effort
}
this.inventory.clear();
// Leave state as FAILED so the caller knows salvage didn't succeed
this.state = 'FAILED';
}
}
/** Refresh the trade page and return new items. */
private async refreshPage(page: Page): Promise<TradeItem[]> {
const items: TradeItem[] = [];
// Set up response listener before reloading
const responseHandler = async (response: { url(): string; json(): Promise<any> }) => {
if (response.url().includes('/api/trade2/fetch/')) {
try {
const json = await response.json();
if (json.result && Array.isArray(json.result)) {
for (const r of json.result) {
items.push({
id: r.id,
w: r.item?.w ?? 1,
h: r.item?.h ?? 1,
stashX: r.listing?.stash?.x ?? 0,
stashY: r.listing?.stash?.y ?? 0,
account: r.listing?.account?.name ?? '',
});
}
}
} catch {
// Response may not be JSON
}
}
};
page.on('response', responseHandler);
await page.reload({ waitUntil: 'networkidle' });
await sleep(2000);
page.off('response', responseHandler);
return items;
}
/**
* Wait for area transition via Client.txt log.
* If `triggerAction` is provided, the listener is registered BEFORE the action
* executes, preventing the race where the event fires before we listen.
*/
private waitForAreaTransition(
timeoutMs: number,
triggerAction?: () => Promise<void>,
): Promise<boolean> {
return new Promise((resolve) => {
let resolved = false;
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
this.logWatcher.removeListener('area-entered', handler);
resolve(false);
}
}, timeoutMs);
const handler = () => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
resolve(true);
}
};
// Register listener FIRST
this.logWatcher.once('area-entered', handler);
// THEN trigger the action that causes the transition
if (triggerAction) {
triggerAction().catch(() => {
// If the action itself fails, clean up and resolve false
if (!resolved) {
resolved = true;
clearTimeout(timer);
this.logWatcher.removeListener('area-entered', handler);
resolve(false);
}
});
}
});
}
/** Find and click a nameplate by OCR text. */
private async findAndClickNameplate(
name: string,
maxRetries: number = 3,
retryDelayMs: number = 1000,
): Promise<{ x: number; y: number } | null> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
logger.info({ name, attempt, maxRetries }, 'Searching for nameplate...');
const pos = await this.screenReader.findTextOnScreen(name, true);
if (pos) {
logger.info({ name, x: pos.x, y: pos.y }, 'Clicking nameplate');
await this.gameController.leftClickAt(pos.x, pos.y);
return pos;
}
if (attempt < maxRetries) {
await sleep(retryDelayMs);
}
}
logger.warn({ name, maxRetries }, 'Nameplate not found after all retries');
return null;
}
}

View file

@ -124,4 +124,16 @@ export class GameController {
await this.inputSender.pressKey(VK.I);
await sleep(300);
}
async ctrlLeftClickAt(x: number, y: number): Promise<void> {
await this.inputSender.ctrlLeftClick(x, y);
}
async holdCtrl(): Promise<void> {
await this.inputSender.keyDown(VK.CONTROL);
}
async releaseCtrl(): Promise<void> {
await this.inputSender.keyUp(VK.CONTROL);
}
}

View file

@ -288,6 +288,14 @@ export class InputSender {
await randomDelay(30, 60);
}
async ctrlLeftClick(x: number, y: number): Promise<void> {
await this.keyDown(VK.CONTROL);
await randomDelay(30, 60);
await this.leftClick(x, y);
await this.keyUp(VK.CONTROL);
await randomDelay(30, 60);
}
private sendMouseInput(dx: number, dy: number, mouseData: number, flags: number): void {
const input = {
type: INPUT_MOUSE_TYPE,

View file

@ -58,6 +58,17 @@ export interface DetectGridResult {
cellHeight?: number;
}
export interface TemplateMatchResult {
found: boolean;
x: number;
y: number;
width: number;
height: number;
confidence: number;
}
export type OcrEngine = 'tesseract' | 'easyocr';
interface DaemonRequest {
cmd: string;
region?: Region;
@ -67,6 +78,7 @@ interface DaemonRequest {
threshold?: number;
minCellSize?: number;
maxCellSize?: number;
engine?: string;
}
interface DaemonResponse {
@ -84,6 +96,12 @@ interface DaemonResponse {
rows?: number;
cellWidth?: number;
cellHeight?: number;
found?: boolean;
x?: number;
y?: number;
width?: number;
height?: number;
confidence?: number;
error?: string;
}
@ -115,10 +133,13 @@ export class OcrDaemon {
// ── Public API ──────────────────────────────────────────────────────────
async ocr(region?: Region): Promise<OcrResponse> {
async ocr(region?: Region, engine?: OcrEngine): Promise<OcrResponse> {
const req: DaemonRequest = { cmd: 'ocr' };
if (region) req.region = region;
const resp = await this.sendWithRetry(req, REQUEST_TIMEOUT);
if (engine && engine !== 'tesseract') req.engine = engine;
// Python engines need longer timeout for first model load + download
const timeout = (engine && engine !== 'tesseract') ? 120_000 : CAPTURE_TIMEOUT;
const resp = await this.sendWithRetry(req, timeout);
return {
ok: true,
text: resp.text ?? '',
@ -161,11 +182,13 @@ export class OcrDaemon {
await this.sendWithRetry({ cmd: 'snapshot' }, REQUEST_TIMEOUT);
}
async diffOcr(savePath?: string, region?: Region): Promise<DiffOcrResponse> {
async diffOcr(savePath?: string, region?: Region, engine?: OcrEngine): Promise<DiffOcrResponse> {
const req: DaemonRequest = { cmd: 'diff-ocr' };
if (savePath) req.path = savePath;
if (region) req.region = region;
const resp = await this.sendWithRetry(req, REQUEST_TIMEOUT);
if (engine && engine !== 'tesseract') req.engine = engine;
const timeout = (engine && engine !== 'tesseract') ? 120_000 : CAPTURE_TIMEOUT;
const resp = await this.sendWithRetry(req, timeout);
return {
text: resp.text ?? '',
lines: resp.lines ?? [],
@ -179,6 +202,21 @@ export class OcrDaemon {
await this.sendWithRetry(req, REQUEST_TIMEOUT);
}
async templateMatch(templatePath: string, region?: Region): Promise<TemplateMatchResult | null> {
const req: DaemonRequest = { cmd: 'match-template', path: templatePath };
if (region) req.region = region;
const resp = await this.sendWithRetry(req, REQUEST_TIMEOUT);
if (!resp.found) return null;
return {
found: true,
x: resp.x!,
y: resp.y!,
width: resp.width!,
height: resp.height!,
confidence: resp.confidence!,
};
}
async stop(): Promise<void> {
this.stopped = true;
if (this.proc) {

View file

@ -1,7 +1,7 @@
import { mkdir } from 'fs/promises';
import { join } from 'path';
import { logger } from '../util/logger.js';
import { OcrDaemon, type OcrResponse, type DiffOcrResponse } from './OcrDaemon.js';
import { OcrDaemon, type OcrResponse, type OcrEngine, type DiffOcrResponse, type TemplateMatchResult } from './OcrDaemon.js';
import { GridReader, type GridLayout, type CellCoord } from './GridReader.js';
import type { Region } from '../types.js';
@ -12,6 +12,7 @@ function elapsed(start: number): string {
export class ScreenReader {
private daemon = new OcrDaemon();
readonly grid = new GridReader(this.daemon);
debugOcrEngine: OcrEngine = 'tesseract';
// ── Screenshot capture ──────────────────────────────────────────────
@ -31,32 +32,122 @@ export class ScreenReader {
// ── OCR helpers ─────────────────────────────────────────────────────
/** Bigram (Dice) similarity between two strings, 0..1. */
private static bigramSimilarity(a: string, b: string): number {
if (a.length < 2 || b.length < 2) return a === b ? 1 : 0;
const bigramsA = new Map<string, number>();
for (let i = 0; i < a.length - 1; i++) {
const bg = a.slice(i, i + 2);
bigramsA.set(bg, (bigramsA.get(bg) ?? 0) + 1);
}
let matches = 0;
for (let i = 0; i < b.length - 1; i++) {
const bg = b.slice(i, i + 2);
const count = bigramsA.get(bg);
if (count && count > 0) {
matches++;
bigramsA.set(bg, count - 1);
}
}
return (2 * matches) / (a.length - 1 + b.length - 1);
}
/** Normalize text for fuzzy comparison: lowercase, strip non-alphanumeric, collapse spaces. */
private static normalize(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]/g, '');
}
private findWordInOcrResult(
result: OcrResponse,
needle: string,
fuzzy: boolean = false,
): { x: number; y: number } | null {
const lower = needle.toLowerCase();
const FUZZY_THRESHOLD = 0.55;
// Multi-word: match against the full line text, return center of the line's bounding box
if (lower.includes(' ')) {
const needleNorm = ScreenReader.normalize(needle);
for (const line of result.lines) {
if (line.words.length === 0) continue;
const lineText = line.text.toLowerCase();
// Exact match
if (lineText.includes(lower)) {
return this.lineBounds(line);
}
// Fuzzy: normalize line text and check sliding windows
if (fuzzy) {
const lineNorm = ScreenReader.normalize(line.text);
// Check windows of similar length to the needle
const windowLen = needleNorm.length;
for (let i = 0; i <= lineNorm.length - windowLen + 2; i++) {
const window = lineNorm.slice(i, i + windowLen + 2);
const sim = ScreenReader.bigramSimilarity(needleNorm, window);
if (sim >= FUZZY_THRESHOLD) {
logger.info({ needle, matched: line.text, similarity: sim.toFixed(2) }, 'Fuzzy nameplate match');
return this.lineBounds(line);
}
}
}
}
return null;
}
// Single word: match against individual words
const needleNorm = ScreenReader.normalize(needle);
for (const line of result.lines) {
for (const word of line.words) {
// Exact match
if (word.text.toLowerCase().includes(lower)) {
return {
x: Math.round(word.x + word.width / 2),
y: Math.round(word.y + word.height / 2),
};
}
// Fuzzy match
if (fuzzy) {
const wordNorm = ScreenReader.normalize(word.text);
const sim = ScreenReader.bigramSimilarity(needleNorm, wordNorm);
if (sim >= FUZZY_THRESHOLD) {
logger.info({ needle, matched: word.text, similarity: sim.toFixed(2) }, 'Fuzzy word match');
return {
x: Math.round(word.x + word.width / 2),
y: Math.round(word.y + word.height / 2),
};
}
}
}
}
return null;
}
/** Get center of a line's bounding box from its words. */
private lineBounds(line: { words: { x: number; y: number; width: number; height: number }[] }): { x: number; y: number } {
const first = line.words[0];
const last = line.words[line.words.length - 1];
const x1 = first.x;
const y1 = first.y;
const x2 = last.x + last.width;
const y2 = Math.max(...line.words.map(w => w.y + w.height));
return {
x: Math.round((x1 + x2) / 2),
y: Math.round((y1 + y2) / 2),
};
}
// ── Full-screen methods ─────────────────────────────────────────────
async findTextOnScreen(
searchText: string,
fuzzy: boolean = false,
): Promise<{ x: number; y: number } | null> {
const t = performance.now();
const result = await this.daemon.ocr();
const pos = this.findWordInOcrResult(result, searchText);
const pos = this.findWordInOcrResult(result, searchText, fuzzy);
if (pos) {
logger.info({ searchText, x: pos.x, y: pos.y, totalMs: elapsed(t) }, 'Found text on screen');
@ -112,6 +203,19 @@ export class ScreenReader {
return this.daemon.diffOcr(savePath, region);
}
// ── Template matching ──────────────────────────────────────────────
async templateMatch(templatePath: string, region?: Region): Promise<TemplateMatchResult | null> {
const t = performance.now();
const result = await this.daemon.templateMatch(templatePath, region);
if (result) {
logger.info({ templatePath, x: result.x, y: result.y, confidence: result.confidence.toFixed(3), ms: elapsed(t) }, 'Template match found');
} else {
logger.info({ templatePath, ms: elapsed(t) }, 'Template match not found');
}
return result;
}
// ── Save utilities ──────────────────────────────────────────────────
async saveScreenshot(path: string): Promise<void> {
@ -133,6 +237,43 @@ export class ScreenReader {
logger.info({ path, region }, 'Region screenshot saved');
}
// ── Debug OCR (alternative engines) ─────────────────────────────────
async debugDiffOcr(savePath?: string, region?: Region): Promise<DiffOcrResponse> {
const t = performance.now();
const result = await this.daemon.diffOcr(savePath, region, this.debugOcrEngine);
logger.info({ engine: this.debugOcrEngine, ms: elapsed(t) }, 'debugDiffOcr');
return result;
}
async debugOcr(region?: Region): Promise<OcrResponse> {
const t = performance.now();
const result = await this.daemon.ocr(region, this.debugOcrEngine);
logger.info({ engine: this.debugOcrEngine, ms: elapsed(t) }, 'debugOcr');
return result;
}
async debugReadFullScreen(): Promise<string> {
const result = await this.daemon.ocr(undefined, this.debugOcrEngine);
return result.text;
}
async debugFindTextOnScreen(
searchText: string,
fuzzy: boolean = false,
): Promise<{ x: number; y: number } | null> {
const t = performance.now();
const result = await this.daemon.ocr(undefined, this.debugOcrEngine);
const pos = this.findWordInOcrResult(result, searchText, fuzzy);
if (pos) {
logger.info({ searchText, engine: this.debugOcrEngine, x: pos.x, y: pos.y, totalMs: elapsed(t) }, 'debugFindText found');
} else {
logger.info({ searchText, engine: this.debugOcrEngine, totalMs: elapsed(t) }, 'debugFindText not found');
}
return pos;
}
// ── Lifecycle ───────────────────────────────────────────────────────
async dispose(): Promise<void> {

View file

@ -5,11 +5,13 @@ import { GameController } from './game/GameController.js';
import { ScreenReader } from './game/ScreenReader.js';
import { ClientLogWatcher } from './log/ClientLogWatcher.js';
import { TradeExecutor } from './executor/TradeExecutor.js';
import { ScrapExecutor } from './executor/ScrapExecutor.js';
import { TradeQueue } from './executor/TradeQueue.js';
import { BotController } from './dashboard/BotController.js';
import { DashboardServer } from './dashboard/DashboardServer.js';
import { ConfigStore } from './dashboard/ConfigStore.js';
import { logger } from './util/logger.js';
import type { TradeLink } from './dashboard/BotController.js';
import type { Page } from 'playwright';
const program = new Command();
@ -73,6 +75,7 @@ program
const logWatcher = new ClientLogWatcher(config.poe2LogPath);
await logWatcher.start();
logWatcher.currentArea = 'Hideout'; // We just sent /hideout on startup
dashboard.broadcastLog('info', 'Watching Client.txt for game events');
const tradeMonitor = new TradeMonitor(config);
@ -89,23 +92,59 @@ program
const tradeQueue = new TradeQueue(executor, config);
// Helper to add a trade search
const activateLink = async (url: string) => {
// Track running scrap executors per link ID
const scrapExecutors = new Map<string, ScrapExecutor>();
// Activate a link based on its mode
const activateLink = async (link: TradeLink) => {
try {
await tradeMonitor.addSearch(url);
dashboard.broadcastLog('info', `Monitoring: ${url}`);
dashboard.broadcastStatus();
if (link.mode === 'scrap') {
// Start scrap loop for this link
const scrapExec = new ScrapExecutor(
gameController,
screenReader,
logWatcher,
tradeMonitor,
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).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 }, 'Failed to add trade search');
dashboard.broadcastLog('error', `Failed to add: ${url}`);
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);
};
// Load all saved + CLI links (only activate ones marked active)
for (const url of allUrls) {
const link = bot.addLink(url);
if (link.active) {
await activateLink(url);
await activateLink(link);
} else {
dashboard.broadcastLog('info', `Loaded (inactive): ${link.name || link.label}`);
}
@ -113,31 +152,40 @@ program
dashboard.broadcastLog('info', `Loaded ${allUrls.size} trade link(s) from config`);
// When dashboard adds a link, activate it in the trade monitor
bot.on('link-added', async (link) => {
// When dashboard adds a link, activate it
bot.on('link-added', async (link: TradeLink) => {
if (link.active) {
await activateLink(link.url);
await activateLink(link);
}
});
// When dashboard removes a link, deactivate it
bot.on('link-removed', async (id: string) => {
await tradeMonitor.removeSearch(id);
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: { url: string; name: string } }) => {
bot.on('link-toggled', async (data: { id: string; active: boolean; link: TradeLink }) => {
if (data.active) {
await activateLink(data.link.url);
await activateLink(data.link);
dashboard.broadcastLog('info', `Activated: ${data.link.name || data.id}`);
} else {
await tradeMonitor.pauseSearch(data.id);
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}`);
}
});
// 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) {
@ -168,9 +216,43 @@ program
// Forward executor state changes to dashboard
const stateInterval = setInterval(() => {
// Feed inventory state from active scrap executors
let inventorySet = false;
for (const [, scrapExec] of scrapExecutors) {
const inv = scrapExec.getInventoryState();
if (inv) {
bot.setInventory(inv);
inventorySet = true;
break;
}
}
if (!inventorySet) bot.setInventory(undefined);
// Check live trade executor state
const execState = executor.getState();
if (bot.state !== execState) {
bot.state = execState;
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);
@ -179,6 +261,9 @@ program
const shutdown = async () => {
logger.info('Shutting down...');
clearInterval(stateInterval);
for (const [, scrapExec] of scrapExecutors) {
await scrapExec.stop();
}
await screenReader.dispose();
await dashboard.stop();
await tradeMonitor.stop();

View file

@ -0,0 +1,115 @@
import { logger } from '../util/logger.js';
const ROWS = 5;
const COLS = 12;
interface PlacedItem {
row: number;
col: number;
w: number;
h: number;
}
export class InventoryTracker {
private grid: boolean[][];
private items: PlacedItem[] = [];
constructor() {
this.grid = Array.from({ length: ROWS }, () => Array(COLS).fill(false));
}
/** Initialize from a grid scan result (occupied cells + detected items). */
initFromScan(cells: boolean[][], items: { row: number; col: number; w: number; h: number }[]): void {
// Reset
for (let r = 0; r < ROWS; r++) {
this.grid[r].fill(false);
}
this.items = [];
// Mark occupied cells from scan
for (let r = 0; r < Math.min(cells.length, ROWS); r++) {
for (let c = 0; c < Math.min(cells[r].length, COLS); c++) {
this.grid[r][c] = cells[r][c];
}
}
// Record detected items
for (const item of items) {
this.items.push({ row: item.row, col: item.col, w: item.w, h: item.h });
}
logger.info({ occupied: ROWS * COLS - this.freeCells, items: this.items.length, free: this.freeCells }, 'Inventory initialized from scan');
}
/** Try to place an item of size w×h. Column-first to match game's left-priority placement. */
tryPlace(w: number, h: number): { row: number; col: number } | null {
for (let col = 0; col <= COLS - w; col++) {
for (let row = 0; row <= ROWS - h; row++) {
if (this.fits(row, col, w, h)) {
this.place(row, col, w, h);
logger.info({ row, col, w, h, free: this.freeCells }, 'Item placed in inventory');
return { row, col };
}
}
}
return null;
}
/** Check if an item of size w×h can fit anywhere. */
canFit(w: number, h: number): boolean {
for (let col = 0; col <= COLS - w; col++) {
for (let row = 0; row <= ROWS - h; row++) {
if (this.fits(row, col, w, h)) return true;
}
}
return false;
}
/** Get all placed items. */
getItems(): PlacedItem[] {
return [...this.items];
}
/** Get a copy of the occupancy grid. */
getGrid(): boolean[][] {
return this.grid.map(row => [...row]);
}
/** Clear entire grid. */
clear(): void {
for (let r = 0; r < ROWS; r++) {
this.grid[r].fill(false);
}
this.items = [];
logger.info('Inventory cleared');
}
/** Get remaining free cells count. */
get freeCells(): number {
let count = 0;
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (!this.grid[r][c]) count++;
}
}
return count;
}
private fits(row: number, col: number, w: number, h: number): boolean {
for (let r = row; r < row + h; r++) {
for (let c = col; c < col + w; c++) {
if (this.grid[r][c]) return false;
}
}
return true;
}
private place(row: number, col: number, w: number, h: number): void {
for (let r = row; r < row + h; r++) {
for (let c = col; c < col + w; c++) {
this.grid[r][c] = true;
}
}
this.items.push({ row, col, w, h });
}
}

View file

@ -19,6 +19,9 @@ export class ClientLogWatcher extends EventEmitter {
private fileOffset: number = 0;
private logPath: string;
/** Last area we transitioned into (from [SCENE] Set Source or "You have entered"). */
currentArea: string = '';
constructor(logPath: string) {
super();
this.logPath = logPath;
@ -71,10 +74,25 @@ export class ClientLogWatcher extends EventEmitter {
private parseLine(line: string): void {
this.emit('line', line);
// Area transition: "You have entered Hideout"
// Area transition: "[SCENE] Set Source [Shoreline Hideout]"
// POE2 uses this format instead of "You have entered ..."
const sceneMatch = line.match(/\[SCENE\] Set Source \[(.+?)\]/);
if (sceneMatch) {
const area = sceneMatch[1];
// Skip the "(null)" transition — it's an intermediate state before the real area loads
if (area !== '(null)') {
this.currentArea = area;
logger.info({ area }, 'Area entered');
this.emit('area-entered', area);
}
return;
}
// Legacy fallback: "You have entered Hideout"
const areaMatch = line.match(/You have entered (.+?)\.?$/);
if (areaMatch) {
const area = areaMatch[1];
this.currentArea = area;
logger.info({ area }, 'Area entered');
this.emit('area-entered', area);
return;

View file

@ -3,7 +3,7 @@ import { chromium, type Browser, type BrowserContext, type Page, type WebSocket
import { SELECTORS } from './selectors.js';
import { logger } from '../util/logger.js';
import { sleep } from '../util/sleep.js';
import type { Config } from '../types.js';
import type { Config, TradeItem } from '../types.js';
// Stealth JS injected into every page to avoid Playwright detection
const STEALTH_SCRIPT = `
@ -226,6 +226,40 @@ export class TradeMonitor extends EventEmitter {
}
}
async openScrapPage(tradeUrl: string): Promise<{ page: Page; items: TradeItem[] }> {
if (!this.context) throw new Error('Browser not started');
const page = await this.context.newPage();
const items: TradeItem[] = [];
page.on('response', async (response) => {
if (response.url().includes('/api/trade2/fetch/')) {
try {
const json = await response.json();
if (json.result && Array.isArray(json.result)) {
for (const r of json.result) {
items.push({
id: r.id,
w: r.item?.w ?? 1,
h: r.item?.h ?? 1,
stashX: r.listing?.stash?.x ?? 0,
stashY: r.listing?.stash?.y ?? 0,
account: r.listing?.account?.name ?? '',
});
}
}
} catch {
// Response may not be JSON
}
}
});
await page.goto(tradeUrl, { waitUntil: 'networkidle' });
await sleep(2000); // ensure API response received
logger.info({ url: tradeUrl, itemCount: items.length }, 'Scrap page opened');
return { page, items };
}
extractSearchId(url: string): string {
const cleaned = url.replace(/\/live\/?$/, '');
const parts = cleaned.split('/');

View file

@ -56,3 +56,16 @@ export interface LogEvent {
type: 'area-entered' | 'whisper-received' | 'trade-accepted' | 'unknown';
data: Record<string, string>;
}
export type LinkMode = 'live' | 'scrap';
export type ScrapState = 'IDLE' | 'TRAVELING' | 'BUYING' | 'SALVAGING' | 'STORING' | 'FAILED';
export interface TradeItem {
id: string;
w: number;
h: number;
stashX: number;
stashY: number;
account: string;
}