Backend: - Complete FastAPI backend with 42+ REST endpoints (auth, servers, config, players, bans, missions, mods, games, system) - Game adapter architecture with Arma 3 as first-class adapter - WebSocket real-time events for status, metrics, logs, players - Background thread system (process monitor, metrics, log tail, RCon poller) - Fernet encryption for sensitive config fields at rest - JWT auth with admin/viewer roles, bcrypt password hashing - SQLite with WAL mode, parameterized queries, migration system - APScheduler cleanup jobs for logs, metrics, events Frontend: - Server Detail page with 7 tabs (overview, config, players, bans, missions, mods, logs) - Settings page with password change and admin user management - Create Server wizard (4-step; known bug: silent validation failure) - New hooks: useServerDetail, useAuth, useGames - New components: ServerHeader, ConfigEditor, PlayerTable, BanTable, MissionList, ModList, LogViewer, PasswordChange, UserManager - WebSocket onEvent callback for real-time log accumulation - 120 unit tests passing (Vitest + React Testing Library) Docs: - Added .gitignore, CLAUDE.md, README.md - Updated FRONTEND.md, ARCHITECTURE.md with current implementation state - Added .env.example for backend configuration Known issues: - Create Server form: "Next" buttons don't validate before advancing, causing silent submit failure when fields are invalid - Config sub-tabs need UX redesign for non-technical users
169 lines
5.9 KiB
Python
169 lines
5.9 KiB
Python
"""
|
|
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) |