Move help to JSON and add server docstrings

This commit is contained in:
Jage9
2026-02-21 16:51:07 -05:00
parent 68bd2cf2ce
commit 35f837e96d
11 changed files with 207 additions and 40 deletions

View File

@@ -1,3 +1,5 @@
"""Client connection model used by the signaling server."""
from __future__ import annotations
from dataclasses import dataclass
@@ -7,6 +9,8 @@ from websockets.asyncio.server import ServerConnection
@dataclass
class ClientConnection:
"""Represents one connected websocket client and its world state."""
websocket: ServerConnection
id: str
nickname: str = "user..."
@@ -14,4 +18,6 @@ class ClientConnection:
y: int = 20
def summary(self) -> dict[str, str | int]:
"""Return a compact serializable snapshot for logs/diagnostics."""
return {"id": self.id, "nickname": self.nickname, "x": self.x, "y": self.y}

View File

@@ -1,3 +1,5 @@
"""Configuration models and loader for the signaling server."""
from __future__ import annotations
from pathlib import Path
@@ -7,29 +9,41 @@ from pydantic import BaseModel, Field
class ServerConfigSection(BaseModel):
"""Bind address and port options for websocket serving."""
bind_ip: str = "127.0.0.1"
port: int = 8765
class NetworkConfigSection(BaseModel):
"""Network transport and safety limits."""
max_message_bytes: int = Field(default=2_000_000, gt=0)
allow_insecure_ws: bool = True
class TlsConfigSection(BaseModel):
"""TLS certificate/key file configuration."""
cert_file: str = ""
key_file: str = ""
class LoggingConfigSection(BaseModel):
"""Runtime logging verbosity options."""
level: str = "INFO"
class StorageConfigSection(BaseModel):
"""Persistent state file location."""
state_file: str = "runtime/items.json"
class AppConfig(BaseModel):
"""Top-level application configuration document."""
server: ServerConfigSection = ServerConfigSection()
network: NetworkConfigSection = NetworkConfigSection()
tls: TlsConfigSection = TlsConfigSection()
@@ -38,6 +52,8 @@ class AppConfig(BaseModel):
def load_config(path: Path | None) -> AppConfig:
"""Load and validate config TOML, applying defaults and TLS checks."""
if path is None:
return AppConfig()

View File

