import { EventEmitter } from 'events'; import { watch } from 'chokidar'; import { createReadStream, statSync, openSync, readSync, closeSync } from 'fs'; import { createInterface } from 'readline'; import { logger } from '../util/logger.js'; export interface LogEvents { 'area-entered': (area: string) => void; 'whisper-received': (data: { player: string; message: string }) => void; 'whisper-sent': (data: { player: string; message: string }) => void; 'trade-accepted': () => void; 'party-joined': (player: string) => void; 'party-left': (player: string) => void; line: (line: string) => void; } export class ClientLogWatcher extends EventEmitter { private watcher: ReturnType | null = null; private fileOffset: number = 0; private logPath: string; /** Last area we transitioned into (from [SCENE] Set Source or "You have entered"). */ currentArea: string = ''; constructor(logPath: string) { super(); this.logPath = logPath; } async start(): Promise { // Start reading from end of file (only new lines) try { const stats = statSync(this.logPath); this.fileOffset = stats.size; // Read tail of log to determine current area before we start watching this.detectCurrentArea(stats.size); } catch { logger.warn({ path: this.logPath }, 'Log file not found yet, will watch for creation'); this.fileOffset = 0; } this.watcher = watch(this.logPath, { persistent: true, usePolling: true, interval: 200, }); this.watcher.on('change', () => { this.readNewLines(); }); logger.info({ path: this.logPath, currentArea: this.currentArea || '(unknown)' }, 'Watching Client.txt for game events'); } /** Read the last chunk of the log file to determine the current area. */ private detectCurrentArea(fileSize: number): void { const TAIL_BYTES = 8192; const start = Math.max(0, fileSize - TAIL_BYTES); const buf = Buffer.alloc(Math.min(TAIL_BYTES, fileSize)); const fd = openSync(this.logPath, 'r'); try { readSync(fd, buf, 0, buf.length, start); } finally { closeSync(fd); } const tail = buf.toString('utf-8'); const lines = tail.split(/\r?\n/); // Walk backwards to find the most recent area transition for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; const sceneMatch = line.match(/\[SCENE\] Set Source \[(.+?)\]/); if (sceneMatch && sceneMatch[1] !== '(null)') { this.currentArea = sceneMatch[1]; logger.info({ area: this.currentArea }, 'Detected current area from log tail'); return; } const areaMatch = line.match(/You have entered (.+?)\.?$/); if (areaMatch) { this.currentArea = areaMatch[1]; logger.info({ area: this.currentArea }, 'Detected current area from log tail'); return; } } } private readNewLines(): void { const stream = createReadStream(this.logPath, { start: this.fileOffset, encoding: 'utf-8', }); const rl = createInterface({ input: stream }); let bytesRead = 0; rl.on('line', (line) => { bytesRead += Buffer.byteLength(line, 'utf-8') + 2; // +2 for \r\n on Windows if (line.trim()) { this.parseLine(line.trim()); } }); rl.on('close', () => { this.fileOffset += bytesRead; }); } private parseLine(line: string): void { this.emit('line', line); // Area transition: "[SCENE] Set Source [Shoreline Hideout]" // POE2 uses this format instead of "You have entered ..." const sceneMatch = line.match(/\[SCENE\] Set Source \[(.+?)\]/); if (sceneMatch) { const area = sceneMatch[1]; // Skip the "(null)" transition — it's an intermediate state before the real area loads if (area !== '(null)') { this.currentArea = area; logger.info({ area }, 'Area entered'); this.emit('area-entered', area); } return; } // Legacy fallback: "You have entered Hideout" const areaMatch = line.match(/You have entered (.+?)\.?$/); if (areaMatch) { const area = areaMatch[1]; this.currentArea = area; logger.info({ area }, 'Area entered'); this.emit('area-entered', area); return; } // Incoming whisper: "@From PlayerName: message" const whisperFromMatch = line.match(/@From\s+(.+?):\s+(.+)$/); if (whisperFromMatch) { const data = { player: whisperFromMatch[1], message: whisperFromMatch[2] }; logger.info(data, 'Whisper received'); this.emit('whisper-received', data); return; } // Outgoing whisper: "@To PlayerName: message" const whisperToMatch = line.match(/@To\s+(.+?):\s+(.+)$/); if (whisperToMatch) { const data = { player: whisperToMatch[1], message: whisperToMatch[2] }; this.emit('whisper-sent', data); return; } // Party join: "PlayerName has joined the party" const partyJoinMatch = line.match(/(.+?) has joined the party/); if (partyJoinMatch) { logger.info({ player: partyJoinMatch[1] }, 'Player joined party'); this.emit('party-joined', partyJoinMatch[1]); return; } // Party leave: "PlayerName has left the party" const partyLeaveMatch = line.match(/(.+?) has left the party/); if (partyLeaveMatch) { this.emit('party-left', partyLeaveMatch[1]); return; } // Trade accepted if (line.includes('Trade accepted') || line.includes('Trade completed')) { logger.info('Trade accepted/completed'); this.emit('trade-accepted'); return; } } async stop(): Promise { if (this.watcher) { await this.watcher.close(); this.watcher = null; } logger.info('Client log watcher stopped'); } }