diff --git a/client/public/version.js b/client/public/version.js index 4a848ff..b3f58fa 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -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.25 R230"; +window.CHGRID_WEB_VERSION = "2026.02.25 R231"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/main.ts b/client/src/main.ts index c2562a5..ebe5ecf 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -196,6 +196,7 @@ const renderer = new CanvasRenderer(dom.canvas); const audio = new AudioEngine(); const settings = new SettingsStore(); let worldGridSize = GRID_SIZE; +let movementTickMs = MOVE_COOLDOWN_MS; let lastWallCollisionDirection: string | null = null; let statusTimeout: number | null = null; let lastFocusedElement: Element | null = null; @@ -1122,7 +1123,7 @@ function handleMovement(): void { if (state.mode !== 'normal') return; if (activeTeleport) return; const now = Date.now(); - if (now - state.player.lastMoveTime < MOVE_COOLDOWN_MS) return; + if (now - state.player.lastMoveTime < movementTickMs) return; let dx = 0; let dy = 0; @@ -1154,7 +1155,7 @@ function handleMovement(): void { persistPlayerPosition(); state.player.lastMoveTime = now; void refreshAudioSubscriptions(true); - void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN, MOVE_COOLDOWN_MS); + void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN, movementTickMs); signaling.send({ type: 'update_position', x: nextX, y: nextY }); const namesOnTile = getPeerNamesAtPosition(nextX, nextY); @@ -1307,7 +1308,6 @@ function getConnectionFlowDeps(): ConnectFlowDeps { signalingConnect: (handler) => signaling.connect(handler as (message: IncomingMessage) => Promise), signalingDisconnect: () => signaling.disconnect(), onMessage: (message) => onSignalingMessage(message as IncomingMessage), - worldGridSize, persistPlayerPosition, peerManagerCleanupAll: () => peerManager.cleanupAll(), radioCleanupAll: () => radioRuntime.cleanupAll(), @@ -1346,6 +1346,9 @@ const onAppMessage = createOnMessageHandler({ setWorldGridSize: (size) => { worldGridSize = size; }, + setMovementTickMs: (value) => { + movementTickMs = Math.max(1, value); + }, setConnecting: (value) => { mediaSession.setConnecting(value); updateConnectAvailability(); diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index 240f14c..7df4261 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -7,6 +7,7 @@ import { type WorldItem } from '../state/gameState'; type MessageHandlerDeps = { getWorldGridSize: () => number; setWorldGridSize: (size: number) => void; + setMovementTickMs: (value: number) => void; setConnecting: (value: boolean) => void; rendererSetGridSize: (size: number) => void; applyServerItemUiDefinitions: (defs: unknown) => boolean; @@ -82,6 +83,9 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) { deps.setWorldGridSize(message.worldConfig.gridSize); } + if (message.worldConfig?.movementTickMs && Number.isInteger(message.worldConfig.movementTickMs) && message.worldConfig.movementTickMs > 0) { + deps.setMovementTickMs(message.worldConfig.movementTickMs); + } deps.rendererSetGridSize(deps.getWorldGridSize()); const schemaReady = deps.applyServerItemUiDefinitions(message.uiDefinitions); if (!schemaReady) { @@ -92,8 +96,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco deps.state.player.id = message.id; deps.state.running = true; deps.setConnecting(false); - deps.state.player.x = Math.max(0, Math.min(deps.getWorldGridSize() - 1, deps.state.player.x)); - deps.state.player.y = Math.max(0, Math.min(deps.getWorldGridSize() - 1, deps.state.player.y)); + deps.state.player.x = Math.max(0, Math.min(deps.getWorldGridSize() - 1, message.player.x)); + deps.state.player.y = Math.max(0, Math.min(deps.getWorldGridSize() - 1, message.player.y)); deps.dom.nicknameContainer.classList.add('hidden'); deps.dom.connectButton.classList.add('hidden'); deps.dom.disconnectButton.classList.remove('hidden'); diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 02919cd..2e10186 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -20,6 +20,12 @@ export const itemSchema = z.object({ export const welcomeMessageSchema = z.object({ type: z.literal('welcome'), id: z.string(), + player: z.object({ + id: z.string(), + nickname: z.string(), + x: z.number().int(), + y: z.number().int(), + }), users: z.array( z.object({ id: z.string(), @@ -32,6 +38,8 @@ export const welcomeMessageSchema = z.object({ worldConfig: z .object({ gridSize: z.number().int().positive(), + movementTickMs: z.number().int().positive().optional(), + movementMaxStepsPerTick: z.number().int().positive().optional(), }) .optional(), serverInfo: z diff --git a/client/src/session/connectionFlow.ts b/client/src/session/connectionFlow.ts index 9e836cb..577f9af 100644 --- a/client/src/session/connectionFlow.ts +++ b/client/src/session/connectionFlow.ts @@ -31,7 +31,6 @@ export type ConnectFlowDeps = { signalingConnect: (onMessage: (message: unknown) => Promise) => Promise; signalingDisconnect: () => void; onMessage: (message: unknown) => Promise; - worldGridSize: number; persistPlayerPosition: () => void; peerManagerCleanupAll: () => void; radioCleanupAll: () => void; @@ -66,25 +65,6 @@ export async function runConnectFlow(deps: ConnectFlowDeps): Promise { return; } - deps.state.player.x = Math.floor(Math.random() * deps.worldGridSize); - deps.state.player.y = Math.floor(Math.random() * deps.worldGridSize); - const storedPosition = localStorage.getItem('spatialChatPosition'); - if (storedPosition) { - try { - const parsed = JSON.parse(storedPosition) as { x?: number; y?: number }; - if (Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) { - const x = Math.floor(parsed.x as number); - const y = Math.floor(parsed.y as number); - if (x >= 0 && x < deps.worldGridSize && y >= 0 && y < deps.worldGridSize) { - deps.state.player.x = x; - deps.state.player.y = y; - } - } - } catch { - // Ignore malformed saved positions. - } - } - try { await deps.mediaPopulateAudioDevices(); if (deps.dom.audioInputSelect.options.length === 0) { diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index fe128f9..f1ec0a1 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -10,7 +10,7 @@ This is a behavior guide for packet semantics beyond raw schemas. ## Client -> Server -- `update_position`: authoritative player position update. +- `update_position`: client movement intent; server enforces world bounds and movement rate policy. - `update_nickname`: nickname change request (server enforces uniqueness). - `chat_message`: player chat. - `ping`: latency measurement. @@ -45,6 +45,9 @@ This is a behavior guide for packet semantics beyond raw schemas. ## Welcome Metadata - `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing. +- `welcome.worldConfig.movementTickMs`: server movement-rate window used for client movement pacing. +- `welcome.worldConfig.movementMaxStepsPerTick`: max allowed grid steps per movement window. +- `welcome.player`: server-assigned spawn/current self position at connect time. - `welcome.serverInfo`: server process identity/version metadata: - `instanceId`: unique id generated at server startup - `version`: server package version (or `unknown` fallback) @@ -60,6 +63,7 @@ This is a behavior guide for packet semantics beyond raw schemas. ## Validation Boundaries - Server is authoritative for all action validation and normalization. +- Server is authoritative for movement acceptance (bounds + rate/delta checks). - Client validates incoming packet shapes and applies runtime behavior. - Client-side item edit validation is convenience only; server remains source of truth. diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 1112f21..ffbd2a0 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -8,10 +8,13 @@ 4. Server sends `welcome` with users/items snapshot. 5. Client: - applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering + - applies `welcome.worldConfig.movementTickMs` as movement pacing guidance + - applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity + - uses `welcome.player` as authoritative starting position - records `welcome.serverInfo` (`instanceId`, `version`) for restart detection - if `welcome.serverInfo.version` differs from running client version, auto-reloads the page - applies `welcome.uiDefinitions` for item menus/properties/options - - sends initial `update_position` + - sends initial `update_position` echo from server-assigned starting tile - sends initial `update_nickname` - creates peer runtimes for known users - syncs item runtimes (`radio`, `emit`) @@ -24,6 +27,7 @@ Each frame: - Handle local movement input. +- Send movement intents; server remains authoritative on accepted movement updates. - Update spatial voice audio. - Update spatial radio audio. - Update spatial item emit audio. diff --git a/server/app/client.py b/server/app/client.py index 84c379a..5ebb60e 100644 --- a/server/app/client.py +++ b/server/app/client.py @@ -16,6 +16,7 @@ class ClientConnection: nickname: str = "user..." x: int = 20 y: int = 20 + last_position_update_ms: int = 0 def summary(self) -> dict[str, str | int]: """Return a compact serializable snapshot for logs/diagnostics.""" diff --git a/server/app/config.py b/server/app/config.py index b814114..726cb6b 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -47,6 +47,8 @@ class WorldConfigSection(BaseModel): """Authoritative world geometry options.""" grid_size: int = Field(default=41, ge=1) + movement_tick_ms: int = Field(default=100, ge=1) + movement_max_steps_per_tick: int = Field(default=2, ge=1) class AppConfig(BaseModel): diff --git a/server/app/models.py b/server/app/models.py index a6b601b..57b08fb 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -115,6 +115,7 @@ class RemoteUser(BaseModel): class WelcomePacket(BasePacket): type: Literal["welcome"] id: str + player: RemoteUser users: list[RemoteUser] items: list[dict] | None = None worldConfig: dict | None = None diff --git a/server/app/server.py b/server/app/server.py index 515dee3..328bfa0 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -9,6 +9,7 @@ from importlib.metadata import PackageNotFoundError, version as package_version import json import logging import os +import random import re import ssl import time @@ -88,6 +89,8 @@ class SignalingServer: max_message_size: int = 2_000_000, state_file: Path | None = None, grid_size: int = 41, + movement_tick_ms: int = 100, + movement_max_steps_per_tick: int = 2, state_save_debounce_ms: int = 200, state_save_max_delay_ms: int = 1000, ): @@ -104,6 +107,8 @@ class SignalingServer: self.piano_recording_state_by_item: dict[str, dict] = {} self.piano_playback_tasks_by_item: dict[str, asyncio.Task[None]] = {} self.grid_size = max(1, grid_size) + self.movement_tick_ms = max(1, int(movement_tick_ms)) + self.movement_max_steps_per_tick = max(1, int(movement_max_steps_per_tick)) self.instance_id = str(uuid.uuid4()) self.server_version = self._resolve_server_version() self.state_save_debounce_ms = max(1, int(state_save_debounce_ms)) @@ -564,6 +569,13 @@ class SignalingServer: return 0 <= x < self.grid_size and 0 <= y < self.grid_size + def _max_allowed_position_delta(self, client: ClientConnection, now_ms: int) -> int: + """Compute max allowed movement delta using server-authoritative rate policy.""" + + elapsed_ms = max(0, now_ms - max(0, client.last_position_update_ms)) + windows = max(1, elapsed_ms // self.movement_tick_ms) + return max(1, windows * self.movement_max_steps_per_tick) + @staticmethod def _normalize_clock_timezone(value: object) -> str: """Normalize timezone input to one of supported clock zones.""" @@ -650,6 +662,9 @@ class SignalingServer: """Handle one websocket client's connect/message/disconnect lifecycle.""" client = ClientConnection(websocket=websocket, id=str(uuid.uuid4())) + client.x = random.randrange(self.grid_size) + client.y = random.randrange(self.grid_size) + client.last_position_update_ms = self.item_service.now_ms() self.clients[websocket] = client LOGGER.info("client connected id=%s total=%d", client.id, len(self.clients)) @@ -695,9 +710,14 @@ class SignalingServer: packet = WelcomePacket( type="welcome", id=client.id, + player=RemoteUser(id=client.id, nickname=client.nickname, x=client.x, y=client.y), users=users, items=[item.model_dump(exclude_none=True) for item in self.items.values()], - worldConfig={"gridSize": self.grid_size}, + worldConfig={ + "gridSize": self.grid_size, + "movementTickMs": self.movement_tick_ms, + "movementMaxStepsPerTick": self.movement_max_steps_per_tick, + }, uiDefinitions=self._build_ui_definitions(), serverInfo={"instanceId": self.instance_id, "version": self.server_version}, ) @@ -770,8 +790,24 @@ class SignalingServer: self.grid_size, ) return + now_ms = self.item_service.now_ms() + requested_delta = max(abs(packet.x - client.x), abs(packet.y - client.y)) + max_delta = self._max_allowed_position_delta(client, now_ms) + if requested_delta > max_delta: + PACKET_LOGGER.warning( + "position rate limit ignored id=%s from=%d,%d to=%d,%d requested_delta=%d max_delta=%d", + client.id, + client.x, + client.y, + packet.x, + packet.y, + requested_delta, + max_delta, + ) + return client.x = packet.x client.y = packet.y + client.last_position_update_ms = now_ms await self._broadcast( BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y), exclude=client.websocket, @@ -1345,6 +1381,8 @@ def run() -> None: max_message_size=config.network.max_message_bytes, state_file=state_file, grid_size=config.world.grid_size, + movement_tick_ms=config.world.movement_tick_ms, + movement_max_steps_per_tick=config.world.movement_max_steps_per_tick, state_save_debounce_ms=config.storage.state_save_debounce_ms, state_save_max_delay_ms=config.storage.state_save_max_delay_ms, ) diff --git a/server/config.example.toml b/server/config.example.toml index e4fa2fc..cb07e88 100644 --- a/server/config.example.toml +++ b/server/config.example.toml @@ -29,3 +29,7 @@ state_save_max_delay_ms = 1000 [world] # Grid width/height in cells. Valid coordinates are 0..grid_size-1. grid_size = 41 +# Server-authoritative movement rate window in milliseconds. +movement_tick_ms = 100 +# Max grid steps allowed per movement tick window. +movement_max_steps_per_tick = 2