""" Entropia Universe Game Client Integration ========================================== Provides integration with the EU game client: - Process detection - Window tracking - Log file monitoring - Event emission """ import os import re import time import logging import threading from dataclasses import dataclass, field from enum import Enum, auto from pathlib import Path from typing import Optional, Callable, Dict, List, Any, Set class GameState(Enum): """Game client connection state.""" DISCONNECTED = auto() DETECTED = auto() # Process found CONNECTED = auto() # Log file accessible PLAYING = auto() # Character active @dataclass class CharacterInfo: """Information about the current character.""" name: Optional[str] = None level: Optional[int] = None profession: Optional[str] = None health: Optional[float] = None position: Optional[tuple] = None class GameClient: """Main interface for Entropia Universe game client. This class monitors the game client process, tracks window state, parses log files, and emits events for game actions. Example: client = GameClient() @client.on('loot') def handle_loot(event): print(f"Got: {event['item']}") client.start() """ # Default paths DEFAULT_INSTALL_PATHS = [ Path("C:/Program Files (x86)/Entropia Universe"), Path("C:/Program Files/Entropia Universe"), Path.home() / "AppData" / "Local" / "Entropia Universe", ] def __init__( self, game_path: Optional[Path] = None, event_bus: Optional[Any] = None, poll_interval: float = 1.0 ): """Initialize game client. Args: game_path: Path to EU installation (auto-detected if None) event_bus: Event bus for emitting events poll_interval: Seconds between process checks """ self.game_path = game_path self.event_bus = event_bus self.poll_interval = poll_interval self._state = GameState.DISCONNECTED self._character = CharacterInfo() self._process_id: Optional[int] = None self._window_handle: Optional[int] = None self._log_path: Optional[Path] = None self._running = False self._monitor_thread: Optional[threading.Thread] = None self._callbacks: Dict[str, List[Callable]] = {} self._logger = logging.getLogger("GameClient") # Sub-components self._log_parser: Optional[Any] = None self._window_tracker: Optional[Any] = None # ========== Properties ========== @property def state(self) -> GameState: """Current game connection state.""" return self._state @property def is_connected(self) -> bool: """Whether game client is connected.""" return self._state in (GameState.CONNECTED, GameState.PLAYING) @property def is_playing(self) -> bool: """Whether character is active in game.""" return self._state == GameState.PLAYING @property def character(self) -> CharacterInfo: """Current character information.""" return self._character # ========== Lifecycle ========== def start(self) -> bool: """Start monitoring the game client. Returns: True if started successfully """ if self._running: return True self._running = True # Auto-detect game path if not provided if not self.game_path: self.game_path = self._detect_game_path() if self.game_path: self._log_path = self.game_path / "chat.log" # Start monitoring thread self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) self._monitor_thread.start() self._logger.info("Game client monitor started") return True def stop(self) -> None: """Stop monitoring the game client.""" self._running = False if self._monitor_thread: self._monitor_thread.join(timeout=2.0) if self._log_parser: self._log_parser.stop() self._logger.info("Game client monitor stopped") # ========== Event Handling ========== def on(self, event_type: str, callback: Callable) -> Callable: """Register event callback. Args: event_type: Event type ('loot', 'skill', 'global', etc.) callback: Function to call when event occurs Returns: The callback function (for use as decorator) """ if event_type not in self._callbacks: self._callbacks[event_type] = [] self._callbacks[event_type].append(callback) return callback def off(self, event_type: str, callback: Callable) -> bool: """Unregister event callback.""" if event_type in self._callbacks: if callback in self._callbacks[event_type]: self._callbacks[event_type].remove(callback) return True return False def _emit(self, event_type: str, data: Dict[str, Any]) -> None: """Emit event to callbacks and event bus.""" # Call local callbacks for callback in self._callbacks.get(event_type, []): try: callback(data) except Exception as e: self._logger.error(f"Error in callback: {e}") # Emit to event bus if self.event_bus: try: self.event_bus.emit(f"game.{event_type}", data, source="game_client") except Exception as e: self._logger.error(f"Error emitting to event bus: {e}") # ========== Detection ========== def _detect_game_path(self) -> Optional[Path]: """Auto-detect EU installation path.""" # Check default paths for path in self.DEFAULT_INSTALL_PATHS: if path.exists() and (path / "Entropia.exe").exists(): self._logger.info(f"Found EU at: {path}") return path # Check registry on Windows try: import winreg with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall") as key: for i in range(winreg.QueryInfoKey(key)[0]): try: subkey_name = winreg.EnumKey(key, i) with winreg.OpenKey(key, subkey_name) as subkey: name = winreg.QueryValueEx(subkey, "DisplayName")[0] if "Entropia" in name: path = winreg.QueryValueEx(subkey, "InstallLocation")[0] return Path(path) except: continue except Exception as e: self._logger.debug(f"Registry search failed: {e}") return None def _find_process(self) -> Optional[int]: """Find EU game process ID.""" try: import psutil for proc in psutil.process_iter(['pid', 'name']): try: if proc.info['name'] and 'entropia' in proc.info['name'].lower(): return proc.info['pid'] except (psutil.NoSuchProcess, psutil.AccessDenied): continue except ImportError: pass # Fallback: check if log file exists and is being written if self._log_path and self._log_path.exists(): # Check if file was modified recently mtime = self._log_path.stat().st_mtime if time.time() - mtime < 60: # Modified in last minute return -1 # Unknown PID but likely running return None def _is_window_focused(self) -> bool: """Check if EU window is focused.""" try: import win32gui import win32process hwnd = win32gui.GetForegroundWindow() _, pid = win32process.GetWindowThreadProcessId(hwnd) return pid == self._process_id except ImportError: pass return False # ========== Monitoring Loop ========== def _monitor_loop(self) -> None: """Main monitoring loop.""" last_log_position = 0 while self._running: try: # Check process pid = self._find_process() if pid and self._state == GameState.DISCONNECTED: self._process_id = pid self._state = GameState.DETECTED self._emit('connected', {'pid': pid}) self._start_log_parsing() elif not pid and self._state != GameState.DISCONNECTED: self._state = GameState.DISCONNECTED self._process_id = None self._emit('disconnected', {}) self._stop_log_parsing() # Check window focus if self._state != GameState.DISCONNECTED: focused = self._is_window_focused() self._emit('focus_changed', {'focused': focused}) time.sleep(self.poll_interval) except Exception as e: self._logger.error(f"Monitor error: {e}") time.sleep(self.poll_interval) def _start_log_parsing(self) -> None: """Start parsing log files.""" if not self._log_path or not self._log_path.exists(): return from .log_parser import LogParser self._log_parser = LogParser(self._log_path) @self._log_parser.on_event def handle_log_event(event_type, data): self._handle_log_event(event_type, data) self._log_parser.start() self._state = GameState.CONNECTED def _stop_log_parsing(self) -> None: """Stop parsing log files.""" if self._log_parser: self._log_parser.stop() self._log_parser = None def _handle_log_event(self, event_type: str, data: Dict[str, Any]) -> None: """Handle events from log parser.""" # Update character info if event_type == 'system_message' and 'character' in data: self._character.name = data.get('character') self._state = GameState.PLAYING # Emit to listeners self._emit(event_type, data) # ========== Public API ========== def get_game_path(self) -> Optional[Path]: """Get detected game path.""" return self.game_path def get_log_path(self) -> Optional[Path]: """Get path to chat.log.""" return self._log_path def set_game_path(self, path: Path) -> None: """Manually set game path.""" self.game_path = Path(path) self._log_path = self.game_path / "chat.log"