324 lines
10 KiB
Python
324 lines
10 KiB
Python
"""
|
|
Log file parser for Entropia Universe chat.log
|
|
===============================================
|
|
|
|
Parses EU chat.log file to extract:
|
|
- Loot events
|
|
- Skill gains
|
|
- Chat messages
|
|
- System messages
|
|
- Globals/HoFs
|
|
"""
|
|
|
|
import re
|
|
import time
|
|
import logging
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional, Callable, Dict, List, Any, Pattern
|
|
|
|
|
|
@dataclass
|
|
class LogEvent:
|
|
"""Represents a parsed log event."""
|
|
timestamp: datetime
|
|
type: str
|
|
data: Dict[str, Any]
|
|
raw: str
|
|
|
|
|
|
class LogParser:
|
|
"""Parser for Entropia Universe chat.log file.
|
|
|
|
Monitors the log file and emits events for interesting game actions.
|
|
|
|
Example:
|
|
parser = LogParser(Path("C:/.../chat.log"))
|
|
|
|
@parser.on_event
|
|
def handle(event_type, data):
|
|
if event_type == 'loot':
|
|
print(f"Loot: {data['item']}")
|
|
|
|
parser.start()
|
|
"""
|
|
|
|
# Regex patterns for different event types
|
|
PATTERNS = {
|
|
# Loot: "2024-01-15 14:30:25 [System] [] [Player] You received Angel Scales x1 Value: 150 PED"
|
|
'loot': re.compile(
|
|
r'You received\s+(.+?)\s+x(\d+)\s+Value:\s+([\d.]+)'
|
|
),
|
|
|
|
# Skill gain: "Skill increase: Rifle (Gain: 0.5234)"
|
|
'skill': re.compile(
|
|
r'Skill increase:\s+(.+?)\s+\(Gain:\s+([\d.]+)\)'
|
|
),
|
|
|
|
# Global/HoF: "[Global] [Player] found something rare! Angel Scales (Uber)"
|
|
'global': re.compile(
|
|
r'\[Global\].*?found something rare!\s+(.+?)\s+\((\d+)\s*PED\)'
|
|
),
|
|
|
|
# HoF (Hall of Fame): "[Hall of Fame] [Player] found something extraordinary!"
|
|
'hof': re.compile(
|
|
r'\[Hall of Fame\].*?found something extraordinary!\s+(.+?)\s+\((\d+)\s*PED\)'
|
|
),
|
|
|
|
# Chat message: "[Trade] [PlayerName]: Message text"
|
|
'chat': re.compile(
|
|
r'\[(\w+)\]\s+\[(.+?)\]:\s+(.*)'
|
|
),
|
|
|
|
# Damage dealt: "You inflicted 45.2 points of damage"
|
|
'damage_dealt': re.compile(
|
|
r'You inflicted\s+([\d.]+)\s+points of damage'
|
|
),
|
|
|
|
# Damage received: "You took 12.3 points of damage"
|
|
'damage_received': re.compile(
|
|
r'You took\s+([\d.]+)\s+points of damage'
|
|
),
|
|
|
|
# Enemy killed: "You killed [Creature Name]"
|
|
'kill': re.compile(
|
|
r'You killed\s+\[(.+?)\]'
|
|
),
|
|
|
|
# Item crafted: "You have successfully crafted [Item Name]"
|
|
'craft': re.compile(
|
|
r'You have successfully crafted\s+\[(.+?)\]'
|
|
),
|
|
|
|
# Mission completed: "Mission "Mission Name" completed!"
|
|
'mission_complete': re.compile(
|
|
r'Mission\s+"(.+?)"\s+completed!'
|
|
),
|
|
|
|
# Experience gain: "You gained 0.2345 experience in your Rifle skill"
|
|
'experience': re.compile(
|
|
r'You gained\s+([\d.]+)\s+experience in your\s+(.+?)\s+skill'
|
|
),
|
|
|
|
# Item looted (alt format): "You looted [Item Name]"
|
|
'loot_alt': re.compile(
|
|
r'You looted\s+\[(.+?)\]\s*(?:x(\d+))?'
|
|
),
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
log_path: Path,
|
|
poll_interval: float = 0.5,
|
|
encoding: str = 'utf-8'
|
|
):
|
|
"""Initialize log parser.
|
|
|
|
Args:
|
|
log_path: Path to chat.log file
|
|
poll_interval: Seconds between file checks
|
|
encoding: File encoding
|
|
"""
|
|
self.log_path = Path(log_path)
|
|
self.poll_interval = poll_interval
|
|
self.encoding = encoding
|
|
|
|
self._running = False
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._file_position = 0
|
|
self._callbacks: List[Callable] = []
|
|
self._logger = logging.getLogger("LogParser")
|
|
|
|
# Track last position to detect new content
|
|
self._last_size = 0
|
|
self._last_modified = 0
|
|
|
|
def start(self) -> bool:
|
|
"""Start parsing log file.
|
|
|
|
Returns:
|
|
True if started successfully
|
|
"""
|
|
if self._running:
|
|
return True
|
|
|
|
if not self.log_path.exists():
|
|
self._logger.error(f"Log file not found: {self.log_path}")
|
|
return False
|
|
|
|
self._running = True
|
|
|
|
# Start at end of file (only read new content)
|
|
try:
|
|
self._last_size = self.log_path.stat().st_size
|
|
self._file_position = self._last_size
|
|
except Exception as e:
|
|
self._logger.error(f"Failed to get file size: {e}")
|
|
return False
|
|
|
|
self._thread = threading.Thread(target=self._parse_loop, daemon=True)
|
|
self._thread.start()
|
|
|
|
self._logger.info(f"Started parsing {self.log_path}")
|
|
return True
|
|
|
|
def stop(self) -> None:
|
|
"""Stop parsing log file."""
|
|
self._running = False
|
|
|
|
if self._thread:
|
|
self._thread.join(timeout=2.0)
|
|
|
|
self._logger.info("Stopped parsing")
|
|
|
|
def on_event(self, callback: Callable[[str, Dict[str, Any]], None]) -> Callable:
|
|
"""Register event callback.
|
|
|
|
Args:
|
|
callback: Function(event_type, data) called for each event
|
|
|
|
Returns:
|
|
The callback function (for use as decorator)
|
|
"""
|
|
self._callbacks.append(callback)
|
|
return callback
|
|
|
|
def _emit(self, event_type: str, data: Dict[str, Any]) -> None:
|
|
"""Emit event to all callbacks."""
|
|
for callback in self._callbacks:
|
|
try:
|
|
callback(event_type, data)
|
|
except Exception as e:
|
|
self._logger.error(f"Error in callback: {e}")
|
|
|
|
def _parse_loop(self) -> None:
|
|
"""Main parsing loop."""
|
|
while self._running:
|
|
try:
|
|
self._check_file()
|
|
time.sleep(self.poll_interval)
|
|
except Exception as e:
|
|
self._logger.error(f"Parse error: {e}")
|
|
time.sleep(self.poll_interval)
|
|
|
|
def _check_file(self) -> None:
|
|
"""Check log file for new content."""
|
|
try:
|
|
stat = self.log_path.stat()
|
|
current_size = stat.st_size
|
|
current_modified = stat.st_mtime
|
|
|
|
# Check if file has new content
|
|
if current_size == self._last_size:
|
|
return
|
|
|
|
if current_size < self._last_size:
|
|
# File was truncated or rotated, start from beginning
|
|
self._file_position = 0
|
|
|
|
self._last_size = current_size
|
|
self._last_modified = current_modified
|
|
|
|
# Read new content
|
|
with open(self.log_path, 'r', encoding=self.encoding, errors='ignore') as f:
|
|
f.seek(self._file_position)
|
|
new_lines = f.readlines()
|
|
self._file_position = f.tell()
|
|
|
|
# Parse each new line
|
|
for line in new_lines:
|
|
self._parse_line(line.strip())
|
|
|
|
except Exception as e:
|
|
self._logger.error(f"Error checking file: {e}")
|
|
|
|
def _parse_line(self, line: str) -> Optional[LogEvent]:
|
|
"""Parse a single log line."""
|
|
if not line:
|
|
return None
|
|
|
|
# Extract timestamp if present
|
|
timestamp = datetime.now()
|
|
ts_match = re.match(r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})', line)
|
|
if ts_match:
|
|
try:
|
|
timestamp = datetime.strptime(ts_match.group(1), '%Y-%m-%d %H:%M:%S')
|
|
except ValueError:
|
|
pass
|
|
|
|
# Try each pattern
|
|
for event_type, pattern in self.PATTERNS.items():
|
|
match = pattern.search(line)
|
|
if match:
|
|
data = self._extract_data(event_type, match, line)
|
|
event = LogEvent(
|
|
timestamp=timestamp,
|
|
type=event_type,
|
|
data=data,
|
|
raw=line
|
|
)
|
|
self._emit(event_type, data)
|
|
return event
|
|
|
|
return None
|
|
|
|
def _extract_data(
|
|
self,
|
|
event_type: str,
|
|
match: re.Match,
|
|
raw_line: str
|
|
) -> Dict[str, Any]:
|
|
"""Extract data from regex match."""
|
|
groups = match.groups()
|
|
|
|
data = {
|
|
'raw': raw_line,
|
|
'timestamp': datetime.now().isoformat(),
|
|
}
|
|
|
|
if event_type == 'loot':
|
|
data['item'] = groups[0].strip()
|
|
data['quantity'] = int(groups[1]) if len(groups) > 1 else 1
|
|
data['value'] = float(groups[2]) if len(groups) > 2 else 0.0
|
|
|
|
elif event_type == 'skill':
|
|
data['skill'] = groups[0].strip()
|
|
data['gain'] = float(groups[1]) if len(groups) > 1 else 0.0
|
|
|
|
elif event_type in ('global', 'hof'):
|
|
data['item'] = groups[0].strip() if groups else 'Unknown'
|
|
data['value'] = float(groups[1]) if len(groups) > 1 else 0.0
|
|
data['is_hof'] = event_type == 'hof'
|
|
|
|
elif event_type == 'chat':
|
|
data['channel'] = groups[0] if groups else 'Unknown'
|
|
data['player'] = groups[1] if len(groups) > 1 else 'Unknown'
|
|
data['message'] = groups[2] if len(groups) > 2 else ''
|
|
|
|
elif event_type == 'damage_dealt':
|
|
data['damage'] = float(groups[0]) if groups else 0.0
|
|
|
|
elif event_type == 'damage_received':
|
|
data['damage'] = float(groups[0]) if groups else 0.0
|
|
|
|
elif event_type == 'kill':
|
|
data['creature'] = groups[0] if groups else 'Unknown'
|
|
|
|
elif event_type == 'craft':
|
|
data['item'] = groups[0] if groups else 'Unknown'
|
|
|
|
elif event_type == 'mission_complete':
|
|
data['mission'] = groups[0] if groups else 'Unknown'
|
|
|
|
elif event_type == 'experience':
|
|
data['amount'] = float(groups[0]) if groups else 0.0
|
|
data['skill'] = groups[1].strip() if len(groups) > 1 else 'Unknown'
|
|
|
|
elif event_type == 'loot_alt':
|
|
data['item'] = groups[0] if groups else 'Unknown'
|
|
data['quantity'] = int(groups[1]) if len(groups) > 1 and groups[1] else 1
|
|
|
|
return data
|