379 lines
13 KiB
Python
379 lines
13 KiB
Python
"""
|
|
EU-Utility - Optimized Log Reader
|
|
|
|
Performance improvements:
|
|
1. Compiled regex patterns (cached at module level)
|
|
2. Ring buffer for O(1) line storage
|
|
3. Pattern matching cache for repeated lines
|
|
4. Batch processing for multiple lines
|
|
5. Memory-efficient string storage
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import time
|
|
import threading
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import List, Dict, Callable, Optional
|
|
from dataclasses import dataclass, field
|
|
from collections import deque
|
|
|
|
from core.performance_optimizations import RingBuffer, StringInterner
|
|
|
|
|
|
@dataclass
|
|
class LogEvent:
|
|
"""Represents a parsed log event."""
|
|
timestamp: datetime
|
|
raw_line: str
|
|
event_type: str
|
|
data: Dict = field(default_factory=dict)
|
|
|
|
|
|
class LogReader:
|
|
"""
|
|
Optimized core service for reading and parsing EU chat.log.
|
|
|
|
Performance features:
|
|
- Compiled regex patterns (cached)
|
|
- Ring buffer for recent lines (O(1) append)
|
|
- LRU cache for pattern matching
|
|
- Batch line processing
|
|
- String interning for memory efficiency
|
|
"""
|
|
|
|
# Pre-compiled patterns (module level for reuse across instances)
|
|
_COMPILED_PATTERNS: Dict[str, re.Pattern] = {}
|
|
_PATTERNS_LOCK = threading.Lock()
|
|
|
|
LOG_PATHS = [
|
|
Path.home() / "Documents" / "Entropia Universe" / "chat.log",
|
|
Path.home() / "Documents" / "Entropia Universe" / "Logs" / "chat.log",
|
|
Path.home() / "Entropia Universe" / "chat.log",
|
|
]
|
|
|
|
def __init__(self, log_path: Path = None):
|
|
self.log_path = log_path or self._find_log_file()
|
|
self.running = False
|
|
self.thread = None
|
|
self.last_position = 0
|
|
|
|
# Subscribers: {event_type: [callbacks]}
|
|
self._subscribers: Dict[str, List[Callable]] = {}
|
|
self._any_subscribers: List[Callable] = []
|
|
self._subscribers_lock = threading.RLock()
|
|
|
|
# Optimized: Use RingBuffer for O(1) append/pop
|
|
self._recent_lines = RingBuffer(1000)
|
|
|
|
# String interner for memory efficiency
|
|
self._string_interner = StringInterner(max_size=5000)
|
|
|
|
# Pattern matching cache (LRU)
|
|
self._pattern_cache: Dict[str, Optional[LogEvent]] = {}
|
|
self._cache_max_size = 10000
|
|
self._cache_lock = threading.Lock()
|
|
|
|
# Stats
|
|
self._stats = {
|
|
'lines_read': 0,
|
|
'events_parsed': 0,
|
|
'start_time': None,
|
|
'cache_hits': 0,
|
|
'cache_misses': 0,
|
|
}
|
|
self._stats_lock = threading.Lock()
|
|
|
|
# Ensure patterns are compiled
|
|
self._ensure_patterns()
|
|
|
|
@classmethod
|
|
def _ensure_patterns(cls):
|
|
"""Ensure regex patterns are compiled (thread-safe)."""
|
|
with cls._PATTERNS_LOCK:
|
|
if not cls._COMPILED_PATTERNS:
|
|
cls._COMPILED_PATTERNS = {
|
|
'skill_gain': re.compile(
|
|
r'(.+?)\s+has\s+improved\s+by\s+(\d+\.?\d*)\s+points?',
|
|
re.IGNORECASE
|
|
),
|
|
'loot': re.compile(
|
|
r'You\s+received\s+(.+?)\s+x\s*(\d+)',
|
|
re.IGNORECASE
|
|
),
|
|
'global': re.compile(
|
|
r'(\w+)\s+received\s+.+?\s+from\s+(\w+)\s+worth\s+(\d+)\s+PED',
|
|
re.IGNORECASE
|
|
),
|
|
'damage': re.compile(
|
|
r'You\s+(?:hit|inflicted)\s+(\d+)\s+damage',
|
|
re.IGNORECASE
|
|
),
|
|
'damage_taken': re.compile(
|
|
r'You\s+were\s+hit\s+for\s+(\d+)\s+damage',
|
|
re.IGNORECASE
|
|
),
|
|
'heal': re.compile(
|
|
r'You\s+(?:healed|restored)\s+(\d+)\s+(?:health|points)',
|
|
re.IGNORECASE
|
|
),
|
|
'mission_complete': re.compile(
|
|
r'Mission\s+completed:\s+(.+)',
|
|
re.IGNORECASE
|
|
),
|
|
'tier_increase': re.compile(
|
|
r'Your\s+(.+?)\s+has\s+reached\s+tier\s+(\d+)',
|
|
re.IGNORECASE
|
|
),
|
|
'enhancer_break': re.compile(
|
|
r'Your\s+(.+?)\s+broke',
|
|
re.IGNORECASE
|
|
),
|
|
}
|
|
|
|
def _find_log_file(self) -> Optional[Path]:
|
|
"""Find EU chat.log file."""
|
|
for path in self.LOG_PATHS:
|
|
if path.exists():
|
|
return path
|
|
return None
|
|
|
|
def start(self) -> bool:
|
|
"""Start log monitoring in background thread."""
|
|
if not self.log_path or not self.log_path.exists():
|
|
print(f"[LogReader] Log file not found. Tried: {self.LOG_PATHS}")
|
|
return False
|
|
|
|
self.running = True
|
|
with self._stats_lock:
|
|
self._stats['start_time'] = datetime.now()
|
|
|
|
# Start at end of file (don't process old lines)
|
|
try:
|
|
self.last_position = self.log_path.stat().st_size
|
|
except OSError:
|
|
self.last_position = 0
|
|
|
|
self.thread = threading.Thread(target=self._watch_loop, daemon=True, name="LogReader")
|
|
self.thread.start()
|
|
|
|
print(f"[LogReader] Started watching: {self.log_path}")
|
|
return True
|
|
|
|
def stop(self):
|
|
"""Stop log monitoring."""
|
|
self.running = False
|
|
if self.thread:
|
|
self.thread.join(timeout=2.0)
|
|
print("[LogReader] Stopped")
|
|
|
|
def _watch_loop(self):
|
|
"""Main watching loop with adaptive polling."""
|
|
poll_interval = 0.5 # Start with 500ms
|
|
empty_polls = 0
|
|
|
|
while self.running:
|
|
try:
|
|
has_new = self._check_for_new_lines()
|
|
|
|
# Adaptive polling: increase interval if no new lines
|
|
if has_new:
|
|
empty_polls = 0
|
|
poll_interval = 0.1 # Fast poll when active
|
|
else:
|
|
empty_polls += 1
|
|
# Gradually slow down to 1 second
|
|
if empty_polls > 10:
|
|
poll_interval = min(1.0, poll_interval * 1.1)
|
|
|
|
except Exception as e:
|
|
print(f"[LogReader] Error: {e}")
|
|
poll_interval = 1.0 # Slow down on error
|
|
|
|
time.sleep(poll_interval)
|
|
|
|
def _check_for_new_lines(self) -> bool:
|
|
"""Check for and process new log lines. Returns True if new lines found."""
|
|
try:
|
|
current_size = self.log_path.stat().st_size
|
|
except OSError:
|
|
return False
|
|
|
|
if current_size < self.last_position:
|
|
# Log was rotated/truncated
|
|
self.last_position = 0
|
|
|
|
if current_size == self.last_position:
|
|
return False
|
|
|
|
# Read new lines
|
|
lines = []
|
|
try:
|
|
with open(self.log_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
f.seek(self.last_position)
|
|
lines = f.readlines()
|
|
self.last_position = f.tell()
|
|
except Exception as e:
|
|
print(f"[LogReader] Read error: {e}")
|
|
return False
|
|
|
|
if lines:
|
|
self._process_lines_batch(lines)
|
|
return True
|
|
|
|
return False
|
|
|
|
def _process_lines_batch(self, lines: List[str]):
|
|
"""Process multiple lines in batch (more efficient)."""
|
|
patterns = self._COMPILED_PATTERNS
|
|
events = []
|
|
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
# Intern the line for memory efficiency
|
|
line = self._string_interner.intern(line)
|
|
|
|
with self._stats_lock:
|
|
self._stats['lines_read'] += 1
|
|
|
|
self._recent_lines.append(line)
|
|
|
|
# Try cache first
|
|
with self._cache_lock:
|
|
cached = self._pattern_cache.get(line)
|
|
|
|
if cached is not None:
|
|
with self._stats_lock:
|
|
self._stats['cache_hits'] += 1
|
|
if cached: # Not None (which means no match)
|
|
events.append(cached)
|
|
continue
|
|
|
|
with self._stats_lock:
|
|
self._stats['cache_misses'] += 1
|
|
|
|
# Parse event
|
|
event = self._parse_event(line, patterns)
|
|
|
|
# Cache result
|
|
with self._cache_lock:
|
|
if len(self._pattern_cache) >= self._cache_max_size:
|
|
# Simple eviction: clear half the cache
|
|
keys = list(self._pattern_cache.keys())[:self._cache_max_size // 2]
|
|
for k in keys:
|
|
del self._pattern_cache[k]
|
|
self._pattern_cache[line] = event
|
|
|
|
if event:
|
|
with self._stats_lock:
|
|
self._stats['events_parsed'] += 1
|
|
events.append(event)
|
|
|
|
# Batch notify (outside parsing loop)
|
|
for event in events:
|
|
self._notify_subscribers(event)
|
|
|
|
def _parse_event(self, line: str, patterns: Dict[str, re.Pattern]) -> Optional[LogEvent]:
|
|
"""Parse a log line into a LogEvent."""
|
|
for event_type, pattern in patterns.items():
|
|
match = pattern.search(line)
|
|
if match:
|
|
return LogEvent(
|
|
timestamp=datetime.now(),
|
|
raw_line=line,
|
|
event_type=event_type,
|
|
data={'groups': match.groups()}
|
|
)
|
|
return None
|
|
|
|
def _notify_subscribers(self, event: LogEvent):
|
|
"""Notify all subscribers of an event."""
|
|
with self._subscribers_lock:
|
|
callbacks = self._subscribers.get(event.event_type, []).copy()
|
|
any_callbacks = self._any_subscribers.copy()
|
|
|
|
# Type-specific subscribers
|
|
for callback in callbacks:
|
|
try:
|
|
callback(event)
|
|
except Exception as e:
|
|
print(f"[LogReader] Subscriber error: {e}")
|
|
|
|
# "Any" subscribers
|
|
for callback in any_callbacks:
|
|
try:
|
|
callback(event)
|
|
except Exception as e:
|
|
print(f"[LogReader] Subscriber error: {e}")
|
|
|
|
# ========== Public API ==========
|
|
|
|
def subscribe(self, event_type: str, callback: Callable):
|
|
"""Subscribe to specific event type."""
|
|
with self._subscribers_lock:
|
|
if event_type not in self._subscribers:
|
|
self._subscribers[event_type] = []
|
|
self._subscribers[event_type].append(callback)
|
|
|
|
def subscribe_all(self, callback: Callable):
|
|
"""Subscribe to all events."""
|
|
with self._subscribers_lock:
|
|
self._any_subscribers.append(callback)
|
|
|
|
def unsubscribe(self, event_type: str, callback: Callable):
|
|
"""Unsubscribe from events."""
|
|
with self._subscribers_lock:
|
|
if event_type in self._subscribers:
|
|
self._subscribers[event_type] = [
|
|
cb for cb in self._subscribers[event_type] if cb != callback
|
|
]
|
|
|
|
def read_lines(self, count: int = 50, filter_text: str = None) -> List[str]:
|
|
"""Read recent lines (API method)."""
|
|
# Convert ring buffer to list (most recent last)
|
|
lines = list(self._recent_lines)
|
|
lines = lines[-count:] if count < len(lines) else lines
|
|
|
|
if filter_text:
|
|
filter_lower = filter_text.lower()
|
|
lines = [l for l in lines if filter_lower in l.lower()]
|
|
|
|
return lines
|
|
|
|
def get_stats(self) -> Dict:
|
|
"""Get reader statistics."""
|
|
with self._stats_lock:
|
|
stats = self._stats.copy()
|
|
|
|
total_cache = stats['cache_hits'] + stats['cache_misses']
|
|
stats['cache_hit_rate'] = (stats['cache_hits'] / total_cache * 100) if total_cache > 0 else 0
|
|
stats['cache_size'] = len(self._pattern_cache)
|
|
return stats
|
|
|
|
def is_available(self) -> bool:
|
|
"""Check if log file is available."""
|
|
return self.log_path is not None and self.log_path.exists()
|
|
|
|
def clear_cache(self):
|
|
"""Clear the pattern cache."""
|
|
with self._cache_lock:
|
|
self._pattern_cache.clear()
|
|
|
|
|
|
# Singleton instance
|
|
_log_reader = None
|
|
_log_reader_lock = threading.Lock()
|
|
|
|
def get_log_reader() -> LogReader:
|
|
"""Get global LogReader instance."""
|
|
global _log_reader
|
|
if _log_reader is None:
|
|
with _log_reader_lock:
|
|
if _log_reader is None:
|
|
_log_reader = LogReader()
|
|
return _log_reader
|