From 4ea5419d30f1dcdab0db1d4fdd25cca69cc5381b Mon Sep 17 00:00:00 2001 From: Jage9 Date: Tue, 24 Feb 2026 21:01:21 -0500 Subject: [PATCH] Fix position desync causing item interaction failures --- client/public/version.js | 2 +- client/src/main.ts | 3 +- client/src/network/messageHandlers.ts | 5 ++ client/src/network/protocol.ts | 2 +- server/app/models.py | 2 + server/app/server.py | 43 +++++++++++++++ server/tests/test_server_message_handling.py | 57 +++++++++++++++++--- 7 files changed, 103 insertions(+), 11 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index 2773708..d81e8fe 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 R238"; +window.CHGRID_WEB_VERSION = "2026.02.25 R239"; // 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 30b9008..e225ecd 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1094,8 +1094,7 @@ function updateTeleport(): void { const completionStatus = activeTeleport.completionStatus; state.player.x = activeTeleport.targetX; state.player.y = activeTeleport.targetY; - signaling.send({ type: 'update_position', x: activeTeleport.targetX, y: activeTeleport.targetY }); - signaling.send({ type: 'teleport_complete' }); + signaling.send({ type: 'teleport_complete', x: activeTeleport.targetX, y: activeTeleport.targetY }); activeTeleport = null; stopTeleportLoopAudio(); persistPlayerPosition(); diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index fa75c4c..e3f24d0 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -140,6 +140,11 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco } case 'update_position': { + if (message.id === deps.state.player.id) { + deps.state.player.x = message.x; + deps.state.player.y = message.y; + break; + } const peer = deps.state.peers.get(message.id); const prevX = peer?.x ?? message.x; const prevY = peer?.y ?? message.y; diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index dc8e4b2..620583b 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -225,7 +225,7 @@ export type IncomingMessage = z.infer; export type OutgoingMessage = | { type: 'signal'; targetId: string; sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit } | { type: 'update_position'; x: number; y: number } - | { type: 'teleport_complete' } + | { type: 'teleport_complete'; x: number; y: number } | { type: 'update_nickname'; nickname: string } | { type: 'chat_message'; message: string } | { type: 'ping'; clientSentAt: number } diff --git a/server/app/models.py b/server/app/models.py index b06a601..e6d87ef 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -27,6 +27,8 @@ class UpdatePositionPacket(BasePacket): class TeleportCompletePacket(BasePacket): type: Literal["teleport_complete"] + x: int + y: int class UpdateNicknamePacket(BasePacket): diff --git a/server/app/server.py b/server/app/server.py index 74f926d..7703af5 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -849,6 +849,10 @@ class SignalingServer: packet.y, self.grid_size, ) + await self._send( + client.websocket, + BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y), + ) return now_ms = self.item_service.now_ms() requested_delta = max(abs(packet.x - client.x), abs(packet.y - client.y)) @@ -865,10 +869,18 @@ class SignalingServer: remaining, client.movement_window_index, ) + await self._send( + client.websocket, + BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y), + ) return client.x = packet.x client.y = packet.y client.last_position_update_ms = now_ms + await self._send( + client.websocket, + BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y), + ) await self._broadcast( BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y), exclude=client.websocket, @@ -882,6 +894,37 @@ class SignalingServer: return if isinstance(packet, TeleportCompletePacket): + if not self._is_in_bounds(packet.x, packet.y): + PACKET_LOGGER.warning( + "out-of-bounds teleport ignored id=%s x=%d y=%d grid_size=%d", + client.id, + packet.x, + packet.y, + self.grid_size, + ) + await self._send( + client.websocket, + BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y), + ) + return + + client.x = packet.x + client.y = packet.y + client.last_position_update_ms = self.item_service.now_ms() + await self._send( + client.websocket, + BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y), + ) + await self._broadcast( + BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y), + exclude=client.websocket, + ) + carried = self.item_service.find_carried_item(client.id) + if carried: + carried.x = client.x + carried.y = client.y + carried.updatedAt = self.item_service.now_ms() + await self._broadcast_item(carried) await self._broadcast( BroadcastTeleportCompletePacket( type="teleport_complete", diff --git a/server/tests/test_server_message_handling.py b/server/tests/test_server_message_handling.py index 1194565..6b2839a 100644 --- a/server/tests/test_server_message_handling.py +++ b/server/tests/test_server_message_handling.py @@ -149,13 +149,56 @@ async def test_teleport_complete_broadcasts_spatial_event(monkeypatch: pytest.Mo async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: broadcast_payloads.append(packet) + async def fake_send(websocket: ServerConnection, packet: object) -> None: + return None + + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + monkeypatch.setattr(server, "_send", fake_send) + + await server._handle_message(client, json.dumps({"type": "teleport_complete", "x": 12, "y": 13})) + + assert len(broadcast_payloads) == 2 + assert broadcast_payloads[0].type == "update_position" + assert broadcast_payloads[0].id == "u1" + assert broadcast_payloads[0].x == 12 + assert broadcast_payloads[0].y == 13 + assert broadcast_payloads[1].type == "teleport_complete" + assert broadcast_payloads[1].id == "u1" + assert broadcast_payloads[1].x == 12 + assert broadcast_payloads[1].y == 13 + + +@pytest.mark.asyncio +async def test_update_position_rate_reject_sends_self_correction(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41) + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=5) + server.clients[ws] = client + server.movement_tick_ms = 100 + server.movement_max_steps_per_tick = 1 + + fixed_now = 10_000 + monkeypatch.setattr(server.item_service, "now_ms", lambda: fixed_now) + + send_payloads: list[object] = [] + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + send_payloads.append(packet) + + async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: + return None + + monkeypatch.setattr(server, "_send", fake_send) monkeypatch.setattr(server, "_broadcast", fake_broadcast) - await server._handle_message(client, json.dumps({"type": "teleport_complete"})) + # 2-tile move exceeds per-window budget and should be rejected with correction. + await server._handle_message(client, json.dumps({"type": "update_position", "x": 7, "y": 5})) - assert len(broadcast_payloads) == 1 - packet = broadcast_payloads[0] - assert packet.type == "teleport_complete" - assert packet.id == "u1" - assert packet.x == 12 - assert packet.y == 13 + assert client.x == 5 + assert client.y == 5 + assert send_payloads + correction = send_payloads[-1] + assert correction.type == "update_position" + assert correction.id == "u1" + assert correction.x == 5 + assert correction.y == 5