poe2-bot/src-old/log/ClientLogWatcher.ts
2026-02-13 01:12:11 -05:00

182 lines
5.7 KiB
TypeScript

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<typeof watch> | 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<void> {
// 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<void> {
if (this.watcher) {
await this.watcher.close();
this.watcher = null;
}
logger.info('Client log watcher stopped');
}
}