feat(globals): add personal global detection with PLAYER_NAME setting

- Add PLAYER_NAME setting to .env.example for avatar name configuration
- Add personal_global patterns for Swedish and English:
  - EN: '[Globals] Player killed a creature (Creature) for X PED'
  - SV: '[Globala] Player dödade ett kreatur (Creature) med X PED'
- Distinguish personal globals from other players' globals
- Show 🎉🎉🎉 YOUR GLOBAL notification with creature name
- Track personal_globals separately in session summary

Fixes #42 - Users can now see when THEY global vs others.
This commit is contained in:
LemonNexus 2026-02-08 18:23:35 +00:00
parent 06a95f56f4
commit 77d8e808fb
3 changed files with 149 additions and 96 deletions

View File

@ -69,6 +69,10 @@ DB_MAX_BACKUPS=10
# Linux/Wine: ~/.wine/drive_c/users/<Username>/Documents/Entropia Universe/chat.log # Linux/Wine: ~/.wine/drive_c/users/<Username>/Documents/Entropia Universe/chat.log
EU_CHAT_LOG_PATH=./test-data/chat.log EU_CHAT_LOG_PATH=./test-data/chat.log
# Your Entropia Universe avatar name (for detecting personal globals/HoFs)
# Example: PLAYER_NAME=John Doe
PLAYER_NAME=
# Log polling interval (milliseconds) # Log polling interval (milliseconds)
LOG_POLL_INTERVAL=1000 LOG_POLL_INTERVAL=1000

View File

