diff --git a/client/public/version.js b/client/public/version.js index aec61c0..11a6eae 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.03.01 R328"; +window.CHGRID_WEB_VERSION = "2026.03.01 R329"; // 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 941a589..9fda5f4 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -2091,6 +2091,7 @@ async function onSignalingMessage(message: IncomingMessage): Promise { } await onAppMessage(message); if (message.type === 'welcome') { + signaling.send({ type: 'welcome_ready' }); await setupMediaAfterAuth(); if (playSelfLoginSound) { void audio.playSample(SYSTEM_SOUND_URLS.logon, 1); diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index d1abcae..ddf1f1a 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -367,6 +367,7 @@ export type OutgoingMessage = | { type: 'auth_login'; username: string; password: string } | { type: 'auth_resume'; sessionToken: string } | { type: 'auth_logout' } + | { type: 'welcome_ready' } | { type: 'admin_roles_list' } | { type: 'admin_role_create'; name: string } | { type: 'admin_role_update_permissions'; role: string; permissions: string[] } diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index e3cb9fe..988ee16 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -14,6 +14,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `auth_login`: authenticate with username/password. - `auth_resume`: resume prior session via stored session token. - `auth_logout`: revoke current session and disconnect. +- `welcome_ready`: client confirms it accepted `welcome` preflight and is ready to join active roster. - `admin_roles_list`: request server role list (with user counts + permission sets). - `admin_role_create`: create role. - `admin_role_update_permissions`: replace one role permission set. @@ -44,6 +45,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `admin_action_result`: structured result for admin actions. - admin mutations include `user_delete` for account deletion. - `welcome`: initial snapshot with users/items plus server UI/world metadata. + - Server delays roster activation/login broadcast until `welcome_ready` is received. - `signal`: forwarded WebRTC offer/answer/ICE. - `update_position`, `update_nickname`, `user_left`: presence updates. - `teleport_complete`: peer teleport landing event with spatial coordinates. diff --git a/server/app/client.py b/server/app/client.py index b1a0c11..630ff1d 100644 --- a/server/app/client.py +++ b/server/app/client.py @@ -27,6 +27,7 @@ class ClientConnection: last_position_update_ms: int = 0 movement_window_index: int = -1 movement_window_steps_used: int = 0 + world_ready: bool = False def summary(self) -> dict[str, str | int]: """Return a compact serializable snapshot for logs/diagnostics.""" diff --git a/server/app/models.py b/server/app/models.py index 0aa5c5e..6316d56 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -63,6 +63,10 @@ class AuthLogoutPacket(BasePacket): type: Literal["auth_logout"] +class WelcomeReadyPacket(BasePacket): + type: Literal["welcome_ready"] + + class AdminRolesListPacket(BasePacket): type: Literal["admin_roles_list"] @@ -189,6 +193,7 @@ ClientPacket = ( | AuthLoginPacket | AuthResumePacket | AuthLogoutPacket + | WelcomeReadyPacket | AdminRolesListPacket | AdminRoleCreatePacket | AdminRoleUpdatePermissionsPacket diff --git a/server/app/server.py b/server/app/server.py index eb02d1d..1234d92 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -102,6 +102,7 @@ from .models import ( UpdateNicknamePacket, UpdatePositionPacket, UserLeftPacket, + WelcomeReadyPacket, WelcomePacket, WorldItem, ) @@ -1419,11 +1420,9 @@ class SignalingServer: ) await self._send(client.websocket, packet) - async def _activate_authenticated_client(self, client: ClientConnection) -> None: - """Move an authenticated websocket client into the active world roster.""" + async def _send_authenticated_welcome(self, client: ClientConnection) -> None: + """Prepare authenticated client state and send welcome before world activation.""" - if client.websocket in self.clients: - return saved_x = getattr(client, "saved_x", None) saved_y = getattr(client, "saved_y", None) if isinstance(saved_x, int) and isinstance(saved_y, int) and self._is_in_bounds(saved_x, saved_y): @@ -1437,6 +1436,16 @@ class SignalingServer: client.last_position_update_ms = now_ms client.movement_window_index = self._movement_window_index(now_ms) client.movement_window_steps_used = 0 + client.world_ready = False + await self._send_welcome(client) + + async def _activate_authenticated_client(self, client: ClientConnection) -> None: + """Move a welcomed authenticated client into active world roster.""" + + if client.websocket in self.clients: + client.world_ready = True + return + client.world_ready = True self.clients[client.websocket] = client LOGGER.info( "client authenticated id=%s user_id=%s username=%s total=%d", @@ -1445,7 +1454,6 @@ class SignalingServer: client.username, len(self.clients), ) - await self._send_welcome(client) await self._broadcast( BroadcastChatMessagePacket( type="chat_message", @@ -1617,7 +1625,7 @@ class SignalingServer: authPolicy=self._auth_policy(), ), ) - await self._activate_authenticated_client(client) + await self._send_authenticated_welcome(client) return True def _build_ui_definitions(self, client: ClientConnection | None = None) -> dict: @@ -2082,6 +2090,14 @@ class SignalingServer: ) return + if isinstance(packet, WelcomeReadyPacket): + await self._activate_authenticated_client(client) + return + + if not client.world_ready: + PACKET_LOGGER.info("ignoring pre-ready packet id=%s type=%s", client.id, packet.type) + return + if await self._handle_admin_packet(client, packet): return diff --git a/server/tests/test_server_message_handling.py b/server/tests/test_server_message_handling.py index 92bfff4..a36a4d6 100644 --- a/server/tests/test_server_message_handling.py +++ b/server/tests/test_server_message_handling.py @@ -276,6 +276,44 @@ async def test_auth_login_failure_message_is_generic(monkeypatch: pytest.MonkeyP assert auth_results[-1].message == AUTH_LOGIN_FAILURE_MESSAGE +@pytest.mark.asyncio +async def test_auth_login_defers_activation_until_welcome_ready(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + username = f"ready_{uuid.uuid4().hex[:8]}" + server.auth_service.register(username, "password99") + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="u1", nickname="tester") + + send_payloads: list[object] = [] + broadcast_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: + broadcast_payloads.append(packet) + + monkeypatch.setattr(server, "_send", fake_send) + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + + await server._handle_message( + client, + json.dumps({"type": "auth_login", "username": username, "password": "password99"}), + ) + + assert client.authenticated is True + assert client.world_ready is False + assert ws not in server.clients + assert any(getattr(packet, "type", "") == "welcome" for packet in send_payloads) + assert not any("has logged in" in getattr(packet, "message", "") for packet in broadcast_payloads) + + await server._handle_message(client, json.dumps({"type": "welcome_ready"})) + + assert client.world_ready is True + assert server.clients.get(ws) is client + assert any("has logged in" in getattr(packet, "message", "") for packet in broadcast_payloads) + + @pytest.mark.asyncio async def test_auth_resume_failure_message_is_generic(monkeypatch: pytest.MonkeyPatch) -> None: server = SignalingServer("127.0.0.1", 8765, None, None)