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>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Configuration model and loading for Commonwealth Online server.
|
||||
|
||||
Supports JSON-based configuration files for hosted deployment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Server configuration model."""
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 7777
|
||||
server_name: str = "Commonwealth Online Server"
|
||||
max_players: int = 16
|
||||
log_verbosity: str = "info"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert config to dictionary."""
|
||||
return {
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"server_name": self.server_name,
|
||||
"max_players": self.max_players,
|
||||
"log_verbosity": self.log_verbosity,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> Config:
|
||||
"""Create config from dictionary."""
|
||||
return cls(
|
||||
host=data.get("host", "0.0.0.0"),
|
||||
port=int(data.get("port", 7777)),
|
||||
server_name=data.get("server_name", "Commonwealth Online Server"),
|
||||
max_players=int(data.get("max_players", 16)),
|
||||
log_verbosity=data.get("log_verbosity", "info"),
|
||||
)
|
||||
|
||||
|
||||
def load_config(config_path: str | None = None) -> Config:
|
||||
"""
|
||||
Load configuration from file or use defaults.
|
||||
|
||||
Args:
|
||||
config_path: Path to config.json file. If None, returns defaults.
|
||||
|
||||
Returns:
|
||||
Config instance.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config_path is provided but file does not exist.
|
||||
json.JSONDecodeError: If config file is invalid JSON.
|
||||
"""
|
||||
if config_path is None:
|
||||
return Config()
|
||||
|
||||
path = Path(config_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||||
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Config file must contain a JSON object at root level.")
|
||||
|
||||
return Config.from_dict(data)
|
||||
|
||||
|
||||
def save_config(config: Config, config_path: str) -> None:
|
||||
"""
|
||||
Save configuration to a JSON file.
|
||||
|
||||
Args:
|
||||
config: Config instance to save.
|
||||
config_path: Path to write config.json.
|
||||
"""
|
||||
path = Path(config_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(path, "w") as f:
|
||||
json.dump(config.to_dict(), f, indent=2)
|
||||
|
||||
|
||||
def generate_default_config(config_path: str) -> Config:
|
||||
"""
|
||||
Generate and save a default configuration file.
|
||||
|
||||
Args:
|
||||
config_path: Path where default config.json will be written.
|
||||
|
||||
Returns:
|
||||
The generated Config instance.
|
||||
"""
|
||||
config = Config()
|
||||
save_config(config, config_path)
|
||||
return config
|
||||
|
||||
|
||||
def validate_config(config: Config) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Validate configuration values.
|
||||
|
||||
Args:
|
||||
config: Config to validate.
|
||||
|
||||
Returns:
|
||||
(is_valid, list_of_errors). Empty list if valid.
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if not config.host:
|
||||
errors.append("host cannot be empty")
|
||||
|
||||
if config.port < 1 or config.port > 65535:
|
||||
errors.append(f"port must be 1-65535, got {config.port}")
|
||||
|
||||
if not config.server_name:
|
||||
errors.append("server_name cannot be empty")
|
||||
|
||||
if config.max_players < 1:
|
||||
errors.append(f"max_players must be >= 1, got {config.max_players}")
|
||||
|
||||
if config.log_verbosity not in ("debug", "info", "warning", "error"):
|
||||
errors.append(f"log_verbosity must be debug/info/warning/error, got {config.log_verbosity}")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
Reference in New Issue
Block a user