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>
369 lines
10 KiB
Python
369 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Commonwealth Online Consumer Server CLI.
|
|
|
|
Production-ready command-line interface for hosting Commonwealth Online servers
|
|
in cloud and on-premises environments.
|
|
|
|
Usage:
|
|
commonwealth serve [--config CONFIG_PATH] [--host HOST] [--port PORT]
|
|
commonwealth status
|
|
commonwealth clients
|
|
commonwealth world time HHmm
|
|
commonwealth world weather FORM_ID
|
|
commonwealth config init OUTPUT_PATH
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
import typer
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
from rich import box
|
|
|
|
from server_service import ServerService, ServerConfig
|
|
from config import Config, load_config, save_config, generate_default_config, validate_config
|
|
|
|
# Rich console for beautiful output
|
|
console = Console()
|
|
app = typer.Typer(
|
|
name="commonwealth",
|
|
help="Commonwealth Online Server CLI",
|
|
pretty_exceptions_enable=False,
|
|
)
|
|
|
|
# Global service instance
|
|
_service: Optional[ServerService] = None
|
|
|
|
|
|
def get_service() -> ServerService:
|
|
"""Get or initialize the global service instance."""
|
|
global _service
|
|
if _service is None:
|
|
_service = ServerService()
|
|
return _service
|
|
|
|
|
|
def log_callback(message: str) -> None:
|
|
"""Callback for server logs from the service."""
|
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
console.print(f"[dim]{timestamp}[/dim] {message}")
|
|
|
|
|
|
def print_startup_banner(config: ServerConfig) -> None:
|
|
"""Print a friendly startup banner."""
|
|
banner = f"""
|
|
================================================================================
|
|
{config.server_name}
|
|
Commonwealth Online Consumer Server
|
|
================================================================================
|
|
|
|
Server Configuration:
|
|
• Binding to {config.host}:{config.port}
|
|
• Max players: {config.max_players}
|
|
• Discovery port (UDP): 7778
|
|
|
|
Connection Instructions:
|
|
• Local: 127.0.0.1:{config.port}
|
|
• LAN: <your-ip>:{config.port}
|
|
• Remote: Forward port {config.port}/TCP on your router
|
|
|
|
Management Commands:
|
|
• Check status: commonwealth status
|
|
• List clients: commonwealth clients
|
|
• Set time: commonwealth world time HHmm
|
|
• Set weather: commonwealth world weather FORM_ID
|
|
|
|
Shutdown:
|
|
• Press Ctrl+C to stop the server gracefully
|
|
================================================================================
|
|
"""
|
|
print(banner)
|
|
|
|
|
|
|
|
|
|
@app.command()
|
|
def serve(
|
|
config: Optional[str] = typer.Option(
|
|
None,
|
|
"--config",
|
|
"-c",
|
|
help="Path to config.json file",
|
|
),
|
|
host: Optional[str] = typer.Option(
|
|
None,
|
|
"--host",
|
|
"-H",
|
|
help="Server bind address (overrides config)",
|
|
),
|
|
port: Optional[int] = typer.Option(
|
|
None,
|
|
"--port",
|
|
"-p",
|
|
help="Server port (overrides config)",
|
|
),
|
|
) -> None:
|
|
"""Start the Commonwealth Online relay server."""
|
|
try:
|
|
# Load or create config
|
|
if config:
|
|
try:
|
|
cfg = load_config(config)
|
|
except FileNotFoundError:
|
|
console.print(f"[red]Error: Config file not found: {config}[/red]")
|
|
raise typer.Exit(code=1)
|
|
except json.JSONDecodeError as e:
|
|
console.print(f"[red]Error: Invalid JSON in config file: {e}[/red]")
|
|
raise typer.Exit(code=1)
|
|
else:
|
|
cfg = Config()
|
|
|
|
# Apply CLI overrides
|
|
if host:
|
|
cfg.host = host
|
|
if port:
|
|
cfg.port = port
|
|
|
|
# Validate config
|
|
is_valid, errors = validate_config(cfg)
|
|
if not is_valid:
|
|
console.print("[red]Configuration validation failed:[/red]")
|
|
for error in errors:
|
|
console.print(f" • {error}")
|
|
raise typer.Exit(code=1)
|
|
|
|
# Create and configure service
|
|
server_config = ServerConfig(
|
|
host=cfg.host,
|
|
port=cfg.port,
|
|
server_name=cfg.server_name,
|
|
max_players=cfg.max_players,
|
|
log_verbosity=cfg.log_verbosity,
|
|
)
|
|
|
|
service = get_service()
|
|
service.config = server_config
|
|
service.add_log_listener(log_callback)
|
|
|
|
# Print startup banner
|
|
print_startup_banner(server_config)
|
|
|
|
# Start server (blocks until shutdown)
|
|
console.print("[yellow]Starting server...[/yellow]")
|
|
service.serve_forever()
|
|
|
|
except KeyboardInterrupt:
|
|
console.print("\n[yellow]Shutdown signal received. Stopping server...[/yellow]")
|
|
service = get_service()
|
|
service.stop()
|
|
console.print("[green]Server stopped gracefully.[/green]")
|
|
except Exception as e:
|
|
console.print(f"[red]Fatal error: {e}[/red]")
|
|
raise typer.Exit(code=1)
|
|
|
|
|
|
@app.command()
|
|
def status(
|
|
json_output: bool = typer.Option(
|
|
False,
|
|
"--json",
|
|
"-j",
|
|
help="Output as JSON",
|
|
),
|
|
) -> None:
|
|
"""Display server status and statistics."""
|
|
service = get_service()
|
|
stats = service.get_stats()
|
|
|
|
if json_output:
|
|
print(service.stats_to_json(stats))
|
|
return
|
|
|
|
# Pretty table output
|
|
status_text = "[green]RUNNING[/green]" if stats.is_running else "[red]STOPPED[/red]"
|
|
uptime_text = _format_uptime(stats.uptime_seconds)
|
|
|
|
info_panel = f"""
|
|
[bold]Server Status[/bold]
|
|
|
|
Status: {status_text}
|
|
Address: [bright_white]{stats.host}:{stats.port}[/bright_white]
|
|
Uptime: [bold]{uptime_text}[/bold]
|
|
Clients: [bold]{stats.connected_clients}[/bold]
|
|
|
|
[bold]Packet Statistics[/bold]
|
|
|
|
Transform Packets: Received {stats.transform_packets_received:,} | Broadcast {stats.transform_packets_broadcast:,}
|
|
WorldState Packets: Received {stats.world_state_packets_received:,} | Broadcast {stats.world_state_packets_broadcast:,}
|
|
Total Packets: Received {stats.packets_received:,} | Sent {stats.packets_sent:,}
|
|
"""
|
|
|
|
console.print(Panel(info_panel.strip(), border_style="cyan", box=box.ROUNDED))
|
|
|
|
|
|
@app.command()
|
|
def clients(
|
|
json_output: bool = typer.Option(
|
|
False,
|
|
"--json",
|
|
"-j",
|
|
help="Output as JSON",
|
|
),
|
|
) -> None:
|
|
"""List connected clients and their statistics."""
|
|
service = get_service()
|
|
stats = service.get_stats()
|
|
|
|
if json_output:
|
|
data = {
|
|
"total_clients": len(stats.clients),
|
|
"clients": [
|
|
{
|
|
"player_id": c.player_id,
|
|
"address": c.address,
|
|
"label": c.label,
|
|
"connected_at": c.connected_at,
|
|
"packets_sent": c.packets_sent,
|
|
"packets_received": c.packets_received,
|
|
}
|
|
for c in stats.clients
|
|
],
|
|
}
|
|
print(json.dumps(data, indent=2))
|
|
return
|
|
|
|
if not stats.clients:
|
|
console.print("[dim]No clients currently connected.[/dim]")
|
|
return
|
|
|
|
# Create table
|
|
table = Table(title="Connected Clients", box=box.ROUNDED)
|
|
table.add_column("Player ID", style="cyan")
|
|
table.add_column("Address", style="bright_white")
|
|
table.add_column("Label", style="yellow")
|
|
table.add_column("Connected", style="green")
|
|
table.add_column("Packets Sent", justify="right")
|
|
table.add_column("Packets Received", justify="right")
|
|
|
|
for client in stats.clients:
|
|
connected_time = datetime.fromtimestamp(client.connected_at).strftime("%H:%M:%S")
|
|
table.add_row(
|
|
str(client.player_id),
|
|
client.address,
|
|
client.label,
|
|
connected_time,
|
|
str(client.packets_sent),
|
|
str(client.packets_received),
|
|
)
|
|
|
|
console.print(table)
|
|
|
|
|
|
world_app = typer.Typer(help="Manage world state (time, weather)")
|
|
|
|
|
|
@world_app.command("time")
|
|
def world_time(
|
|
hhmm: str = typer.Argument(..., help="Time in HHmm format (e.g., 1430 for 14:30)"),
|
|
) -> None:
|
|
"""Set server time (broadcast to all clients)."""
|
|
service = get_service()
|
|
|
|
if not service.is_running():
|
|
console.print("[red]Error: Server is not running.[/red]")
|
|
raise typer.Exit(code=1)
|
|
|
|
# Validate format
|
|
if not (len(hhmm) <= 4 and hhmm.isdigit()):
|
|
console.print(f"[red]Error: Time must be in HHmm format (e.g., 1430). Got: {hhmm}[/red]")
|
|
raise typer.Exit(code=1)
|
|
|
|
success, message = service.set_server_time(hhmm)
|
|
|
|
if success:
|
|
console.print(f"[green]{message}[/green]")
|
|
else:
|
|
console.print(f"[red]{message}[/red]")
|
|
raise typer.Exit(code=1)
|
|
|
|
|
|
@world_app.command("weather")
|
|
def world_weather(
|
|
form_id: str = typer.Argument(..., help="8-digit hex form ID (e.g., 0002b52a)"),
|
|
) -> None:
|
|
"""Set server weather (broadcast to all clients)."""
|
|
service = get_service()
|
|
|
|
if not service.is_running():
|
|
console.print("[red]Error: Server is not running.[/red]")
|
|
raise typer.Exit(code=1)
|
|
|
|
success, message = service.set_server_weather(form_id)
|
|
|
|
if success:
|
|
console.print(f"[green]{message}[/green]")
|
|
else:
|
|
console.print(f"[red]{message}[/red]")
|
|
raise typer.Exit(code=1)
|
|
|
|
|
|
app.add_typer(world_app, name="world")
|
|
|
|
|
|
config_app = typer.Typer(help="Manage server configuration")
|
|
|
|
|
|
@config_app.command("init")
|
|
def config_init(
|
|
output_path: str = typer.Argument(
|
|
"commonwealth-server.json",
|
|
help="Path where config file will be created",
|
|
),
|
|
) -> None:
|
|
"""Generate a default configuration file."""
|
|
path = Path(output_path)
|
|
|
|
if path.exists():
|
|
console.print(f"[yellow]File already exists: {output_path}[/yellow]")
|
|
if typer.confirm("Overwrite?"):
|
|
generate_default_config(str(path))
|
|
console.print(f"[green]Config written to {output_path}[/green]")
|
|
else:
|
|
console.print("[dim]Cancelled.[/dim]")
|
|
else:
|
|
generate_default_config(str(path))
|
|
console.print(f"[green]Config written to {output_path}[/green]")
|
|
console.print(f"\n[yellow]To start the server with this config:[/yellow]")
|
|
console.print(f" commonwealth serve --config {output_path}")
|
|
|
|
|
|
app.add_typer(config_app, name="config")
|
|
|
|
|
|
def _format_uptime(seconds: float) -> str:
|
|
"""Format uptime as human-readable string."""
|
|
if seconds < 60:
|
|
return f"{int(seconds)}s"
|
|
elif seconds < 3600:
|
|
minutes = int(seconds / 60)
|
|
secs = int(seconds % 60)
|
|
return f"{minutes}m {secs}s"
|
|
else:
|
|
hours = int(seconds / 3600)
|
|
minutes = int((seconds % 3600) / 60)
|
|
return f"{hours}h {minutes}m"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app()
|