Lemontropia-Suite/core/log_watcher.py

742 lines
27 KiB
Python

# 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)
PATTERN_DECAY = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
r'(?:Your|Din)\s+([\w\s]+?)\s+(?:has\s+decayed|har\s+nått\s+minimalt\s+skick)',
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,
'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()}
)
# 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'
]