@ -28,16 +28,16 @@ class LogEvent:
class LogWatcher: 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. 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.
""" """
# ======================================================================== # ========================================================================
# REGEX PATTERNS - ENGLISH & SWEDISH (from real game logs) # REGEX PATTERNS - ENGLISH & SWEDISH (from real game logs)
# ======================================================================== # ========================================================================
# LOOT PATTERNS # LOOT PATTERNS
# English: "You received Shrapnel x 123 (Value: 1.23 PED)" # English: "You received Shrapnel x 123 (Value: 1.23 PED)"
# Swedish: "Du fick Shrapnel x (4627) Värde: 0.4627 PED" # Swedish: "Du fick Shrapnel x (4627) Värde: 0.4627 PED"
@ -48,14 +48,14 @@ class LogWatcher:
r'Value:\s+(\d+(?:\.\d+)?)\s+PED', r'Value:\s+(\d+(?:\.\d+)?)\s+PED',
re.IGNORECASE re.IGNORECASE
) )
PATTERN_LOOT_SV = re.compile( PATTERN_LOOT_SV = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' 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'Du\s+fick\s+([\w\s\-()]+?)\s+x\s*\((\d+)\)\s*'
r'Värde:\s+(\d+(?:\.\d+)?)\s+PED', r'Värde:\s+(\d+(?:\.\d+)?)\s+PED',
re.IGNORECASE re.IGNORECASE
) )
# GLOBAL PATTERNS (Other players) # GLOBAL PATTERNS (Other players)
# English: "PlayerName globals in Zone for 150.00 PED" # English: "PlayerName globals in Zone for 150.00 PED"
# Swedish: "PlayerName hittade en avsättning (Item) med ett värde av X PED" # Swedish: "PlayerName hittade en avsättning (Item) med ett värde av X PED"
@ -65,14 +65,31 @@ class LogWatcher:
r'(?:for|with\s+a\s+value\s+of)\s+(\d+(?:\.\d+)?)\s+PED', r'(?:for|with\s+a\s+value\s+of)\s+(\d+(?:\.\d+)?)\s+PED',
re.IGNORECASE re.IGNORECASE
) )
PATTERN_GLOBAL_SV = re.compile( PATTERN_GLOBAL_SV = 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+\[Globala?\]\s+\[?\]?\s*'
r'([\w\s]+?)\s+hittade\s+en\s+avsättning\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', r'med\s+ett\s+värde\s+av\s+(\d+(?:\.\d+)?)\s+PED',
re.IGNORECASE re.IGNORECASE
) )
# PERSONAL GLOBAL (when YOU get a global - different from seeing others)
# English: "[Globals] [Player] killed a creature (Creature) with a value of X PED"
# Swedish: "[Globala] [Player] dödade ett kreatur (Creature) med ett värde av X PED"
PATTERN_PERSONAL_GLOBAL_EN = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[Globals\]\s+\[?\]?\s*'
r'([\w\s]+?)\s+killed\s+a\s+creature\s+\(([^)]+)\)\s+'
r'(?:with\s+a\s+value\s+of|for)\s+(\d+(?:\.\d+)?)\s+PED',
re.IGNORECASE
)
PATTERN_PERSONAL_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+dödade\s+ett\s+kreatur\s+\(([^)]+)\)\s+'
r'med\s+ett\s+värde\s+av\s+(\d+(?:\.\d+)?)\s+PED',
re.IGNORECASE
)
# HALL OF FAME PATTERNS # HALL OF FAME PATTERNS
# Swedish: "...En post har lagts till i Hall of Fame!" # Swedish: "...En post har lagts till i Hall of Fame!"
PATTERN_HOF_MARKER = re.compile( PATTERN_HOF_MARKER = re.compile(
@ -80,7 +97,7 @@ class LogWatcher:
r'.*?Hall\s+of\s+Fame', r'.*?Hall\s+of\s+Fame',
re.IGNORECASE re.IGNORECASE
) )
# SKILL GAIN PATTERNS # SKILL GAIN PATTERNS
# English: "You have gained 1.1466 experience in your Whip skill" # English: "You have gained 1.1466 experience in your Whip skill"
# English (alt): "You gained 0.45 experience in your Rifle skill" # English (alt): "You gained 0.45 experience in your Rifle skill"
@ -90,13 +107,13 @@ class LogWatcher:
r'You\s+(?:have\s+)?gained\s+(\d+(?:\.\d+)?)\s+experience\s+in\s+your\s+([\w\s]+?)\s+skill', r'You\s+(?:have\s+)?gained\s+(\d+(?:\.\d+)?)\s+experience\s+in\s+your\s+([\w\s]+?)\s+skill',
re.IGNORECASE re.IGNORECASE
) )
PATTERN_SKILL_SV = re.compile( PATTERN_SKILL_SV = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' 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', r'Du\s+har\s+fått\s+(\d+(?:\.\d+)?)\s+erfarenhet\s+i\s+din\s+([\w\s]+?)\s+färdighet',
re.IGNORECASE re.IGNORECASE
) )
# SKILL LEVEL UP # SKILL LEVEL UP
# English: "You have advanced to level 45 in Rifle" # English: "You have advanced to level 45 in Rifle"
# Swedish: "Du har avancerat till nivå 45 i Rifle" # Swedish: "Du har avancerat till nivå 45 i Rifle"
@ -105,7 +122,7 @@ class LogWatcher:
r'You\s+have\s+advanced\s+to\s+level\s+(\d+)\s+in\s+([\w\s]+)', r'You\s+have\s+advanced\s+to\s+level\s+(\d+)\s+in\s+([\w\s]+)',
re.IGNORECASE re.IGNORECASE
) )
# DAMAGE DEALT - Swedish & English # DAMAGE DEALT - Swedish & English
# Swedish: "Du orsakade 13.5 poäng skada" # Swedish: "Du orsakade 13.5 poäng skada"
# English: "You inflicted 4.4 points of damage" # English: "You inflicted 4.4 points of damage"
@ -114,13 +131,13 @@ class LogWatcher:
r'Du\s+orsakade\s+(\d+(?:\.\d+)?)\s+poäng\s+skada', r'Du\s+orsakade\s+(\d+(?:\.\d+)?)\s+poäng\s+skada',
re.IGNORECASE re.IGNORECASE
) )
PATTERN_DAMAGE_DEALT_EN = re.compile( 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'^(\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', r'You\s+inflicted\s+(\d+(?:\.\d+)?)\s+points?\s+of\s+damage',
re.IGNORECASE re.IGNORECASE
) )
# CRITICAL HIT # CRITICAL HIT
# Swedish: "Kritisk träff - Extra skada! Du orsakade 44.4 poäng skada" # 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" # English: "Critical hit - Additional damage! You inflicted 49.6 points of damage"
@ -129,13 +146,13 @@ class LogWatcher:
r'Kritisk\s+träff.*?Du\s+orsakade\s+(\d+(?:\.\d+)?)\s+poäng\s+skada', r'Kritisk\s+träff.*?Du\s+orsakade\s+(\d+(?:\.\d+)?)\s+poäng\s+skada',
re.IGNORECASE re.IGNORECASE
) )
PATTERN_CRITICAL_EN = re.compile( PATTERN_CRITICAL_EN = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' 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', r'Critical\s+hit.*?You\s+inflicted\s+(\d+(?:\.\d+)?)\s+points?\s+of\s+damage',
re.IGNORECASE re.IGNORECASE
) )
# DAMAGE TAKEN # DAMAGE TAKEN
# Swedish: "Du tog 31.5 poäng skada" # Swedish: "Du tog 31.5 poäng skada"
# English: "You took 7.4 points of damage" # English: "You took 7.4 points of damage"
@ -144,13 +161,13 @@ class LogWatcher:
r'Du\s+tog\s+(\d+(?:\.\d+)?)\s+poäng\s+skada', r'Du\s+tog\s+(\d+(?:\.\d+)?)\s+poäng\s+skada',
re.IGNORECASE re.IGNORECASE
) )
PATTERN_DAMAGE_TAKEN_EN = re.compile( 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'^(\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', r'You\s+took\s+(\d+(?:\.\d+)?)\s+points?\s+of\s+damage',
re.IGNORECASE re.IGNORECASE
) )
# HEALING # HEALING
# Swedish: "Du läkte dig själv 4.0 poäng" # Swedish: "Du läkte dig själv 4.0 poäng"
# English: "You healed yourself 25.5 points" # English: "You healed yourself 25.5 points"
@ -159,13 +176,13 @@ class LogWatcher:
r'Du\s+läkte\s+dig\s+jälv\s+(\d+(?:\.\d+)?)\s+poäng', r'Du\s+läkte\s+dig\s+jälv\s+(\d+(?:\.\d+)?)\s+poäng',
re.IGNORECASE re.IGNORECASE
) )
PATTERN_HEAL_EN = re.compile( PATTERN_HEAL_EN = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' 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?', r'You\s+healed\s+yourself\s+(\d+(?:\.\d+)?)\s+points?',
re.IGNORECASE re.IGNORECASE
) )
# WEAPON TIER/LEVEL UP # WEAPON TIER/LEVEL UP
# Swedish: "Din Piron PBP-17 (L) har nått nivå 0.38" # Swedish: "Din Piron PBP-17 (L) har nått nivå 0.38"
# English: "Your Piron PBP-17 (L) has reached tier 2.68" # English: "Your Piron PBP-17 (L) has reached tier 2.68"
@ -174,13 +191,13 @@ class LogWatcher:
r'Din\s+([\w\s\-()]+?)\s+har\s+nått\s+nivå\s+(\d+(?:\.\d+)?)', r'Din\s+([\w\s\-()]+?)\s+har\s+nått\s+nivå\s+(\d+(?:\.\d+)?)',
re.IGNORECASE re.IGNORECASE
) )
PATTERN_WEAPON_TIER_EN = re.compile( 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'^(\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+)?)', r'Your\s+([\w\s\-()]+?)\s+has\s+reached\s+tier\s+(\d+(?:\.\d+)?)',
re.IGNORECASE re.IGNORECASE
) )
# COMBAT EVADE/DODGE/MISS # COMBAT EVADE/DODGE/MISS
# English: "You Evaded", "The target Evaded your attack", "The attack missed you" # English: "You Evaded", "The target Evaded your attack", "The attack missed you"
PATTERN_EVADE = re.compile( PATTERN_EVADE = re.compile(
@ -188,7 +205,7 @@ class LogWatcher:
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)', 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 re.IGNORECASE
) )
# DECAY (when weapon durability decreases) # DECAY (when weapon durability decreases)
# English: "Your Omegaton M2100 has decayed 15 PEC" # English: "Your Omegaton M2100 has decayed 15 PEC"
# Swedish: "Din Piron PBP-17 (L) har nått minimalt skick" # Swedish: "Din Piron PBP-17 (L) har nått minimalt skick"
@ -197,7 +214,7 @@ class LogWatcher:
r'(?:Your|Din)\s+([\w\s\-()]+?)\s+(?:has\s+decayed|har\s+nått\s+minimalt\s+skick)', r'(?:Your|Din)\s+([\w\s\-()]+?)\s+(?:has\s+decayed|har\s+nått\s+minimalt\s+skick)',
re.IGNORECASE re.IGNORECASE
) )
# BROKEN ENHANCERS # BROKEN ENHANCERS
# English: "Your enhancer Weapon Damage Enhancer 1 on your Piron PBP-17 (L) broke" # English: "Your enhancer Weapon Damage Enhancer 1 on your Piron PBP-17 (L) broke"
PATTERN_ENHANCER_BROKEN = re.compile( PATTERN_ENHANCER_BROKEN = re.compile(
@ -205,7 +222,7 @@ class LogWatcher:
r'Your\s+enhancer\s+([\w\s]+?)\s+on\s+your\s+([\w\s\-()]+?)\s+broke', r'Your\s+enhancer\s+([\w\s]+?)\s+on\s+your\s+([\w\s\-()]+?)\s+broke',
re.IGNORECASE re.IGNORECASE
) )
# PED TRANSFER # PED TRANSFER
# Swedish: "Överföring slutförd! 3.38000 PED har överförts till ditt PED-kort." # Swedish: "Överföring slutförd! 3.38000 PED har överförts till ditt PED-kort."
PATTERN_PED_TRANSFER = re.compile( PATTERN_PED_TRANSFER = re.compile(
@ -213,7 +230,7 @@ class LogWatcher:
r'Överföring\s+slutförd.*?((\d+(?:\.\d+)?))\s+PED', r'Överföring\s+slutförd.*?((\d+(?:\.\d+)?))\s+PED',
re.IGNORECASE re.IGNORECASE
) )
# ATTRIBUTE GAIN (Agility, etc) # ATTRIBUTE GAIN (Agility, etc)
# Swedish: "Din Agility har förbättrats med 0.0001" # Swedish: "Din Agility har förbättrats med 0.0001"
# English: "Your Agility has improved by 0.0001" OR "You gained 0.0001 Agility" # English: "Your Agility has improved by 0.0001" OR "You gained 0.0001 Agility"
@ -222,18 +239,20 @@ class LogWatcher:
r'Din\s+(\w+)\s+har\s+förbättrats\s+med\s+(\d+(?:\.\d+)?)', r'Din\s+(\w+)\s+har\s+förbättrats\s+med\s+(\d+(?:\.\d+)?)',
re.IGNORECASE re.IGNORECASE
) )
PATTERN_ATTRIBUTE_EN = re.compile( PATTERN_ATTRIBUTE_EN = re.compile(
r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' 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+)', r'(?:Your\s+(\w+)\s+has\s+improved\s+by|You\s+gained)\s+(\d+(?:\.\d+)?)\s+(\w+)',
re.IGNORECASE re.IGNORECASE
) )
EVENT_PATTERNS = { EVENT_PATTERNS = {
'loot_en': PATTERN_LOOT_EN, 'loot_en': PATTERN_LOOT_EN,
'loot_sv': PATTERN_LOOT_SV, 'loot_sv': PATTERN_LOOT_SV,
'global_en': PATTERN_GLOBAL_EN, 'global_en': PATTERN_GLOBAL_EN,
'global_sv': PATTERN_GLOBAL_SV, 'global_sv': PATTERN_GLOBAL_SV,
'personal_global_en': PATTERN_PERSONAL_GLOBAL_EN,
'personal_global_sv': PATTERN_PERSONAL_GLOBAL_SV,
'hof': PATTERN_HOF_MARKER, 'hof': PATTERN_HOF_MARKER,
'skill_en': PATTERN_SKILL_EN, 'skill_en': PATTERN_SKILL_EN,
'skill_sv': PATTERN_SKILL_SV, 'skill_sv': PATTERN_SKILL_SV,
@ -254,23 +273,23 @@ class LogWatcher:
'attribute_sv': PATTERN_ATTRIBUTE_SV, 'attribute_sv': PATTERN_ATTRIBUTE_SV,
'attribute_en': PATTERN_ATTRIBUTE_EN, 'attribute_en': PATTERN_ATTRIBUTE_EN,
} }
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."""
self.mock_mode = mock_mode self.mock_mode = mock_mode
if log_path is None: if log_path is None:
if mock_mode: if mock_mode:
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:
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
self.observers: Dict[str, List[Callable]] = { self.observers: Dict[str, List[Callable]] = {
'loot': [], 'global': [], 'hof': [], 'skill': [], 'loot': [], 'global': [], 'hof': [], 'skill': [],
'damage_dealt': [], 'damage_taken': [], 'heal': [], 'damage_dealt': [], 'damage_taken': [], 'heal': [],
@ -278,50 +297,50 @@ class LogWatcher:
'critical_hit': [], 'ped_transfer': [], 'attribute': [], 'critical_hit': [], 'ped_transfer': [], 'attribute': [],
'any': [], 'any': [],
} }
self._running = False self._running = False
self._file_position = 0 self._file_position = 0
self._last_file_size = 0 self._last_file_size = 0
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
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."""
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",
] ]
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",
]) ])
for path in possible_paths: for path in possible_paths:
if path.exists(): if path.exists():
logger.info(f"Found EU log: {path}") logger.info(f"Found EU log: {path}")
return path return path
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
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."""
if event_type not in self.observers: if event_type not in self.observers:
self.observers[event_type] = [] self.observers[event_type] = []
self.observers[event_type].append(callback) self.observers[event_type].append(callback)
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."""
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."""
if event.event_type in self.observers: if event.event_type in self.observers:
@ -330,17 +349,17 @@ class LogWatcher:
callback(event) callback(event)
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}")
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}")
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")
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.
@ -349,28 +368,38 @@ class LogWatcher:
line = line.strip() line = line.strip()
if not line: if not line:
return None return None
# Try each pattern # Try each pattern
# LOOT - Swedish (prioritize based on your game client) # LOOT - Swedish (prioritize based on your game client)
match = self.PATTERN_LOOT_SV.match(line) match = self.PATTERN_LOOT_SV.match(line)
if match: if match:
return self._create_loot_event_sv(match, line) return self._create_loot_event_sv(match, line)
# LOOT - English # LOOT - English
match = self.PATTERN_LOOT_EN.match(line) match = self.PATTERN_LOOT_EN.match(line)
if match: if match:
return self._create_loot_event_en(match, line) return self._create_loot_event_en(match, line)
# GLOBAL - Swedish # GLOBAL - Swedish
match = self.PATTERN_GLOBAL_SV.match(line) match = self.PATTERN_GLOBAL_SV.match(line)
if match: if match:
return self._create_global_event_sv(match, line) return self._create_global_event_sv(match, line)
# GLOBAL - English # GLOBAL - English
match = self.PATTERN_GLOBAL_EN.match(line) match = self.PATTERN_GLOBAL_EN.match(line)
if match: if match:
return self._create_global_event_en(match, line) return self._create_global_event_en(match, line)
# PERSONAL GLOBAL - Swedish (when YOU get a global)
match = self.PATTERN_PERSONAL_GLOBAL_SV.match(line)
if match:
return self._create_personal_global_event(match, line, 'swedish')
# PERSONAL GLOBAL - English (when YOU get a global)
match = self.PATTERN_PERSONAL_GLOBAL_EN.match(line)
if match:
return self._create_personal_global_event(match, line, 'english')
# HOF # HOF
match = self.PATTERN_HOF_MARKER.match(line) match = self.PATTERN_HOF_MARKER.match(line)
if match: if match:
@ -380,7 +409,7 @@ class LogWatcher:
raw_line=line, raw_line=line,
data={'message': 'Hall of Fame entry'} data={'message': 'Hall of Fame entry'}
) )
# SKILL - Swedish # SKILL - Swedish
match = self.PATTERN_SKILL_SV.match(line) match = self.PATTERN_SKILL_SV.match(line)
if match: if match:
@ -394,7 +423,7 @@ class LogWatcher:
'language': 'swedish' 'language': 'swedish'
} }
) )
# SKILL - English # SKILL - English
match = self.PATTERN_SKILL_EN.match(line) match = self.PATTERN_SKILL_EN.match(line)
if match: if match:
@ -408,7 +437,7 @@ class LogWatcher:
'language': 'english' 'language': 'english'
} }
) )
# LEVEL UP - English # LEVEL UP - English
match = self.PATTERN_LEVEL_UP_EN.match(line) match = self.PATTERN_LEVEL_UP_EN.match(line)
if match: if match:
@ -422,7 +451,7 @@ class LogWatcher:
'language': 'english' 'language': 'english'
} }
) )
# DAMAGE DEALT - Swedish # DAMAGE DEALT - Swedish
match = self.PATTERN_DAMAGE_DEALT_SV.match(line) match = self.PATTERN_DAMAGE_DEALT_SV.match(line)
if match: if match:
@ -432,7 +461,7 @@ class LogWatcher:
raw_line=line, raw_line=line,
data={'damage': Decimal(match.group(2)), 'language': 'swedish'} data={'damage': Decimal(match.group(2)), 'language': 'swedish'}
) )
# DAMAGE DEALT - English # DAMAGE DEALT - English
match = self.PATTERN_DAMAGE_DEALT_EN.match(line) match = self.PATTERN_DAMAGE_DEALT_EN.match(line)
if match: if match:
@ -442,7 +471,7 @@ class LogWatcher:
raw_line=line, raw_line=line,
data={'damage': Decimal(match.group(2)), 'language': 'english'} data={'damage': Decimal(match.group(2)), 'language': 'english'}
) )
# CRITICAL HIT - Swedish # CRITICAL HIT - Swedish
match = self.PATTERN_CRITICAL_SV.match(line) match = self.PATTERN_CRITICAL_SV.match(line)
if match: if match:
@ -452,7 +481,7 @@ class LogWatcher:
raw_line=line, raw_line=line,
data={'damage': Decimal(match.group(2)), 'language': 'swedish'} data={'damage': Decimal(match.group(2)), 'language': 'swedish'}
) )
# CRITICAL HIT - English # CRITICAL HIT - English
match = self.PATTERN_CRITICAL_EN.match(line) match = self.PATTERN_CRITICAL_EN.match(line)
if match: if match:
@ -462,7 +491,7 @@ class LogWatcher:
raw_line=line, raw_line=line,
data={'damage': Decimal(match.group(2)), 'language': 'english'} data={'damage': Decimal(match.group(2)), 'language': 'english'}
) )
# DAMAGE TAKEN - Swedish # DAMAGE TAKEN - Swedish
match = self.PATTERN_DAMAGE_TAKEN_SV.match(line) match = self.PATTERN_DAMAGE_TAKEN_SV.match(line)
if match: if match:
@ -472,7 +501,7 @@ class LogWatcher:
raw_line=line, raw_line=line,
data={'damage': Decimal(match.group(2)), 'language': 'swedish'} data={'damage': Decimal(match.group(2)), 'language': 'swedish'}
) )
# DAMAGE TAKEN - English # DAMAGE TAKEN - English
match = self.PATTERN_DAMAGE_TAKEN_EN.match(line) match = self.PATTERN_DAMAGE_TAKEN_EN.match(line)
if match: if match:
@ -482,7 +511,7 @@ class LogWatcher:
raw_line=line, raw_line=line,
data={'damage': Decimal(match.group(2)), 'language': 'english'} data={'damage': Decimal(match.group(2)), 'language': 'english'}
) )
# HEALING - Swedish # HEALING - Swedish
match = self.PATTERN_HEAL_SV.match(line) match = self.PATTERN_HEAL_SV.match(line)
if match: if match:
@ -492,7 +521,7 @@ class LogWatcher:
raw_line=line, raw_line=line,
data={'heal_amount': Decimal(match.group(2)), 'language': 'swedish'} data={'heal_amount': Decimal(match.group(2)), 'language': 'swedish'}
) )
# HEALING - English # HEALING - English
match = self.PATTERN_HEAL_EN.match(line) match = self.PATTERN_HEAL_EN.match(line)
if match: if match:
@ -502,7 +531,7 @@ class LogWatcher:
raw_line=line, raw_line=line,
data={'heal_amount': Decimal(match.group(2)), 'language': 'english'} data={'heal_amount': Decimal(match.group(2)), 'language': 'english'}
) )
# WEAPON TIER/LEVEL - Swedish # WEAPON TIER/LEVEL - Swedish
match = self.PATTERN_WEAPON_TIER_SV.match(line) match = self.PATTERN_WEAPON_TIER_SV.match(line)
if match: if match:
@ -516,7 +545,7 @@ class LogWatcher:
'language': 'swedish' 'language': 'swedish'
} }
) )
# WEAPON TIER/LEVEL - English # WEAPON TIER/LEVEL - English
match = self.PATTERN_WEAPON_TIER_EN.match(line) match = self.PATTERN_WEAPON_TIER_EN.match(line)
if match: if match:
@ -530,7 +559,7 @@ class LogWatcher:
'language': 'english' 'language': 'english'
} }
) )
# EVADE/DODGE/MISS # EVADE/DODGE/MISS
match = self.PATTERN_EVADE.match(line) match = self.PATTERN_EVADE.match(line)
if match: if match:
@ -540,7 +569,7 @@ class LogWatcher:
raw_line=line, raw_line=line,
data={'type': match.group(2)} data={'type': match.group(2)}
) )
# DECAY # DECAY
match = self.PATTERN_DECAY.match(line) match = self.PATTERN_DECAY.match(line)
if match: if match:
@ -550,7 +579,7 @@ class LogWatcher:
raw_line=line, raw_line=line,
data={'item': match.group(2).strip()} data={'item': match.group(2).strip()}
) )
# BROKEN ENHANCER # BROKEN ENHANCER
match = self.PATTERN_ENHANCER_BROKEN.match(line) match = self.PATTERN_ENHANCER_BROKEN.match(line)
if match: if match:
@ -563,7 +592,7 @@ class LogWatcher:
'weapon': match.group(3).strip() 'weapon': match.group(3).strip()
} }
) )
# ATTRIBUTE GAIN - Swedish # ATTRIBUTE GAIN - Swedish
match = self.PATTERN_ATTRIBUTE_SV.match(line) match = self.PATTERN_ATTRIBUTE_SV.match(line)
if match: if match:
@ -577,7 +606,7 @@ class LogWatcher:
'language': 'swedish' 'language': 'swedish'
} }
) )
# ATTRIBUTE GAIN - English # ATTRIBUTE GAIN - English
match = self.PATTERN_ATTRIBUTE_EN.match(line) match = self.PATTERN_ATTRIBUTE_EN.match(line)
if match: if match:
@ -591,9 +620,9 @@ class LogWatcher:
'language': 'english' 'language': 'english'
} }
) )
return None return None
def _create_loot_event_sv(self, match: re.Match, line: str) -> LogEvent: def _create_loot_event_sv(self, match: re.Match, line: str) -> LogEvent:
"""Create loot event from Swedish pattern.""" """Create loot event from Swedish pattern."""
return LogEvent( return LogEvent(
@ -607,7 +636,7 @@ class LogWatcher:
'language': 'swedish' 'language': 'swedish'
} }
) )
def _create_loot_event_en(self, match: re.Match, line: str) -> LogEvent: def _create_loot_event_en(self, match: re.Match, line: str) -> LogEvent:
"""Create loot event from English pattern.""" """Create loot event from English pattern."""
value = match.group(4) value = match.group(4)
@ -622,7 +651,7 @@ class LogWatcher:
'language': 'english' 'language': 'english'
} }
) )
def _create_global_event_sv(self, match: re.Match, line: str) -> LogEvent: def _create_global_event_sv(self, match: re.Match, line: str) -> LogEvent:
"""Create global event from Swedish pattern.""" """Create global event from Swedish pattern."""
return LogEvent( return LogEvent(
@ -636,7 +665,7 @@ class LogWatcher:
'language': 'swedish' 'language': 'swedish'
} }
) )
def _create_global_event_en(self, match: re.Match, line: str) -> LogEvent: def _create_global_event_en(self, match: re.Match, line: str) -> LogEvent:
"""Create global event from English pattern.""" """Create global event from English pattern."""
return LogEvent( return LogEvent(
@ -650,39 +679,53 @@ class LogWatcher:
'language': 'english' 'language': 'english'
} }
) )
def _create_personal_global_event(self, match: re.Match, line: str, language: str) -> LogEvent:
"""Create personal global event (when YOU get a global)."""
return LogEvent(
timestamp=self._parse_timestamp(match.group(1)),
event_type='personal_global',
raw_line=line,
data={
'player_name': match.group(2).strip(),
'creature': match.group(3).strip(),
'value_ped': Decimal(match.group(4)),
'language': language
}
)
# ======================================================================== # ========================================================================
# ASYNC POLLING LOOP # ASYNC POLLING LOOP
# ======================================================================== # ========================================================================
async def start(self) -> None: async def start(self) -> None:
"""Start watching log file asynchronously.""" """Start watching log file asynchronously."""
if self._running: if self._running:
logger.warning("LogWatcher already running") logger.warning("LogWatcher already running")
return return
self._running = True self._running = True
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 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")
async def stop(self) -> None: async def stop(self) -> None:
"""Stop watching log file.""" """Stop watching log file."""
self._running = False self._running = False
if self._task: if self._task:
self._task.cancel() self._task.cancel()
try: try:
await self._task await self._task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
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."""
while self._running: while self._running:
@ -690,33 +733,33 @@ class LogWatcher:
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}")
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."""
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
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
if current_size == self._file_position: if current_size == self._file_position:
return return
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()
for line in new_lines: for line in new_lines:
event = self._parse_line(line) event = self._parse_line(line)
if event: if event:
self._notify(event) self._notify(event)
self._last_file_size = current_size self._last_file_size = current_size
@ -726,7 +769,7 @@ class LogWatcher:
class MockLogGenerator: class MockLogGenerator:
"""Generates mock log entries for testing.""" """Generates mock log entries for testing."""
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)",
"2026-02-08 14:23:45 [System] You gained 0.45 experience in your Rifle skill", "2026-02-08 14:23:45 [System] You gained 0.45 experience in your Rifle skill",
@ -740,17 +783,17 @@ class MockLogGenerator:
"2025-09-23 19:36:08 [System] Du har fått 0.3238 erfarenhet i din Translocation färdighet", "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: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."""
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)]
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)")

