Introduce production-ready CLI for hosting Commonwealth Online servers in cloud and on-premises environments. Features: - Server orchestration service (server_service.py) wrapping relay lifecycle - JSON configuration system (config.py) for hosted deployments - Typer+Rich CLI (consumer_server_cli.py) with serve/status/clients/world commands - Windows launcher (start.bat) for one-click server startup - Auto-detection of LAN addresses and dependency installation - Machine-readable JSON output for monitoring and automation Cli commands: serve - Start server with optional config overrides status - Display server stats and packet counters clients - List connected players world time - Set in-game time for all clients world weather - Set weather for all clients config init - Generate default configuration file Documentation: - Updated docs/setup.md with CLI quick-start guide - Added server/README.md with usage instructions - Updated changelog and dev-log with test results Testing: - Verified config generation and loading - Verified server startup banner and LAN detection - Verified fake client connection and welcome packet - Verified CLI help and command routing Co-authored-by: Cursor <cursoragent@cursor.com>
236 lines
7.5 KiB
Python
236 lines
7.5 KiB
Python
"""
|
|
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)
|