feat(logs): add Swedish language support from real game logs

- Add Swedish regex patterns based on real chat.log from user session
- Support loot, globals, skills, damage, healing, weapon tier
- Add real-chat-sample.log for testing (14 events parsed)
- LogWatcher now parses Swedish game client output correctly
This commit is contained in:
LemonNexus 2026-02-08 17:30:31 +00:00
parent dfe4e8125f
commit c511ff2042
2 changed files with 374 additions and 220 deletions

View File

@ -1,6 +1,6 @@
# Description: LogWatcher implementing Observer Pattern for real-time log parsing # 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) # Monitors chat.log asynchronously with minimal CPU impact (Rule #3: 60+ FPS)
# Standards: Python 3.11+, asyncio, regex patterns for Entropia Universe
import asyncio import asyncio
import re import re
@ -29,112 +29,165 @@ class LogWatcher:
""" """
Watches Entropia Universe chat.log and notifies observers of events. 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 Implements Observer Pattern: Multiple modules can subscribe to specific
event types without tight coupling. event types without tight coupling.
Performance Optimized (Rule #3):
- Asynchronous polling with configurable interval
- Efficient file seeking (only reads new lines)
- Compiled regex patterns
- Minimal memory footprint
Attributes:
log_path: Path to chat.log file
poll_interval: Polling interval in seconds (default: 1.0)
observers: Dict of event_type -> list of callback functions
_running: Whether the watcher is active
_file_position: Current read position in file
""" """
# ======================================================================== # ========================================================================
# REGEX PATTERNS (Compiled for performance) # REGEX PATTERNS - ENGLISH & SWEDISH (from real game logs)
# ======================================================================== # ========================================================================
# Global/Hall of Fame patterns # LOOT PATTERNS
PATTERN_GLOBAL = re.compile( # English: "You received Shrapnel x 123 (Value: 1.23 PED)"
# Swedish: "Du fick Shrapnel x (4627) Värde: 0.4627 PED"
PATTERN_LOOT_EN = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+' r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
r'([\w\s]+)\s+globals\s+in\s+([\w\s]+)\s+for\s+(\d+(?:\.\d+)?)\s+PED', r'You\s+received\s+([\w\s]+?)\s+x\s*(\d+)\s*.*?'
r'(?:Value:\s+(\d+(?:\.\d+)?)\s+PED)?',
re.IGNORECASE re.IGNORECASE
) )
PATTERN_HOF = re.compile( PATTERN_LOOT_SV = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+' r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*'
r'([\w\s]+)\s+is\s+in\s+the\s+Hall\s+of\s+Fame\s+.*?(\d+(?:\.\d+)?)\s+PED', r'Du\s+fick\s+([\w\s\-()]+?)\s+x\s*\((\d+)\)\s*'
r'Värde:\s+(\d+(?:\.\d+)?)\s+PED',
re.IGNORECASE re.IGNORECASE
) )
# Regular loot pattern # GLOBAL PATTERNS (Other players)
PATTERN_LOOT = re.compile( # English: "PlayerName globals in Zone for 150.00 PED"
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+' # Swedish: "PlayerName hittade en avsättning (Item) med ett värde av X PED"
r'You\s+received\s+([\w\s]+)\s+x\s*(\d+)\s+.*?(?:Value:\s+(\d+(?:\.\d+)?)\s+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 re.IGNORECASE
) )
# Alternative loot pattern (different wording in EU) PATTERN_GLOBAL_SV = re.compile(
PATTERN_LOOT_ALT = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[Globala?\]\s+\[?\]?\s*'
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+' r'([\w\s]+?)\s+hittade\s+en\s+avsättning\s+\(([^)]+)\)\s+'
r'You\s+received\s+([\w\s]+)\s+\(.*?\d+\s+items\)', r'med\s+ett\s+värde\s+av\s+(\d+(?:\.\d+)?)\s+PED',
re.IGNORECASE re.IGNORECASE
) )
# Skill gain pattern # HALL OF FAME PATTERNS
PATTERN_SKILL = re.compile( # Swedish: "...En post har lagts till i Hall of Fame!"
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+' PATTERN_HOF_MARKER = re.compile(
r'You\s+gained\s+(\d+(?:\.\d+)?)\s+experience\s+in\s+your\s+([\w\s]+)\s+skill', 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 re.IGNORECASE
) )
# Skill level up pattern # SKILL GAIN PATTERNS
PATTERN_SKILL_LEVEL = re.compile( # English: "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+' r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
r'Congratulations!\s+You\s+have\s+advanced\s+to\s+level\s+(\d+)\s+in\s+([\w\s]+)', r'You\s+gained\s+(\d+(?:\.\d+)?)\s+experience\s+in\s+your\s+([\w\s]+?)\s+skill',
re.IGNORECASE re.IGNORECASE
) )
# Weapon decay pattern PATTERN_SKILL_SV = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\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
)
# DAMAGE DEALT
# Swedish: "Du orsakade 13.5 poäng skada"
PATTERN_DAMAGE_DEALT_SV = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
r'Du\s+orsakade\s+(\d+(?:\.\d+)?)\s+poäng\s+skada',
re.IGNORECASE
)
# CRITICAL HIT
# Swedish: "Kritisk träff - Extra skada! Du orsakade 44.4 poäng skada"
PATTERN_CRITICAL_SV = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
r'Kritisk\s+träff.*?Du\s+orsakade\s+(\d+(?:\.\d+)?)\s+poäng\s+skada',
re.IGNORECASE
)
# DAMAGE TAKEN
# Swedish: "Du tog 31.5 poäng skada"
PATTERN_DAMAGE_TAKEN_SV = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
r'Du\s+tog\s+(\d+(?:\.\d+)?)\s+poäng\s+skada',
re.IGNORECASE
)
# HEALING
# Swedish: "Du läkte dig själv 4.0 poäng"
PATTERN_HEAL_SV = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
r'Du\s+läkte\s+dig\själv\s+(\d+(?:\.\d+)?)\s+poäng',
re.IGNORECASE
)
# WEAPON TIER/LEVEL UP
# Swedish: "Din Piron PBP-17 (L) har nått nivå 0.38"
PATTERN_WEAPON_TIER_SV = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
r'Din\s+([\w\s\-()]+?)\s+har\s+nått\s+nivå\s+(\d+(?:\.\d+)?)',
re.IGNORECASE
)
# COMBAT EVADE/DODGE/MISS
PATTERN_EVADE = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
r'(You\s+Evaded|You\s+dodged|The\s+target\s+Dodged|The\s+attack\s+missed)',
re.IGNORECASE
)
# DECAY (when weapon durability decreases)
PATTERN_DECAY = re.compile( PATTERN_DECAY = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+' r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
r'Your\s+([\w\s]+)\s+has\s+decayed\s+(\d+(?:\.\d+)?)\s+PEC', r'(?:Your|Din)\s+([\w\s]+?)\s+(?:has\s+decayed|har\s+nått\s+minimalt\s+skick)',
re.IGNORECASE re.IGNORECASE
) )
# Creature killed / target info (useful for context) # PED TRANSFER
PATTERN_KILL = re.compile( # 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'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
r'You\s+killed\s+a\s+([\w\s]+)', r'Överföring\s+slutförd.*?((\d+(?:\.\d+)?))\s+PED',
re.IGNORECASE re.IGNORECASE
) )
# Enhancer break pattern # ATTRIBUTE GAIN (Agility, etc)
PATTERN_ENHANCER = re.compile( # Swedish: "Din Agility har förbättrats med 0.0001"
PATTERN_ATTRIBUTE_SV = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+' r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
r'Your\s+([\w\s]+)\s+enhancer\s+has\s+broken', r'Din\s+(\w+)\s+har\s+förbättrats\s+med\s+(\d+(?:\.\d+)?)',
re.IGNORECASE re.IGNORECASE
) )
EVENT_PATTERNS = { EVENT_PATTERNS = {
'global': PATTERN_GLOBAL, 'loot_en': PATTERN_LOOT_EN,
'hof': PATTERN_HOF, 'loot_sv': PATTERN_LOOT_SV,
'loot': PATTERN_LOOT, 'global_en': PATTERN_GLOBAL_EN,
'loot_alt': PATTERN_LOOT_ALT, 'global_sv': PATTERN_GLOBAL_SV,
'skill': PATTERN_SKILL, 'hof': PATTERN_HOF_MARKER,
'skill_level': PATTERN_SKILL_LEVEL, 'skill_en': PATTERN_SKILL_EN,
'skill_sv': PATTERN_SKILL_SV,
'damage_dealt': PATTERN_DAMAGE_DEALT_SV,
'critical_hit': PATTERN_CRITICAL_SV,
'damage_taken': PATTERN_DAMAGE_TAKEN_SV,
'heal': PATTERN_HEAL_SV,
'weapon_tier': PATTERN_WEAPON_TIER_SV,
'evade': PATTERN_EVADE,
'decay': PATTERN_DECAY, 'decay': PATTERN_DECAY,
'kill': PATTERN_KILL, 'ped_transfer': PATTERN_PED_TRANSFER,
'enhancer_break': PATTERN_ENHANCER, 'attribute': PATTERN_ATTRIBUTE_SV,
} }
def __init__(self, log_path: Optional[str] = None, def __init__(self, log_path: Optional[str] = None,
poll_interval: float = 1.0, poll_interval: float = 1.0,
mock_mode: bool = False): mock_mode: bool = False):
""" """Initialize LogWatcher."""
Initialize LogWatcher.
Args:
log_path: Path to chat.log. Defaults to EU standard location or ./test-data/
poll_interval: Seconds between polls (default: 1.0 for 60+ FPS compliance)
mock_mode: Use mock data instead of real log file
"""
self.mock_mode = mock_mode self.mock_mode = mock_mode
if log_path is None: if log_path is None:
@ -142,23 +195,17 @@ class LogWatcher:
core_dir = Path(__file__).parent core_dir = Path(__file__).parent
log_path = core_dir.parent / "test-data" / "mock-chat.log" log_path = core_dir.parent / "test-data" / "mock-chat.log"
else: else:
# Try to find EU log path
log_path = self._find_eu_log_path() log_path = self._find_eu_log_path()
self.log_path = Path(log_path) self.log_path = Path(log_path)
self.poll_interval = poll_interval self.poll_interval = poll_interval
# Observer registry: event_type -> list of callbacks
self.observers: Dict[str, List[Callable]] = { self.observers: Dict[str, List[Callable]] = {
'global': [], 'loot': [], 'global': [], 'hof': [], 'skill': [],
'hof': [], 'damage_dealt': [], 'damage_taken': [], 'heal': [],
'loot': [], 'weapon_tier': [], 'evade': [], 'decay': [],
'skill': [], 'critical_hit': [], 'ped_transfer': [], 'attribute': [],
'skill_level': [], 'any': [],
'decay': [],
'kill': [],
'enhancer_break': [],
'any': [], # Catch-all for all events
} }
self._running = False self._running = False
@ -169,19 +216,12 @@ class LogWatcher:
logger.info(f"LogWatcher initialized: {self.log_path} (mock={mock_mode})") logger.info(f"LogWatcher initialized: {self.log_path} (mock={mock_mode})")
def _find_eu_log_path(self) -> Path: def _find_eu_log_path(self) -> Path:
""" """Attempt to find Entropia Universe chat.log."""
Attempt to find Entropia Universe chat.log.
Returns:
Path to log file or test-data fallback
"""
# Common Windows paths
possible_paths = [ possible_paths = [
Path.home() / "Documents" / "Entropia Universe" / "chat.log", Path.home() / "Documents" / "Entropia Universe" / "chat.log",
Path("C:") / "Users" / os.getenv("USERNAME", "User") / "Documents" / "Entropia Universe" / "chat.log", Path("C:") / "Users" / os.getenv("USERNAME", "User") / "Documents" / "Entropia Universe" / "chat.log",
] ]
# Linux/Wine paths
wine_prefix = Path.home() / ".wine" / "drive_c" wine_prefix = Path.home() / ".wine" / "drive_c"
possible_paths.extend([ possible_paths.extend([
wine_prefix / "users" / os.getenv("USER", "user") / "Documents" / "Entropia Universe" / "chat.log", wine_prefix / "users" / os.getenv("USER", "user") / "Documents" / "Entropia Universe" / "chat.log",
@ -192,23 +232,12 @@ class LogWatcher:
logger.info(f"Found EU log: {path}") logger.info(f"Found EU log: {path}")
return path return path
# Fallback to test data
fallback = Path(__file__).parent.parent / "test-data" / "chat.log" fallback = Path(__file__).parent.parent / "test-data" / "chat.log"
logger.warning(f"EU log not found, using fallback: {fallback}") logger.warning(f"EU log not found, using fallback: {fallback}")
return fallback return fallback
# ========================================================================
# OBSERVER PATTERN METHODS
# ========================================================================
def subscribe(self, event_type: str, callback: Callable[[LogEvent], None]) -> None: def subscribe(self, event_type: str, callback: Callable[[LogEvent], None]) -> None:
""" """Subscribe to an event type."""
Subscribe to an event type.
Args:
event_type: Type of event to listen for
callback: Function to call when event occurs
"""
if event_type not in self.observers: if event_type not in self.observers:
self.observers[event_type] = [] self.observers[event_type] = []
@ -216,26 +245,14 @@ class LogWatcher:
logger.debug(f"Subscribed to {event_type}: {callback.__name__}") logger.debug(f"Subscribed to {event_type}: {callback.__name__}")
def unsubscribe(self, event_type: str, callback: Callable[[LogEvent], None]) -> None: def unsubscribe(self, event_type: str, callback: Callable[[LogEvent], None]) -> None:
""" """Unsubscribe from an event type."""
Unsubscribe from an event type.
Args:
event_type: Type of event
callback: Function to remove
"""
if event_type in self.observers: if event_type in self.observers:
if callback in self.observers[event_type]: if callback in self.observers[event_type]:
self.observers[event_type].remove(callback) self.observers[event_type].remove(callback)
logger.debug(f"Unsubscribed from {event_type}: {callback.__name__}") logger.debug(f"Unsubscribed from {event_type}: {callback.__name__}")
def _notify(self, event: LogEvent) -> None: def _notify(self, event: LogEvent) -> None:
""" """Notify all observers of an event."""
Notify all observers of an event.
Args:
event: LogEvent to broadcast
"""
# Notify specific observers
if event.event_type in self.observers: if event.event_type in self.observers:
for callback in self.observers[event.event_type]: for callback in self.observers[event.event_type]:
try: try:
@ -243,17 +260,12 @@ class LogWatcher:
except Exception as e: except Exception as e:
logger.error(f"Observer error for {event.event_type}: {e}") logger.error(f"Observer error for {event.event_type}: {e}")
# Notify catch-all observers
for callback in self.observers['any']: for callback in self.observers['any']:
try: try:
callback(event) callback(event)
except Exception as e: except Exception as e:
logger.error(f"Observer error for 'any': {e}") logger.error(f"Observer error for 'any': {e}")
# ========================================================================
# PARSING METHODS
# ========================================================================
def _parse_timestamp(self, ts_str: str) -> datetime: def _parse_timestamp(self, ts_str: str) -> datetime:
"""Parse EU timestamp format.""" """Parse EU timestamp format."""
return datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S") return datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
@ -261,98 +273,218 @@ class LogWatcher:
def _parse_line(self, line: str) -> Optional[LogEvent]: def _parse_line(self, line: str) -> Optional[LogEvent]:
""" """
Parse a single log line. Parse a single log line.
Returns LogEvent if parsed, None otherwise.
Args:
line: Raw log line
Returns:
LogEvent if parsed, None otherwise
""" """
line = line.strip() line = line.strip()
if not line: if not line:
return None return None
# Try each pattern # Try each pattern
for event_type, pattern in self.EVENT_PATTERNS.items(): # LOOT - Swedish (prioritize based on your game client)
match = pattern.match(line) match = self.PATTERN_LOOT_SV.match(line)
if match: if match:
return self._create_event(event_type, match, line) 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'
}
)
# DAMAGE DEALT
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))}
)
# CRITICAL HIT
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))}
)
# DAMAGE TAKEN
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))}
)
# HEALING
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))}
)
# WEAPON TIER/LEVEL
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))
}
)
# 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
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))
}
)
return None return None
def _create_event(self, event_type: str, match: re.Match, raw_line: str) -> LogEvent: def _create_loot_event_sv(self, match: re.Match, line: str) -> LogEvent:
""" """Create loot event from Swedish pattern."""
Create LogEvent from regex match.
Args:
event_type: Type of event matched
match: Regex match object
raw_line: Original log line
Returns:
Populated LogEvent
"""
groups = match.groups()
timestamp = self._parse_timestamp(groups[0])
data: Dict[str, Any] = {}
if event_type == 'global':
data = {
'player_name': groups[1].strip(),
'zone': groups[2].strip(),
'value_ped': Decimal(groups[3]),
}
elif event_type == 'hof':
data = {
'player_name': groups[1].strip(),
'value_ped': Decimal(groups[2]),
}
elif event_type == 'loot':
data = {
'item_name': groups[1].strip(),
'quantity': int(groups[2]) if groups[2] else 1,
'value_ped': Decimal(groups[3]) if groups[3] else Decimal("0.0"),
}
elif event_type == 'skill':
data = {
'gained': Decimal(groups[1]),
'skill_name': groups[2].strip(),
}
elif event_type == 'skill_level':
data = {
'new_level': int(groups[1]),
'skill_name': groups[2].strip(),
}
elif event_type == 'decay':
data = {
'item_name': groups[1].strip(),
'decay_pec': Decimal(groups[2]),
}
elif event_type == 'kill':
data = {
'creature_name': groups[1].strip(),
}
elif event_type == 'enhancer_break':
data = {
'enhancer_type': groups[1].strip(),
}
return LogEvent( return LogEvent(
timestamp=timestamp, timestamp=self._parse_timestamp(match.group(1)),
event_type=event_type, event_type='loot',
raw_line=raw_line, raw_line=line,
data=data 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 (Performance Optimized) # ASYNC POLLING LOOP
# ======================================================================== # ========================================================================
async def start(self) -> None: async def start(self) -> None:
@ -363,10 +495,9 @@ class LogWatcher:
self._running = True self._running = True
# Initialize file position
if self.log_path.exists(): if self.log_path.exists():
self._last_file_size = self.log_path.stat().st_size self._last_file_size = self.log_path.stat().st_size
self._file_position = self._last_file_size # Start at end (new entries only) self._file_position = self._last_file_size
self._task = asyncio.create_task(self._watch_loop()) self._task = asyncio.create_task(self._watch_loop())
logger.info("LogWatcher started") logger.info("LogWatcher started")
@ -385,47 +516,34 @@ class LogWatcher:
logger.info("LogWatcher stopped") logger.info("LogWatcher stopped")
async def _watch_loop(self) -> None: async def _watch_loop(self) -> None:
""" """Main watching loop."""
Main watching loop.
Efficiently polls file for new content with minimal CPU usage.
"""
while self._running: while self._running:
try: try:
await self._poll_once() await self._poll_once()
except Exception as e: except Exception as e:
logger.error(f"Poll error: {e}") logger.error(f"Poll error: {e}")
# Non-blocking sleep (Rule #3: preserve FPS)
await asyncio.sleep(self.poll_interval) await asyncio.sleep(self.poll_interval)
async def _poll_once(self) -> None: async def _poll_once(self) -> None:
""" """Single poll iteration."""
Single poll iteration.
Reads new lines from file and processes them.
"""
if not self.log_path.exists(): if not self.log_path.exists():
return return
current_size = self.log_path.stat().st_size current_size = self.log_path.stat().st_size
# Check if file was truncated (new session)
if current_size < self._file_position: if current_size < self._file_position:
logger.info("Log file truncated, resetting position") logger.info("Log file truncated, resetting position")
self._file_position = 0 self._file_position = 0
# Check if new content exists
if current_size == self._file_position: if current_size == self._file_position:
return # No new content return
# Read new lines
with open(self.log_path, 'r', encoding='utf-8', errors='ignore') as f: with open(self.log_path, 'r', encoding='utf-8', errors='ignore') as f:
f.seek(self._file_position) f.seek(self._file_position)
new_lines = f.readlines() new_lines = f.readlines()
self._file_position = f.tell() self._file_position = f.tell()
# Parse and notify
for line in new_lines: for line in new_lines:
event = self._parse_line(line) event = self._parse_line(line)
if event: if event:
@ -439,11 +557,7 @@ class LogWatcher:
# ============================================================================ # ============================================================================
class MockLogGenerator: class MockLogGenerator:
""" """Generates mock log entries for testing."""
Generates mock log entries for testing.
Simulates Entropia Universe chat.log output.
"""
MOCK_LINES = [ MOCK_LINES = [
"2026-02-08 14:23:15 [System] You received Shrapnel x 123 (Value: 1.23 PED)", "2026-02-08 14:23:15 [System] You received Shrapnel x 123 (Value: 1.23 PED)",
@ -453,23 +567,20 @@ class MockLogGenerator:
"2026-02-08 14:26:10 [System] You received Animal Thyroid Oil x 5", "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: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", "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 @classmethod
def create_mock_file(cls, path: Path, lines: int = 100) -> None: def create_mock_file(cls, path: Path, lines: int = 100) -> None:
""" """Create a mock chat.log file."""
Create a mock chat.log file.
Args:
path: Path for mock file
lines: Number of lines to generate
"""
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w') as f: with open(path, 'w') as f:
for i in range(lines): for i in range(lines):
line = cls.MOCK_LINES[i % len(cls.MOCK_LINES)] line = cls.MOCK_LINES[i % len(cls.MOCK_LINES)]
# Vary timestamps slightly
f.write(f"{line}\n") f.write(f"{line}\n")
logger.info(f"Created mock log: {path} ({lines} lines)") logger.info(f"Created mock log: {path} ({lines} lines)")

View File

@ -0,0 +1,43 @@
# Real Entropia Universe chat.log sample from user session
# Date: 2025-09-23
# Player: Roberth Noname Rajala
# Location: Arkadia Event Area 4
# Language: Swedish game client
# Purpose: Regex pattern testing for Lemontropia Suite
2025-09-23 19:34:56 [#calytrade] [Dracula exile sanguinium] WTS 16k [Vibrant Sweat] 1.5 / k
2025-09-23 19:34:59 [System] [] Mottagen Effekt Över Tid: Increased Skill Gain
2025-09-23 19:35:31 [Globala] [] Lolly Guarana Lo konstruerade ett objekt (Explosive Projectiles) värt 411 PED! En post har lagts till i Hall of Fame!
2025-09-23 19:35:59 [Globala] [] kuo darkness darkness hittade en avsättning (Blausariam Stone) med ett värde av 197 PED! En post har lagts till i Hall of Fame!
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
2025-09-23 19:36:19 [System] [] The target Dodged your attack
2025-09-23 19:36:19 [System] [] Du tog 31.5 poäng skada
2025-09-23 19:36:20 [System] [] Du orsakade 18.7 poäng skada
2025-09-23 19:36:22 [System] [] Din Piron PBP-17 (L) har nått nivå 0.38
2025-09-23 19:36:23 [System] [] Du har fått 0.0108 erfarenhet i din BLP Weaponry Technology färdighet
2025-09-23 19:36:32 [System] [] Du har fått 0.0168 Serendipity
2025-09-23 19:36:43 [System] [] Du fick Shrapnel x (4627) Värde: 0.4627 PED
2025-09-23 19:36:43 [System] [] Du fick Animal Oil Residue x (151) Värde: 1.51 PED
2025-09-23 19:36:44 [System] [] Mottagen Effekt Över Tid: Helande
2025-09-23 19:36:44 [System] [] Du läkte dig själv 4.0 poäng
2025-09-23 19:36:46 [System] [] Din Regeneration Chip 3 är nära att nå minimalt skick, överväg att reparera den så snart som möjligt
2025-09-23 19:36:46 [System] [] Du läkte dig själv 30.2 poäng
2025-09-23 19:36:46 [System] [] Du har fått 0.3973 erfarenhet i din Concentration färdighet
2025-09-23 19:36:57 [System] [] Kritisk träff - Extra skada! Du orsakade 44.4 poäng skada
2025-09-23 19:37:05 [System] [] Du fick Shrapnel x (6858) Värde: 0.6858 PED
2025-09-23 19:37:05 [System] [] Du har fått 0.1546 erfarenhet i din Skinning färdighet
2025-09-23 19:37:13 [Globala] [] Nanashana Nana Itsanai hittade en avsättning (M-type Asteroid XIX) med ett värde av 1029 PED! En post har lagts till i Hall of Fame!
2025-09-23 19:37:21 [System] [] Du fick Shrapnel x (5530) Värde: 0.5530 PED
2025-09-23 19:37:36 [System] [] Du fick Animal Eye Oil x (11) Värde: 0.5500 PED
2025-09-23 19:37:36 [System] [] Du fick Shrapnel x (1614) Värde: 0.1614 PED
2025-09-23 19:37:37 [Globala] [] Miss Hester Globalisse konstruerade ett objekt (Level 2 Finder Amplifier Light (L)) värt 243 PED! En post har lagts till i Hall of Fame!
2025-09-23 19:39:39 [Globala] [] Alondriel Alon De Valle konstruerade ett objekt (Explosive Projectiles) värt 1574 PED! En post har lagts till i Hall of Fame!
2025-09-23 19:42:18 [System] [] Överföring slutförd! 3.38000 PED har överförts till ditt PED-kort.
2025-09-23 19:42:50 [Globala] [] Roberth Noname Rajala dödade ett kreatur (Nusul Provider) med ett värde av 48 PED vid Arkadia Event Area 4!
2025-09-23 19:42:50 [System] [] Du fick Piron PBP-17 (L) x (1) Värde: 25.00 PED
2025-09-23 19:42:50 [System] [] Du fick Shrapnel x (163760) Värde: 16.37 PED
2025-09-23 19:42:50 [System] [] Du fick Animal Oil Residue x (736) Värde: 7.36 PED
2025-09-23 19:53:33 [System] [] Reduced 3.2 points of critical damage
2025-09-23 19:53:33 [System] [] Kritisk träff - Extra skada! Du tog 22.6 poäng skada
2025-09-23 19:54:34 [System] [] Din Agility har förbättrats med 0.0001