Make spawn and movement acceptance server-authoritative

This commit is contained in:
Jage9
2026-02-24 19:52:38 -05:00
parent a588148039
commit 7488ac9f67
12 changed files with 78 additions and 29 deletions

View File

@@ -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."""

View File

@@ -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):

View File

@@ -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

View File

@@ -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,
)