""" RemoteAdminPollerThread — polls the game server's remote admin interface (e.g. BattlEye RCon for Arma3) to sync the player list. Design notes: - Uses the RemoteAdminClient protocol injected at construction time - Reconnects automatically on disconnect with exponential backoff - Persists current player list to players table via PlayerRepository - Emits player_join / player_leave events via EventRepository - Pushes player list updates to broadcast_queue if provided Poll interval: 30 seconds. Reconnect backoff: 5s -> 10s -> 20s -> 40s -> 60s (cap). """ from __future__ import annotations import logging import queue from core.dal.event_repository import EventRepository from core.dal.player_repository import PlayerRepository from core.threads.base_thread import BaseServerThread logger = logging.getLogger(__name__) _POLL_INTERVAL = 30.0 _RECONNECT_BACKOFF_BASE = 5.0 _RECONNECT_BACKOFF_MAX = 60.0 _RECONNECT_BACKOFF_MULT = 2.0 class RemoteAdminPollerThread(BaseServerThread): """ Polls the remote admin interface for a game server. Args: server_id: Database server ID. remote_admin_client: Connected RemoteAdminClient instance. broadcast_queue: Optional queue.Queue for player list pushes. """ def __init__( self, server_id: int, remote_admin_client, broadcast_queue=None, ) -> None: super().__init__(server_id, "RemoteAdminPoller") self._client = remote_admin_client self._broadcast_queue = broadcast_queue self._connected = False self._reconnect_backoff = _RECONNECT_BACKOFF_BASE self._known_players: dict[str, dict] = {} # player_uid -> player data # ── Lifecycle ── def _on_stop(self) -> None: if self._connected and self._client is not None: try: self._client.disconnect() except Exception as exc: logger.debug("[%s] Error disconnecting remote admin on stop: %s", self.name, exc) self._connected = False # ── Main loop ── def _run_loop(self) -> None: if not self._connected: self._attempt_connect() return self._stop_event.wait(timeout=_POLL_INTERVAL) if self._stop_event.is_set(): return try: players = self._client.get_players() self._reconnect_backoff = _RECONNECT_BACKOFF_BASE self._sync_players(players) except Exception as exc: logger.warning("[%s] Poll failed: %s — will reconnect", self.name, exc) self._connected = False try: if self._client is not None: self._client.disconnect() except Exception as exc: logger.debug("[%s] Error disconnecting after poll failure: %s", self.name, exc) # ── Connection management ── def _attempt_connect(self) -> None: try: self._client.connect() if hasattr(self._client, "connect") else None self._connected = True self._reconnect_backoff = _RECONNECT_BACKOFF_BASE logger.info("[%s] Connected to remote admin", self.name) except Exception as exc: logger.warning( "[%s] Connection failed: %s — retrying in %.1fs", self.name, exc, self._reconnect_backoff, ) self._stop_event.wait(timeout=self._reconnect_backoff) self._reconnect_backoff = min( self._reconnect_backoff * _RECONNECT_BACKOFF_MULT, _RECONNECT_BACKOFF_MAX, ) # ── Player sync ── def _sync_players(self, current_players: list[dict]) -> None: """ Diff current_players against self._known_players. Insert join events for new players, leave events for departed ones. Upsert all current players in the DB. Each player dict must have at least: slot_id, name (other fields optional). """ if self._db is None: return player_repo = PlayerRepository(self._db) event_repo = EventRepository(self._db) # Build uid sets for diffing — use slot_id as key current_slots = {str(p.get("slot_id", i)): p for i, p in enumerate(current_players)} current_keys = set(current_slots.keys()) known_keys = set(self._known_players.keys()) joined = current_keys - known_keys left = known_keys - current_keys for slot_key, player in current_slots.items(): player_repo.upsert(server_id=self.server_id, player=player) if slot_key in joined: event_repo.insert( server_id=self.server_id, event_type="player_join", detail={"name": player.get("name", ""), "slot": slot_key}, ) logger.debug("[%s] Player joined: %s (slot %s)", self.name, player.get("name"), slot_key) for slot_key in left: departed = self._known_players[slot_key] event_repo.insert( server_id=self.server_id, event_type="player_leave", detail={"name": departed.get("name", ""), "slot": slot_key}, ) logger.debug("[%s] Player left: %s (slot %s)", self.name, departed.get("name"), slot_key) try: self._db.commit() except Exception as exc: logger.error("[%s] DB commit failed during player sync: %s", self.name, exc) self._db.rollback() # Update known players self._known_players = current_slots if self._broadcast_queue is not None: try: self._broadcast_queue.put_nowait({ "type": "players", "server_id": self.server_id, "data": current_players, }) except queue.Full: logger.debug("[%s] Broadcast queue full, dropping players event", self.name)