182 lines
5.7 KiB
TypeScript
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');
|
|
}
|
|
}
|