10
main.py
View File

@ -207,7 +207,7 @@ class LemontropiaApp:
logger.info(f"Using REAL log: {log_path}") logger.info(f"Using REAL log: {log_path}")
# Stats tracking # Stats tracking
stats = {'loot': 0, 'globals': 0, 'hofs': 0, 'skills': 0, 'level_ups': 0, 'weapon_tiers': 0, 'enhancers_broken': 0, 'damage_dealt': 0, 'damage_taken': 0, 'evades': 0, 'total_ped': Decimal('0.0')} stats = {'loot': 0, 'globals': 0, 'personal_globals': 0, 'hofs': 0, 'skills': 0, 'level_ups': 0, 'weapon_tiers': 0, 'enhancers_broken': 0, 'damage_dealt': 0, 'damage_taken': 0, 'evades': 0, 'total_ped': Decimal('0.0')}
def on_event(event): def on_event(event):
"""Handle log events.""" """Handle log events."""
@ -232,6 +232,10 @@ class LemontropiaApp:
stats['globals'] += 1 stats['globals'] += 1
print(f" 🌍 GLOBAL: {event.data.get('player_name')} found {event.data.get('value_ped')} PED!") print(f" 🌍 GLOBAL: {event.data.get('player_name')} found {event.data.get('value_ped')} PED!")
elif event.event_type == 'personal_global':
stats['personal_globals'] += 1
print(f" 🎉🎉🎉 YOUR GLOBAL: {event.data.get('player_name')} killed {event.data.get('creature')} for {event.data.get('value_ped')} PED!!! 🎉🎉🎉")
elif event.event_type == 'hof': elif event.event_type == 'hof':
stats['hofs'] += 1 stats['hofs'] += 1
print(f" 🏆 HALL OF FAME: {event.data.get('value_ped')} PED!") print(f" 🏆 HALL OF FAME: {event.data.get('value_ped')} PED!")
@ -271,6 +275,7 @@ class LemontropiaApp:
# Subscribe to events # Subscribe to events
self.watcher.subscribe('loot', on_event) self.watcher.subscribe('loot', on_event)
self.watcher.subscribe('global', on_event) self.watcher.subscribe('global', on_event)
self.watcher.subscribe('personal_global', on_event)
self.watcher.subscribe('hof', on_event) self.watcher.subscribe('hof', on_event)
self.watcher.subscribe('skill', on_event) self.watcher.subscribe('skill', on_event)
self.watcher.subscribe('level_up', on_event) self.watcher.subscribe('level_up', on_event)
@ -307,7 +312,8 @@ class LemontropiaApp:
print("="*50) print("="*50)
print(f"\n📊 SESSION SUMMARY:") print(f"\n📊 SESSION SUMMARY:")
print(f" Loot events: {stats['loot']}") print(f" Loot events: {stats['loot']}")
print(f" Globals: {stats['globals']}") print(f" Your Globals: {stats['personal_globals']}")
print(f" Other Globals: {stats['globals']}")
print(f" HoFs: {stats['hofs']}") print(f" HoFs: {stats['hofs']}")
print(f" Skills: {stats['skills']}") print(f" Skills: {stats['skills']}")
print(f" Level Ups: {stats['level_ups']}") print(f" Level Ups: {stats['level_ups']}")