Move help to JSON and add server docstrings
This commit is contained in:
@@ -31,45 +31,7 @@
|
||||
class="hidden"
|
||||
aria-label="Chat Grid, press arrows to move."
|
||||
></canvas>
|
||||
<div id="instructions" class="hidden">
|
||||
<h2>Help</h2>
|
||||
|
||||
<h3>Movement</h3>
|
||||
<p><b>Arrow Keys:</b> Move</p>
|
||||
<p><b>C:</b> Speak coordinates</p>
|
||||
<p><b>Escape:</b> Disconnect/cancel</p>
|
||||
|
||||
<h3>Users, Nickname, and Chat</h3>
|
||||
<p><b>L:</b> Locate nearest user</p>
|
||||
<p><b>Shift+L:</b> List users</p>
|
||||
<p><b>Shift+U:</b> Speak connected users</p>
|
||||
<p><b>N:</b> Change nickname</p>
|
||||
<p><b>Slash:</b> Start chat</p>
|
||||
<p><b>Comma / Period:</b> Previous/next message</p>
|
||||
<p><b>Less Than / Greater Than:</b> First/last message</p>
|
||||
|
||||
<h3>Items</h3>
|
||||
<p><b>I:</b> Locate nearest item</p>
|
||||
<p><b>Shift+I:</b> List items</p>
|
||||
<p><b>A:</b> Add item</p>
|
||||
<p><b>O:</b> Edit item properties</p>
|
||||
<p><b>Shift+O:</b> Read all item properties</p>
|
||||
<p><b>D:</b> Pick up/drop item</p>
|
||||
<p><b>Shift+D:</b> Delete item</p>
|
||||
<p><b>U:</b> Use item</p>
|
||||
|
||||
<h3>Audio</h3>
|
||||
<p><b>P:</b> Ping server</p>
|
||||
<p><b>M:</b> Mute/unmute yourself</p>
|
||||
<p><b>Shift+M:</b> Toggle stereo/mono output</p>
|
||||
<p><b>! (Shift+1):</b> Toggle loopback monitor</p>
|
||||
<p><b>1:</b> Toggle voice layer</p>
|
||||
<p><b>2:</b> Toggle item sounds layer</p>
|
||||
<p><b>3:</b> Toggle media layer</p>
|
||||
<p><b>4:</b> Toggle world layer (other users)</p>
|
||||
<p><b>E:</b> Select voice effect</p>
|
||||
<p><b>Dash or Equals:</b> Lower/raise active effect value</p>
|
||||
</div>
|
||||
<div id="instructions" class="hidden"></div>
|
||||
|
||||
<footer id="appFooter">
|
||||
<small id="appVersion">Another AI experiment with Jage. Version</small>
|
||||
|
||||
52
client/public/help.json
Normal file
52
client/public/help.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"sections": [
|
||||
{
|
||||
"title": "Movement",
|
||||
"items": [
|
||||
{ "keys": "Arrow Keys", "description": "Move" },
|
||||
{ "keys": "C", "description": "Speak coordinates" },
|
||||
{ "keys": "Escape", "description": "Disconnect/cancel" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Users, Nickname, and Chat",
|
||||
"items": [
|
||||
{ "keys": "L", "description": "Locate nearest user" },
|
||||
{ "keys": "Shift+L", "description": "List users" },
|
||||
{ "keys": "Shift+U", "description": "Speak connected users" },
|
||||
{ "keys": "N", "description": "Change nickname" },
|
||||
{ "keys": "Slash", "description": "Start chat" },
|
||||
{ "keys": "Comma / Period", "description": "Previous/next message" },
|
||||
{ "keys": "Less Than / Greater Than", "description": "First/last message" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Items",
|
||||
"items": [
|
||||
{ "keys": "I", "description": "Locate nearest item" },
|
||||
{ "keys": "Shift+I", "description": "List items" },
|
||||
{ "keys": "A", "description": "Add item" },
|
||||
{ "keys": "O", "description": "Edit item properties" },
|
||||
{ "keys": "Shift+O", "description": "Read all item properties" },
|
||||
{ "keys": "D", "description": "Pick up/drop item" },
|
||||
{ "keys": "Shift+D", "description": "Delete item" },
|
||||
{ "keys": "U", "description": "Use item" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Audio",
|
||||
"items": [
|
||||
{ "keys": "P", "description": "Ping server" },
|
||||
{ "keys": "M", "description": "Mute/unmute yourself" },
|
||||
{ "keys": "Shift+M", "description": "Toggle stereo/mono output" },
|
||||
{ "keys": "! (Shift+1)", "description": "Toggle loopback monitor" },
|
||||
{ "keys": "1", "description": "Toggle voice layer" },
|
||||
{ "keys": "2", "description": "Toggle item sounds layer" },
|
||||
{ "keys": "3", "description": "Toggle media layer" },
|
||||
{ "keys": "4", "description": "Toggle world layer (other users)" },
|
||||
{ "keys": "E", "description": "Select voice effect" },
|
||||
{ "keys": "Dash or Equals", "description": "Lower/raise active effect value" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Maintainer-controlled web client version.
|
||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
||||
window.CHGRID_WEB_VERSION = "2026.02.21 R104";
|
||||
window.CHGRID_WEB_VERSION = "2026.02.21 R105";
|
||||
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||
|
||||
@@ -105,6 +105,20 @@ type ChangelogData = {
|
||||
sections: ChangelogSection[];
|
||||
};
|
||||
|
||||
type HelpItem = {
|
||||
keys: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type HelpSection = {
|
||||
title: string;
|
||||
items: HelpItem[];
|
||||
};
|
||||
|
||||
type HelpData = {
|
||||
sections: HelpSection[];
|
||||
};
|
||||
|
||||
type AudioLayerState = {
|
||||
voice: boolean;
|
||||
item: boolean;
|
||||
@@ -245,6 +259,7 @@ audio.setOutputMode(outputMode);
|
||||
|
||||
loadEffectLevels();
|
||||
loadAudioLayerState();
|
||||
void loadHelp();
|
||||
void loadChangelog();
|
||||
|
||||
function requiredById<T extends HTMLElement>(id: string): T {
|
||||
@@ -297,6 +312,42 @@ function setUpdatesExpanded(expanded: boolean): void {
|
||||
dom.updatesPanel.classList.toggle('hidden', !expanded);
|
||||
}
|
||||
|
||||
function renderHelp(help: HelpData): void {
|
||||
dom.instructions.innerHTML = '';
|
||||
const heading = document.createElement('h2');
|
||||
heading.textContent = 'Help';
|
||||
dom.instructions.appendChild(heading);
|
||||
for (const section of help.sections) {
|
||||
const sectionHeading = document.createElement('h3');
|
||||
sectionHeading.textContent = section.title;
|
||||
dom.instructions.appendChild(sectionHeading);
|
||||
for (const item of section.items) {
|
||||
const line = document.createElement('p');
|
||||
const keys = document.createElement('b');
|
||||
keys.textContent = `${item.keys}:`;
|
||||
line.appendChild(keys);
|
||||
line.append(` ${item.description}`);
|
||||
dom.instructions.appendChild(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHelp(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(withBase('help.json'), { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const help = (await response.json()) as HelpData;
|
||||
if (!Array.isArray(help.sections) || help.sections.length === 0) {
|
||||
return;
|
||||
}
|
||||
renderHelp(help);
|
||||
} catch {
|
||||
// Keep existing/static help if loading fails.
|
||||
}
|
||||
}
|
||||
|
||||
function renderChangelog(changelog: ChangelogData): void {
|
||||
dom.updatesPanel.innerHTML = '';
|
||||
for (const section of changelog.sections) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Pydantic packet and entity models shared across server message handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Executable wrapper for running the signaling server."""
|
||||
|
||||
from app.server import run
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user