@@ -1,3 +1,5 @@
"""Server-side catalog of global item type definitions and defaults."""
from __future__ import annotations
from dataclasses import dataclass
@@ -51,6 +53,8 @@ CLOCK_TIME_ZONE_OPTIONS: tuple[str, ...] = (
@dataclass(frozen=True)
class ItemDefinition:
"""Global behavior and defaults shared by all instances of one item type."""
default_title: str
capabilities: tuple[str, ...]
use_sound: str | None
@@ -93,10 +97,14 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
def get_item_definition(item_type: ItemType) -> ItemDefinition:
"""Return catalog definition for a known item type."""
return ITEM_DEFINITIONS[item_type]
def get_item_use_cooldown_ms(item_type: ItemType) -> int:
"""Return validated global use cooldown in milliseconds for an item type."""
definition = get_item_definition(item_type)
cooldown_ms = definition.use_cooldown_ms
if isinstance(cooldown_ms, int) and cooldown_ms > 0:

View File

@@ -1,3 +1,5 @@
"""Item persistence, hydration, and local mutation helpers."""
from __future__ import annotations
import json
@@ -16,16 +18,24 @@ LOGGER = logging.getLogger("chgrid.server")
class ItemService:
"""Owns world-item storage, lifecycle, and persistence to disk."""
def __init__(self, state_file: Path | None = None):
"""Create service and eagerly load persisted state when configured."""
self.state_file = state_file
self.items: dict[str, WorldItem] = {}
self.load_state()
@staticmethod
def now_ms() -> int:
"""Return current Unix time in milliseconds."""
return int(time.time() * 1000)
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock"]) -> WorldItem:
"""Create a new server-authoritative item at the caller's position."""
item_def = get_item_definition(item_type)
now = self.now_ms()
return WorldItem(
@@ -46,22 +56,32 @@ class ItemService:
)
def add_item(self, item: WorldItem) -> None:
"""Insert or replace an item in in-memory state."""
self.items[item.id] = item
def remove_item(self, item_id: str) -> None:
"""Remove an item by id when present."""
if item_id in self.items:
del self.items[item_id]
def find_carried_item(self, client_id: str) -> WorldItem | None:
"""Return the currently carried item for a client, if any."""
for item in self.items.values():
if item.carrierId == client_id:
return item
return None
def items_on_square(self, x: int, y: int) -> list[WorldItem]:
"""Return non-carried items occupying a specific world coordinate."""
return [item for item in self.items.values() if item.carrierId is None and item.x == x and item.y == y]
def drop_carried_items_for_disconnect(self, client: ClientConnection) -> list[WorldItem]:
"""Drop all items carried by a disconnected client onto their last tile."""
changed: list[WorldItem] = []
for item in self.items.values():
if item.carrierId == client.id:
@@ -73,6 +93,8 @@ class ItemService:
return changed
def load_state(self) -> None:
"""Load persisted item instances and rehydrate global fields from catalog."""
if not self.state_file:
return
try:
@@ -108,6 +130,8 @@ class ItemService:
LOGGER.warning("failed to load persisted item state from %s: %s", self.state_file, exc)
def save_state(self) -> None:
"""Persist instance-only item data to configured state file."""
if not self.state_file:
return
try:

View File

@@ -1,3 +1,5 @@
"""Pydantic packet and entity models shared across server message handling."""
from __future__ import annotations
from typing import Literal

View File

@@ -1,3 +1,5 @@
"""Websocket signaling server for chat, presence, and item interactions."""
from __future__ import annotations
import argparse
@@ -55,6 +57,8 @@ RADIO_CHANNEL_IDS = {"stereo", "mono", "left", "right"}
class SignalingServer:
"""Coordinates websocket clients, signaling, and authoritative item actions."""
def __init__(
self,
host: str,
@@ -64,6 +68,8 @@ class SignalingServer:
max_message_size: int = 2_000_000,
state_file: Path | None = None,
):
"""Initialize runtime state, TLS context, and item service."""
self.host = host
self.port = port
self.max_message_size = max_message_size
@@ -74,12 +80,18 @@ class SignalingServer:
@property
def items(self) -> dict[str, WorldItem]:
"""Expose current item map owned by the item service."""
return self.item_service.items
def _nickname_key(self, nickname: str) -> str:
"""Normalize nickname for case-insensitive comparisons."""
return nickname.casefold()
def _is_nickname_taken(self, nickname: str, exclude_client_id: str | None = None) -> bool:
"""Check whether nickname is already used by another active client."""
wanted = self._nickname_key(nickname)
for other in self.clients.values():
if exclude_client_id is not None and other.id == exclude_client_id:
@@ -90,10 +102,14 @@ class SignalingServer:
@staticmethod
def _item_type_label(item: WorldItem) -> str:
"""Return user-facing item type wording for chat/status strings."""
return "radio" if item.type == "radio_station" else item.type
@staticmethod
def _normalize_clock_timezone(value: object) -> str:
"""Normalize timezone input to one of supported clock zones."""
token = str(value or "").strip()
if token in CLOCK_TIME_ZONE_OPTIONS:
return token
@@ -101,6 +117,8 @@ class SignalingServer:
@staticmethod
def _parse_clock_use_24_hour(value: object) -> bool | None:
"""Parse bool-like clock format values (`on/off`, `true/false`, etc.)."""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
@@ -115,6 +133,8 @@ class SignalingServer:
@classmethod
def _format_clock_display_time(cls, params: dict) -> str:
"""Render current clock text based on item timezone/format params."""
tz_name = cls._normalize_clock_timezone(params.get("timeZone"))
use_24_hour = cls._parse_clock_use_24_hour(params.get("use24Hour"))
if use_24_hour is None:
@@ -133,6 +153,8 @@ class SignalingServer:
message: str,
item_id: str | None = None,
) -> None:
"""Send a structured item action result to one client."""
await self._send(
client.websocket,
ItemActionResultPacket(
@@ -145,9 +167,13 @@ class SignalingServer:
)
async def _broadcast_item(self, item: WorldItem) -> None:
"""Broadcast a full item snapshot update to all connected clients."""
await self._broadcast(ItemUpsertPacket(type="item_upsert", item=item))
async def start(self) -> None:
"""Start websocket serving and run until cancelled."""
protocol = "wss" if self._ssl_context else "ws"
LOGGER.info("starting signaling server on %s://%s:%d", protocol, self.host, self.port)
async with serve(
@@ -160,6 +186,8 @@ class SignalingServer:
await asyncio.Future()
async def _handle_client(self, websocket: ServerConnection) -> None:
"""Handle one websocket client's connect/message/disconnect lifecycle."""
client = ClientConnection(websocket=websocket, id=str(uuid.uuid4()))
self.clients[websocket] = client
LOGGER.info("client connected id=%s total=%d", client.id, len(self.clients))
@@ -191,6 +219,8 @@ class SignalingServer:
)
async def _send_welcome(self, client: ClientConnection) -> None:
"""Send initial world snapshot to a newly connected client."""
users = [
RemoteUser(id=other.id, nickname=other.nickname, x=other.x, y=other.y)
for ws, other in self.clients.items()
@@ -211,6 +241,8 @@ class SignalingServer:
others_message: str,
delay_seconds: float = 3.0,
) -> None:
"""Delay then publish wheel result text to self and other users."""
await asyncio.sleep(delay_seconds)
await self._broadcast(
BroadcastChatMessagePacket(type="chat_message", message=others_message, system=True),
@@ -223,6 +255,8 @@ class SignalingServer:
)
async def _handle_message(self, client: ClientConnection, raw_message: str) -> None:
"""Decode, validate, and route one inbound client packet."""
try:
payload = json.loads(raw_message)
except json.JSONDecodeError:
@@ -754,12 +788,16 @@ class SignalingServer:
)
async def _broadcast(self, packet: object, exclude: ServerConnection | None = None) -> None:
"""Broadcast one packet to all clients except an optional websocket."""
for websocket in list(self.clients.keys()):
if websocket is exclude:
continue
await self._send(websocket, packet)
async def _send(self, websocket: ServerConnection, packet: object) -> None:
"""Send one packet to one websocket, swallowing per-client send failures."""
try:
if hasattr(packet, "model_dump"):
data = packet.model_dump(exclude_none=True)
@@ -770,6 +808,8 @@ class SignalingServer:
LOGGER.debug("send failure: %s", exc)
def _find_by_id(self, client_id: str) -> ClientConnection | None:
"""Resolve a client id to an active connection."""
for client in self.clients.values():
if client.id == client_id:
return client
@@ -777,6 +817,8 @@ class SignalingServer:
@staticmethod
def _build_ssl_context(cert: str | None, key: str | None) -> ssl.SSLContext | None:
"""Create TLS server context when cert/key are configured."""
if not cert or not key:
return None
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
@@ -785,6 +827,8 @@ class SignalingServer:
def run() -> None:
"""CLI entrypoint for running the signaling server process."""
parser = argparse.ArgumentParser(description="chgrid signaling server")
parser.add_argument("--config", default="config.toml")
parser.add_argument("--host", default=None)