Files
Commonwealth-Online-Public/server/server_service.py
T
andrewandCursor 7a3e0855b0 Add consumer server CLI with Typer + Rich and Windows launcher
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>
2026-07-09 20:44:40 +12:00

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)