# Description: LogWatcher implementing Observer Pattern for real-time log parsing # Updated: Swedish language support based on real game logs # Monitors chat.log asynchronously with minimal CPU impact (Rule #3: 60+ FPS) import asyncio import re import os from pathlib import Path from typing import Callable, List, Dict, Any, Optional from dataclasses import dataclass from datetime import datetime from decimal import Decimal import logging import time logger = logging.getLogger(__name__) @dataclass class LogEvent: """Represents a parsed event from chat.log.""" timestamp: datetime event_type: str raw_line: str data: Dict[str, Any] class LogWatcher: """ Watches Entropia Universe chat.log and notifies observers of events. Supports multiple languages (English, Swedish) based on real game logs. Implements Observer Pattern: Multiple modules can subscribe to specific event types without tight coupling. """ # ======================================================================== # REGEX PATTERNS - ENGLISH & SWEDISH (from real game logs) # ======================================================================== # LOOT PATTERNS # English: "You received Shrapnel x 123 (Value: 1.23 PED)" # Swedish: "Du fick Shrapnel x (4627) Värde: 0.4627 PED" # English loot: "You received Animal Oil Residue x (2) Value: 0.0 PED" PATTERN_LOOT_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'You\s+received\s+([\w\s]+?)\s+x\s*\((\d+)\)\s*' r'Value:\s+(\d+(?:\.\d+)?)\s+PED', re.IGNORECASE ) PATTERN_LOOT_SV = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'Du\s+fick\s+([\w\s\-()]+?)\s+x\s*\((\d+)\)\s*' r'Värde:\s+(\d+(?:\.\d+)?)\s+PED', re.IGNORECASE ) # GLOBAL PATTERNS (Other players) # English: "PlayerName globals in Zone for 150.00 PED" # Swedish: "PlayerName hittade en avsättning (Item) med ett värde av X PED" PATTERN_GLOBAL_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[Globala?\]\s+\[?\]?\s*' r'([\w\s]+?)\s+(?:globals?|found)\s+(?:in|a)\s+([\w\s]+?)\s+' r'(?:for|with\s+a\s+value\s+of)\s+(\d+(?:\.\d+)?)\s+PED', re.IGNORECASE ) PATTERN_GLOBAL_SV = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[Globala?\]\s+\[?\]?\s*' r'([\w\s]+?)\s+hittade\s+en\s+avsättning\s+\(([^)]+)\)\s+' r'med\s+ett\s+värde\s+av\s+(\d+(?:\.\d+)?)\s+PED', re.IGNORECASE ) # HALL OF FAME PATTERNS # Swedish: "...En post har lagts till i Hall of Fame!" PATTERN_HOF_MARKER = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[\w+\]\s+\[?\]?\s*' r'.*?Hall\s+of\s+Fame', re.IGNORECASE ) # SKILL GAIN PATTERNS # English: "You have gained 1.1466 experience in your Whip skill" # English (alt): "You gained 0.45 experience in your Rifle skill" # Swedish: "Du har fått 0.3238 erfarenhet i din Translocation färdighet" PATTERN_SKILL_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'You\s+(?:have\s+)?gained\s+(\d+(?:\.\d+)?)\s+experience\s+in\s+your\s+([\w\s]+?)\s+skill', re.IGNORECASE ) PATTERN_SKILL_SV = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'Du\s+har\s+fått\s+(\d+(?:\.\d+)?)\s+erfarenhet\s+i\s+din\s+([\w\s]+?)\s+färdighet', re.IGNORECASE ) # SKILL LEVEL UP # English: "You have advanced to level 45 in Rifle" # Swedish: "Du har avancerat till nivå 45 i Rifle" PATTERN_LEVEL_UP_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'You\s+have\s+advanced\s+to\s+level\s+(\d+)\s+in\s+([\w\s]+)', re.IGNORECASE ) # DAMAGE DEALT - Swedish & English # Swedish: "Du orsakade 13.5 poäng skada" # English: "You inflicted 4.4 points of damage" PATTERN_DAMAGE_DEALT_SV = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'Du\s+orsakade\s+(\d+(?:\.\d+)?)\s+poäng\s+skada', re.IGNORECASE ) PATTERN_DAMAGE_DEALT_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'You\s+inflicted\s+(\d+(?:\.\d+)?)\s+points?\s+of\s+damage', re.IGNORECASE ) # CRITICAL HIT # Swedish: "Kritisk träff - Extra skada! Du orsakade 44.4 poäng skada" # English: "Critical hit - Additional damage! You inflicted 49.6 points of damage" PATTERN_CRITICAL_SV = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'Kritisk\s+träff.*?Du\s+orsakade\s+(\d+(?:\.\d+)?)\s+poäng\s+skada', re.IGNORECASE ) PATTERN_CRITICAL_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'Critical\s+hit.*?You\s+inflicted\s+(\d+(?:\.\d+)?)\s+points?\s+of\s+damage', re.IGNORECASE ) # DAMAGE TAKEN # Swedish: "Du tog 31.5 poäng skada" # English: "You took 7.4 points of damage" PATTERN_DAMAGE_TAKEN_SV = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'Du\s+tog\s+(\d+(?:\.\d+)?)\s+poäng\s+skada', re.IGNORECASE ) PATTERN_DAMAGE_TAKEN_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'You\s+took\s+(\d+(?:\.\d+)?)\s+points?\s+of\s+damage', re.IGNORECASE ) # HEALING # Swedish: "Du läkte dig själv 4.0 poäng" # English: "You healed yourself 25.5 points" PATTERN_HEAL_SV = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'Du\s+läkte\s+dig\s+jälv\s+(\d+(?:\.\d+)?)\s+poäng', re.IGNORECASE ) PATTERN_HEAL_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'You\s+healed\s+yourself\s+(\d+(?:\.\d+)?)\s+points?', re.IGNORECASE ) # WEAPON TIER/LEVEL UP # Swedish: "Din Piron PBP-17 (L) har nått nivå 0.38" # English: "Your Piron PBP-17 (L) has reached tier 2.68" PATTERN_WEAPON_TIER_SV = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'Din\s+([\w\s\-()]+?)\s+har\s+nått\s+nivå\s+(\d+(?:\.\d+)?)', re.IGNORECASE ) PATTERN_WEAPON_TIER_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'Your\s+([\w\s\-()]+?)\s+has\s+reached\s+tier\s+(\d+(?:\.\d+)?)', re.IGNORECASE ) # COMBAT EVADE/DODGE/MISS # English: "You Evaded", "The target Evaded your attack", "The attack missed you" PATTERN_EVADE = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'(You\s+Evaded|You\s+dodged|The\s+target\s+Evaded\s+your\s+attack|The\s+target\s+Dodged|The\s+attack\s+missed\s+you)', re.IGNORECASE ) # DECAY (when weapon durability decreases) # English: "Your Omegaton M2100 has decayed 15 PEC" # Swedish: "Din Piron PBP-17 (L) har nått minimalt skick" PATTERN_DECAY = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'(?:Your|Din)\s+([\w\s\-()]+?)\s+(?:has\s+decayed|har\s+nått\s+minimalt\s+skick)', re.IGNORECASE ) # BROKEN ENHANCERS # English: "Your enhancer Weapon Damage Enhancer 1 on your Piron PBP-17 (L) broke" PATTERN_ENHANCER_BROKEN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'Your\s+enhancer\s+([\w\s]+?)\s+on\s+your\s+([\w\s\-()]+?)\s+broke', re.IGNORECASE ) # PED TRANSFER # Swedish: "Överföring slutförd! 3.38000 PED har överförts till ditt PED-kort." PATTERN_PED_TRANSFER = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+' r'Överföring\s+slutförd.*?((\d+(?:\.\d+)?))\s+PED', re.IGNORECASE ) # ATTRIBUTE GAIN (Agility, etc) # Swedish: "Din Agility har förbättrats med 0.0001" # English: "Your Agility has improved by 0.0001" OR "You gained 0.0001 Agility" PATTERN_ATTRIBUTE_SV = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'Din\s+(\w+)\s+har\s+förbättrats\s+med\s+(\d+(?:\.\d+)?)', re.IGNORECASE ) PATTERN_ATTRIBUTE_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'(?:Your\s+(\w+)\s+has\s+improved\s+by|You\s+gained)\s+(\d+(?:\.\d+)?)\s+(\w+)', re.IGNORECASE ) EVENT_PATTERNS = { 'loot_en': PATTERN_LOOT_EN, 'loot_sv': PATTERN_LOOT_SV, 'global_en': PATTERN_GLOBAL_EN, 'global_sv': PATTERN_GLOBAL_SV, 'hof': PATTERN_HOF_MARKER, 'skill_en': PATTERN_SKILL_EN, 'skill_sv': PATTERN_SKILL_SV, 'damage_dealt_sv': PATTERN_DAMAGE_DEALT_SV, 'damage_dealt_en': PATTERN_DAMAGE_DEALT_EN, 'critical_hit_sv': PATTERN_CRITICAL_SV, 'critical_hit_en': PATTERN_CRITICAL_EN, 'damage_taken_sv': PATTERN_DAMAGE_TAKEN_SV, 'damage_taken_en': PATTERN_DAMAGE_TAKEN_EN, 'heal_sv': PATTERN_HEAL_SV, 'heal_en': PATTERN_HEAL_EN, 'weapon_tier_sv': PATTERN_WEAPON_TIER_SV, 'weapon_tier_en': PATTERN_WEAPON_TIER_EN, 'evade': PATTERN_EVADE, 'decay': PATTERN_DECAY, 'enhancer_broken': PATTERN_ENHANCER_BROKEN, 'ped_transfer': PATTERN_PED_TRANSFER, 'attribute_sv': PATTERN_ATTRIBUTE_SV, 'attribute_en': PATTERN_ATTRIBUTE_EN, } def __init__(self, log_path: Optional[str] = None, poll_interval: float = 1.0, mock_mode: bool = False): """Initialize LogWatcher.""" self.mock_mode = mock_mode if log_path is None: if mock_mode: core_dir = Path(__file__).parent log_path = core_dir.parent / "test-data" / "mock-chat.log" else: log_path = self._find_eu_log_path() self.log_path = Path(log_path) self.poll_interval = poll_interval self.observers: Dict[str, List[Callable]] = { 'loot': [], 'global': [], 'hof': [], 'skill': [], 'damage_dealt': [], 'damage_taken': [], 'heal': [], 'weapon_tier': [], 'evade': [], 'decay': [], 'critical_hit': [], 'ped_transfer': [], 'attribute': [], 'any': [], } self._running = False self._file_position = 0 self._last_file_size = 0 self._task: Optional[asyncio.Task] = None logger.info(f"LogWatcher initialized: {self.log_path} (mock={mock_mode})") def _find_eu_log_path(self) -> Path: """Attempt to find Entropia Universe chat.log.""" possible_paths = [ Path.home() / "Documents" / "Entropia Universe" / "chat.log", Path("C:") / "Users" / os.getenv("USERNAME", "User") / "Documents" / "Entropia Universe" / "chat.log", ] wine_prefix = Path.home() / ".wine" / "drive_c" possible_paths.extend([ wine_prefix / "users" / os.getenv("USER", "user") / "Documents" / "Entropia Universe" / "chat.log", ]) for path in possible_paths: if path.exists(): logger.info(f"Found EU log: {path}") return path fallback = Path(__file__).parent.parent / "test-data" / "chat.log" logger.warning(f"EU log not found, using fallback: {fallback}") return fallback def subscribe(self, event_type: str, callback: Callable[[LogEvent], None]) -> None: """Subscribe to an event type.""" if event_type not in self.observers: self.observers[event_type] = [] self.observers[event_type].append(callback) logger.debug(f"Subscribed to {event_type}: {callback.__name__}") def unsubscribe(self, event_type: str, callback: Callable[[LogEvent], None]) -> None: """Unsubscribe from an event type.""" if event_type in self.observers: if callback in self.observers[event_type]: self.observers[event_type].remove(callback) logger.debug(f"Unsubscribed from {event_type}: {callback.__name__}") def _notify(self, event: LogEvent) -> None: """Notify all observers of an event.""" if event.event_type in self.observers: for callback in self.observers[event.event_type]: try: callback(event) except Exception as e: logger.error(f"Observer error for {event.event_type}: {e}") for callback in self.observers['any']: try: callback(event) except Exception as e: logger.error(f"Observer error for 'any': {e}") def _parse_timestamp(self, ts_str: str) -> datetime: """Parse EU timestamp format.""" return datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S") def _parse_line(self, line: str) -> Optional[LogEvent]: """ Parse a single log line. Returns LogEvent if parsed, None otherwise. """ line = line.strip() if not line: return None # Try each pattern # LOOT - Swedish (prioritize based on your game client) match = self.PATTERN_LOOT_SV.match(line) if match: return self._create_loot_event_sv(match, line) # LOOT - English match = self.PATTERN_LOOT_EN.match(line) if match: return self._create_loot_event_en(match, line) # GLOBAL - Swedish match = self.PATTERN_GLOBAL_SV.match(line) if match: return self._create_global_event_sv(match, line) # GLOBAL - English match = self.PATTERN_GLOBAL_EN.match(line) if match: return self._create_global_event_en(match, line) # HOF match = self.PATTERN_HOF_MARKER.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='hof', raw_line=line, data={'message': 'Hall of Fame entry'} ) # SKILL - Swedish match = self.PATTERN_SKILL_SV.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='skill', raw_line=line, data={ 'gained': Decimal(match.group(2)), 'skill_name': match.group(3).strip(), 'language': 'swedish' } ) # SKILL - English match = self.PATTERN_SKILL_EN.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='skill', raw_line=line, data={ 'gained': Decimal(match.group(2)), 'skill_name': match.group(3).strip(), 'language': 'english' } ) # LEVEL UP - English match = self.PATTERN_LEVEL_UP_EN.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='level_up', raw_line=line, data={ 'new_level': int(match.group(2)), 'skill_name': match.group(3).strip(), 'language': 'english' } ) # DAMAGE DEALT - Swedish match = self.PATTERN_DAMAGE_DEALT_SV.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='damage_dealt', raw_line=line, data={'damage': Decimal(match.group(2)), 'language': 'swedish'} ) # DAMAGE DEALT - English match = self.PATTERN_DAMAGE_DEALT_EN.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='damage_dealt', raw_line=line, data={'damage': Decimal(match.group(2)), 'language': 'english'} ) # CRITICAL HIT - Swedish match = self.PATTERN_CRITICAL_SV.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='critical_hit', raw_line=line, data={'damage': Decimal(match.group(2)), 'language': 'swedish'} ) # CRITICAL HIT - English match = self.PATTERN_CRITICAL_EN.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='critical_hit', raw_line=line, data={'damage': Decimal(match.group(2)), 'language': 'english'} ) # DAMAGE TAKEN - Swedish match = self.PATTERN_DAMAGE_TAKEN_SV.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='damage_taken', raw_line=line, data={'damage': Decimal(match.group(2)), 'language': 'swedish'} ) # DAMAGE TAKEN - English match = self.PATTERN_DAMAGE_TAKEN_EN.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='damage_taken', raw_line=line, data={'damage': Decimal(match.group(2)), 'language': 'english'} ) # HEALING - Swedish match = self.PATTERN_HEAL_SV.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='heal', raw_line=line, data={'heal_amount': Decimal(match.group(2)), 'language': 'swedish'} ) # HEALING - English match = self.PATTERN_HEAL_EN.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='heal', raw_line=line, data={'heal_amount': Decimal(match.group(2)), 'language': 'english'} ) # WEAPON TIER/LEVEL - Swedish match = self.PATTERN_WEAPON_TIER_SV.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='weapon_tier', raw_line=line, data={ 'weapon_name': match.group(2).strip(), 'new_tier': Decimal(match.group(3)), 'language': 'swedish' } ) # WEAPON TIER/LEVEL - English match = self.PATTERN_WEAPON_TIER_EN.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='weapon_tier', raw_line=line, data={ 'weapon_name': match.group(2).strip(), 'new_tier': Decimal(match.group(3)), 'language': 'english' } ) # EVADE/DODGE/MISS match = self.PATTERN_EVADE.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='evade', raw_line=line, data={'type': match.group(2)} ) # DECAY match = self.PATTERN_DECAY.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='decay', raw_line=line, data={'item': match.group(2).strip()} ) # BROKEN ENHANCER match = self.PATTERN_ENHANCER_BROKEN.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='enhancer_broken', raw_line=line, data={ 'enhancer_type': match.group(2).strip(), 'weapon': match.group(3).strip() } ) # ATTRIBUTE GAIN - Swedish match = self.PATTERN_ATTRIBUTE_SV.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='attribute', raw_line=line, data={ 'attribute': match.group(2), 'increase': Decimal(match.group(3)), 'language': 'swedish' } ) # ATTRIBUTE GAIN - English match = self.PATTERN_ATTRIBUTE_EN.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='attribute', raw_line=line, data={ 'attribute': match.group(4) if match.group(4) else match.group(2), 'increase': Decimal(match.group(3)), 'language': 'english' } ) return None def _create_loot_event_sv(self, match: re.Match, line: str) -> LogEvent: """Create loot event from Swedish pattern.""" return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='loot', raw_line=line, data={ 'item_name': match.group(2).strip(), 'quantity': int(match.group(3)), 'value_ped': Decimal(match.group(4)), 'language': 'swedish' } ) def _create_loot_event_en(self, match: re.Match, line: str) -> LogEvent: """Create loot event from English pattern.""" value = match.group(4) return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='loot', raw_line=line, data={ 'item_name': match.group(2).strip(), 'quantity': int(match.group(3)), 'value_ped': Decimal(value) if value else Decimal('0'), 'language': 'english' } ) def _create_global_event_sv(self, match: re.Match, line: str) -> LogEvent: """Create global event from Swedish pattern.""" return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='global', raw_line=line, data={ 'player_name': match.group(2).strip(), 'item': match.group(3).strip(), 'value_ped': Decimal(match.group(4)), 'language': 'swedish' } ) def _create_global_event_en(self, match: re.Match, line: str) -> LogEvent: """Create global event from English pattern.""" return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='global', raw_line=line, data={ 'player_name': match.group(2).strip(), 'zone': match.group(3).strip(), 'value_ped': Decimal(match.group(4)), 'language': 'english' } ) # ======================================================================== # ASYNC POLLING LOOP # ======================================================================== async def start(self) -> None: """Start watching log file asynchronously.""" if self._running: logger.warning("LogWatcher already running") return self._running = True if self.log_path.exists(): self._last_file_size = self.log_path.stat().st_size self._file_position = self._last_file_size self._task = asyncio.create_task(self._watch_loop()) logger.info("LogWatcher started") async def stop(self) -> None: """Stop watching log file.""" self._running = False if self._task: self._task.cancel() try: await self._task except asyncio.CancelledError: pass logger.info("LogWatcher stopped") async def _watch_loop(self) -> None: """Main watching loop.""" while self._running: try: await self._poll_once() except Exception as e: logger.error(f"Poll error: {e}") await asyncio.sleep(self.poll_interval) async def _poll_once(self) -> None: """Single poll iteration.""" if not self.log_path.exists(): return current_size = self.log_path.stat().st_size if current_size < self._file_position: logger.info("Log file truncated, resetting position") self._file_position = 0 if current_size == self._file_position: return with open(self.log_path, 'r', encoding='utf-8', errors='ignore') as f: f.seek(self._file_position) new_lines = f.readlines() self._file_position = f.tell() for line in new_lines: event = self._parse_line(line) if event: self._notify(event) self._last_file_size = current_size # ============================================================================ # MOCK MODE SUPPORT # ============================================================================ class MockLogGenerator: """Generates mock log entries for testing.""" MOCK_LINES = [ "2026-02-08 14:23:15 [System] You received Shrapnel x 123 (Value: 1.23 PED)", "2026-02-08 14:23:45 [System] You gained 0.45 experience in your Rifle skill", "2026-02-08 14:24:02 [System] Your Omegaton M2100 has decayed 15 PEC", "2026-02-08 14:25:30 [System] PlayerOne globals in Twin Peaks for 150.00 PED", "2026-02-08 14:26:10 [System] You received Animal Thyroid Oil x 5", "2026-02-08 14:27:55 [System] Congratulations! You have advanced to level 45 in Rifle", "2026-02-08 14:30:00 [System] PlayerTwo is in the Hall of Fame! Loot of 2500.00 PED", # Swedish examples "2025-09-23 19:36:43 [System] Du fick Shrapnel x (4627) Värde: 0.4627 PED", "2025-09-23 19:36:08 [System] Du har fått 0.3238 erfarenhet i din Translocation färdighet", "2025-09-23 19:36:18 [System] Du orsakade 13.5 poäng skada", ] @classmethod def create_mock_file(cls, path: Path, lines: int = 100) -> None: """Create a mock chat.log file.""" path.parent.mkdir(parents=True, exist_ok=True) with open(path, 'w') as f: for i in range(lines): line = cls.MOCK_LINES[i % len(cls.MOCK_LINES)] f.write(f"{line}\n") logger.info(f"Created mock log: {path} ({lines} lines)") # ============================================================================ # MODULE EXPORTS # ============================================================================ __all__ = [ 'LogWatcher', 'LogEvent', 'MockLogGenerator' ]