""" Orchestration layer for Commonwealth Online server. Wraps the relay server lifecycle, configuration, and admin operations for both CLI and future GUI host applications. """ from __future__ import annotations import json import time import threading from dataclasses import dataclass, asdict from typing import Any, Callable from server_core import FalloutTogetherServer @dataclass class ServerConfig: """Server configuration.""" host: str = "0.0.0.0" port: int = 7777 server_name: str = "Commonwealth Online Server" max_players: int = 16 log_verbosity: str = "info" @dataclass class ClientSnapshot: """Snapshot of a connected client.""" player_id: int address: str connected_at: float packets_sent: int packets_received: int label: str @dataclass class ServerStats: """Server statistics snapshot.""" is_running: bool host: str port: str server_name: str uptime_seconds: float connected_clients: int clients: list[ClientSnapshot] packets_received: int packets_sent: int transform_packets_received: int transform_packets_broadcast: int world_state_packets_received: int world_state_packets_broadcast: int class ServerService: """ High-level server orchestration facade. Provides lifecycle management, configuration application, and admin operations for the underlying FalloutTogetherServer relay. """ def __init__(self, config: ServerConfig | None = None) -> None: self.config = config or ServerConfig() self._server: FalloutTogetherServer | None = None self._log_listeners: list[Callable[[str], None]] = [] self._log_lock = threading.RLock() self._running = False def add_log_listener(self, callback: Callable[[str], None]) -> None: """Register a callback for server log messages.""" with self._log_lock: if callback not in self._log_listeners: self._log_listeners.append(callback) def remove_log_listener(self, callback: Callable[[str], None]) -> None: """Unregister a log callback.""" with self._log_lock: if callback in self._log_listeners: self._log_listeners.remove(callback) def _dispatch_log(self, message: str) -> None: """Dispatch a log message to all registered listeners.""" with self._log_lock: listeners = list(self._log_listeners) for listener in listeners: try: listener(message) except Exception: pass def start(self) -> None: """Start the server in a background thread.""" if self._running: self._dispatch_log("Server is already running.") return self._server = FalloutTogetherServer( host=self.config.host, port=self.config.port, ) self._server.add_log_listener(self._dispatch_log) self._running = True thread = threading.Thread(target=self._serve_forever, daemon=True) thread.start() self._dispatch_log(f"Server started in background thread.") def serve_forever(self) -> None: """Start the server and block until shutdown.""" if self._running: raise RuntimeError("Server is already running.") self._server = FalloutTogetherServer( host=self.config.host, port=self.config.port, ) self._server.add_log_listener(self._dispatch_log) self._running = True try: self._server.serve_forever() finally: self._running = False def _serve_forever(self) -> None: """Internal serve_forever for background thread.""" try: if self._server: self._server.serve_forever() finally: self._running = False def stop(self) -> None: """Stop the server.""" if not self._running or not self._server: self._dispatch_log("Server is not running.") return self._server.stop() self._running = False self._dispatch_log("Server stopped.") def is_running(self) -> bool: """Check if the server is running.""" return self._running and (self._server is not None and self._server.is_running()) def get_stats(self) -> ServerStats: """Get current server statistics.""" if not self._server: return ServerStats( is_running=False, host=self.config.host, port=str(self.config.port), server_name=self.config.server_name, uptime_seconds=0.0, connected_clients=0, clients=[], packets_received=0, packets_sent=0, transform_packets_received=0, transform_packets_broadcast=0, world_state_packets_received=0, world_state_packets_broadcast=0, ) core_stats = self._server.get_stats() clients_data = self._server.get_clients() client_snapshots = [ ClientSnapshot( player_id=client["playerId"], address=f"{client['address'][0]}:{client['address'][1]}", connected_at=client["connectedAt"], packets_sent=client["packetsSent"], packets_received=client["packetsReceived"], label=client["label"], ) for client in clients_data ] return ServerStats( is_running=core_stats.get("isRunning", False), host=core_stats.get("host", self.config.host), port=str(core_stats.get("port", self.config.port)), server_name=self.config.server_name, uptime_seconds=core_stats.get("uptimeSeconds", 0.0), connected_clients=core_stats.get("connectedClients", 0), clients=client_snapshots, packets_received=core_stats.get("packetsReceived", 0), packets_sent=core_stats.get("packetsSent", 0), transform_packets_received=core_stats.get("transformPacketsReceived", 0), transform_packets_broadcast=core_stats.get("transformPacketsBroadcast", 0), world_state_packets_received=core_stats.get("worldStatePacketsReceived", 0), world_state_packets_broadcast=core_stats.get("worldStatePacketsBroadcast", 0), ) def set_server_time(self, hhmm: str) -> tuple[bool, str]: """ Set server time (HHmm format). Returns (success, message). """ if not self._server: return False, "Server is not running." success = self._server.set_server_time(hhmm) if success: return True, f"Server time set to {hhmm}." else: return False, f"Invalid time format. Use HHmm (e.g., 1430 for 14:30)." def set_server_weather(self, fw_console_arg: str) -> tuple[bool, str]: """ Set server weather (form ID or preset name). Returns (success, message). """ if not self._server: return False, "Server is not running." success = self._server.set_server_weather(fw_console_arg) if success: return True, f"Server weather updated to {fw_console_arg}." else: return False, f"Invalid weather ID. Use an 8-digit hex form ID or preset name." def stats_to_json(self, stats: ServerStats) -> str: """Serialize stats to JSON.""" data = asdict(stats) return json.dumps(data, indent=2)