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:
parent
dfe4e8125f
commit
c511ff2042
|
|
@ -1,6 +1,6 @@
|
|||
# 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)
|
||||
# Standards: Python 3.11+, asyncio, regex patterns for Entropia Universe
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
|
@ -29,112 +29,165 @@ 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.
|
||||
|
||||
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
|
||||
PATTERN_GLOBAL = re.compile(
|
||||
# LOOT PATTERNS
|
||||
# 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'([\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
|
||||
)
|
||||
|
||||
PATTERN_HOF = re.compile(
|
||||
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
|
||||
r'([\w\s]+)\s+is\s+in\s+the\s+Hall\s+of\s+Fame\s+.*?(\d+(?:\.\d+)?)\s+PED',
|
||||
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
|
||||
)
|
||||
|
||||
# Regular loot pattern
|
||||
PATTERN_LOOT = re.compile(
|
||||
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
|
||||
r'You\s+received\s+([\w\s]+)\s+x\s*(\d+)\s+.*?(?:Value:\s+(\d+(?:\.\d+)?)\s+PED)?',
|
||||
# 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
|
||||
)
|
||||
|
||||
# Alternative loot pattern (different wording in EU)
|
||||
PATTERN_LOOT_ALT = re.compile(
|
||||
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
|
||||
r'You\s+received\s+([\w\s]+)\s+\(.*?\d+\s+items\)',
|
||||
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
|
||||
)
|
||||
|
||||
# Skill gain pattern
|
||||
PATTERN_SKILL = re.compile(
|
||||
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+'
|
||||
r'You\s+gained\s+(\d+(?:\.\d+)?)\s+experience\s+in\s+your\s+([\w\s]+)\s+skill',
|
||||
# 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 level up pattern
|
||||
PATTERN_SKILL_LEVEL = re.compile(
|
||||
# SKILL GAIN PATTERNS
|
||||
# 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'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
|
||||
)
|
||||
|
||||
# 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(
|
||||
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
|
||||
)
|
||||
|
||||
# Creature killed / target info (useful for context)
|
||||
PATTERN_KILL = re.compile(
|
||||
# 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'You\s+killed\s+a\s+([\w\s]+)',
|
||||
r'Överföring\s+slutförd.*?((\d+(?:\.\d+)?))\s+PED',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# Enhancer break pattern
|
||||
PATTERN_ENHANCER = re.compile(
|
||||
# ATTRIBUTE GAIN (Agility, etc)
|
||||
# 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'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
|
||||
)
|
||||
|
||||
EVENT_PATTERNS = {
|
||||
'global': PATTERN_GLOBAL,
|
||||
'hof': PATTERN_HOF,
|
||||
'loot': PATTERN_LOOT,
|
||||
'loot_alt': PATTERN_LOOT_ALT,
|
||||
'skill': PATTERN_SKILL,
|
||||
'skill_level': PATTERN_SKILL_LEVEL,
|
||||
'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': 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,
|
||||
'kill': PATTERN_KILL,
|
||||
'enhancer_break': PATTERN_ENHANCER,
|
||||
'ped_transfer': PATTERN_PED_TRANSFER,
|
||||
'attribute': PATTERN_ATTRIBUTE_SV,
|
||||
}
|
||||
|
||||
def __init__(self, log_path: Optional[str] = None,
|
||||
poll_interval: float = 1.0,
|
||||
mock_mode: bool = False):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
"""Initialize LogWatcher."""
|
||||
self.mock_mode = mock_mode
|
||||
|
||||
if log_path is None:
|
||||
|
|
@ -142,23 +195,17 @@ class LogWatcher:
|
|||
core_dir = Path(__file__).parent
|
||||
log_path = core_dir.parent / "test-data" / "mock-chat.log"
|
||||
else:
|
||||
# Try to find EU log path
|
||||
log_path = self._find_eu_log_path()
|
||||
|
||||
self.log_path = Path(log_path)
|
||||
self.poll_interval = poll_interval
|
||||
|
||||
# Observer registry: event_type -> list of callbacks
|
||||
self.observers: Dict[str, List[Callable]] = {
|
||||
'global': [],
|
||||
'hof': [],
|
||||
'loot': [],
|
||||
'skill': [],
|
||||
'skill_level': [],
|
||||
'decay': [],
|
||||
'kill': [],
|
||||
'enhancer_break': [],
|
||||
'any': [], # Catch-all for all events
|
||||
'loot': [], 'global': [], 'hof': [], 'skill': [],
|
||||
'damage_dealt': [], 'damage_taken': [], 'heal': [],
|
||||
'weapon_tier': [], 'evade': [], 'decay': [],
|
||||
'critical_hit': [], 'ped_transfer': [], 'attribute': [],
|
||||
'any': [],
|
||||
}
|
||||
|
||||
self._running = False
|
||||
|
|
@ -169,19 +216,12 @@ class LogWatcher:
|
|||
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.
|
||||
|
||||
Returns:
|
||||
Path to log file or test-data fallback
|
||||
"""
|
||||
# Common Windows paths
|
||||
"""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",
|
||||
]
|
||||
|
||||
# Linux/Wine paths
|
||||
wine_prefix = Path.home() / ".wine" / "drive_c"
|
||||
possible_paths.extend([
|
||||
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}")
|
||||
return path
|
||||
|
||||
# Fallback to test data
|
||||
fallback = Path(__file__).parent.parent / "test-data" / "chat.log"
|
||||
logger.warning(f"EU log not found, using fallback: {fallback}")
|
||||
return fallback
|
||||
|
||||
# ========================================================================
|
||||
# OBSERVER PATTERN METHODS
|
||||
# ========================================================================
|
||||
|
||||
def subscribe(self, event_type: str, callback: Callable[[LogEvent], None]) -> None:
|
||||
"""
|
||||
Subscribe to an event type.
|
||||
|
||||
Args:
|
||||
event_type: Type of event to listen for
|
||||
callback: Function to call when event occurs
|
||||
"""
|
||||
"""Subscribe to an event type."""
|
||||
if event_type not in self.observers:
|
||||
self.observers[event_type] = []
|
||||
|
||||
|
|
@ -216,26 +245,14 @@ class LogWatcher:
|
|||
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.
|
||||
|
||||
Args:
|
||||
event_type: Type of event
|
||||
callback: Function to remove
|
||||
"""
|
||||
"""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.
|
||||
|
||||
Args:
|
||||
event: LogEvent to broadcast
|
||||
"""
|
||||
# Notify specific observers
|
||||
"""Notify all observers of an event."""
|
||||
if event.event_type in self.observers:
|
||||
for callback in self.observers[event.event_type]:
|
||||
try:
|
||||
|
|
@ -243,17 +260,12 @@ class LogWatcher:
|
|||
except Exception as e:
|
||||
logger.error(f"Observer error for {event.event_type}: {e}")
|
||||
|
||||
# Notify catch-all observers
|
||||
for callback in self.observers['any']:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception as e:
|
||||
logger.error(f"Observer error for 'any': {e}")
|
||||
|
||||
# ========================================================================
|
||||
# PARSING METHODS
|
||||
# ========================================================================
|
||||
|
||||
def _parse_timestamp(self, ts_str: str) -> datetime:
|
||||
"""Parse EU timestamp format."""
|
||||
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]:
|
||||
"""
|
||||
Parse a single log line.
|
||||
|
||||
Args:
|
||||
line: Raw log line
|
||||
|
||||
Returns:
|
||||
LogEvent if parsed, None otherwise
|
||||
Returns LogEvent if parsed, None otherwise.
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None
|
||||
|
||||
# Try each pattern
|
||||
for event_type, pattern in self.EVENT_PATTERNS.items():
|
||||
match = pattern.match(line)
|
||||
if match:
|
||||
return self._create_event(event_type, match, line)
|
||||
# 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'
|
||||
}
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
def _create_event(self, event_type: str, match: re.Match, raw_line: str) -> LogEvent:
|
||||
"""
|
||||
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(),
|
||||
}
|
||||
|
||||
def _create_loot_event_sv(self, match: re.Match, line: str) -> LogEvent:
|
||||
"""Create loot event from Swedish pattern."""
|
||||
return LogEvent(
|
||||
timestamp=timestamp,
|
||||
event_type=event_type,
|
||||
raw_line=raw_line,
|
||||
data=data
|
||||
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 (Performance Optimized)
|
||||
# ASYNC POLLING LOOP
|
||||
# ========================================================================
|
||||
|
||||
async def start(self) -> None:
|
||||
|
|
@ -363,10 +495,9 @@ class LogWatcher:
|
|||
|
||||
self._running = True
|
||||
|
||||
# Initialize file position
|
||||
if self.log_path.exists():
|
||||
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())
|
||||
logger.info("LogWatcher started")
|
||||
|
|
@ -385,47 +516,34 @@ class LogWatcher:
|
|||
logger.info("LogWatcher stopped")
|
||||
|
||||
async def _watch_loop(self) -> None:
|
||||
"""
|
||||
Main watching loop.
|
||||
|
||||
Efficiently polls file for new content with minimal CPU usage.
|
||||
"""
|
||||
"""Main watching loop."""
|
||||
while self._running:
|
||||
try:
|
||||
await self._poll_once()
|
||||
except Exception as e:
|
||||
logger.error(f"Poll error: {e}")
|
||||
|
||||
# Non-blocking sleep (Rule #3: preserve FPS)
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
|
||||
async def _poll_once(self) -> None:
|
||||
"""
|
||||
Single poll iteration.
|
||||
|
||||
Reads new lines from file and processes them.
|
||||
"""
|
||||
"""Single poll iteration."""
|
||||
if not self.log_path.exists():
|
||||
return
|
||||
|
||||
current_size = self.log_path.stat().st_size
|
||||
|
||||
# Check if file was truncated (new session)
|
||||
if current_size < self._file_position:
|
||||
logger.info("Log file truncated, resetting position")
|
||||
self._file_position = 0
|
||||
|
||||
# Check if new content exists
|
||||
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:
|
||||
f.seek(self._file_position)
|
||||
new_lines = f.readlines()
|
||||
self._file_position = f.tell()
|
||||
|
||||
# Parse and notify
|
||||
for line in new_lines:
|
||||
event = self._parse_line(line)
|
||||
if event:
|
||||
|
|
@ -439,11 +557,7 @@ class LogWatcher:
|
|||
# ============================================================================
|
||||
|
||||
class MockLogGenerator:
|
||||
"""
|
||||
Generates mock log entries for testing.
|
||||
|
||||
Simulates Entropia Universe chat.log output.
|
||||
"""
|
||||
"""Generates mock log entries for testing."""
|
||||
|
||||
MOCK_LINES = [
|
||||
"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: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.
|
||||
|
||||
Args:
|
||||
path: Path for mock file
|
||||
lines: Number of lines to generate
|
||||
"""
|
||||
"""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)]
|
||||
# Vary timestamps slightly
|
||||
f.write(f"{line}\n")
|
||||
|
||||
logger.info(f"Created mock log: {path} ({lines} lines)")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue