Defer world activation until welcome preflight confirmation
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
// Maintainer-controlled web client version.
|
// Maintainer-controlled web client version.
|
||||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
// 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.
|
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||||
|
|||||||
@@ -2091,6 +2091,7 @@ async function onSignalingMessage(message: IncomingMessage): Promise<void> {
|
|||||||
}
|
}
|
||||||
await onAppMessage(message);
|
await onAppMessage(message);
|
||||||
if (message.type === 'welcome') {
|
if (message.type === 'welcome') {
|
||||||
|
signaling.send({ type: 'welcome_ready' });
|
||||||
await setupMediaAfterAuth();
|
await setupMediaAfterAuth();
|
||||||
if (playSelfLoginSound) {
|
if (playSelfLoginSound) {
|
||||||
void audio.playSample(SYSTEM_SOUND_URLS.logon, 1);
|
void audio.playSample(SYSTEM_SOUND_URLS.logon, 1);
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ export type OutgoingMessage =
|
|||||||
| { type: 'auth_login'; username: string; password: string }
|
| { type: 'auth_login'; username: string; password: string }
|
||||||
| { type: 'auth_resume'; sessionToken: string }
|
| { type: 'auth_resume'; sessionToken: string }
|
||||||
| { type: 'auth_logout' }
|
| { type: 'auth_logout' }
|
||||||
|
| { type: 'welcome_ready' }
|
||||||
| { type: 'admin_roles_list' }
|
| { type: 'admin_roles_list' }
|
||||||
| { type: 'admin_role_create'; name: string }
|
| { type: 'admin_role_create'; name: string }
|
||||||
| { type: 'admin_role_update_permissions'; role: string; permissions: string[] }
|
| { type: 'admin_role_update_permissions'; role: string; permissions: string[] }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
|||||||
- `auth_login`: authenticate with username/password.
|
- `auth_login`: authenticate with username/password.
|
||||||
- `auth_resume`: resume prior session via stored session token.
|
- `auth_resume`: resume prior session via stored session token.
|
||||||
- `auth_logout`: revoke current session and disconnect.
|
- `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_roles_list`: request server role list (with user counts + permission sets).
|
||||||
- `admin_role_create`: create role.
|
- `admin_role_create`: create role.
|
||||||
- `admin_role_update_permissions`: replace one role permission set.
|
- `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_action_result`: structured result for admin actions.
|
||||||
- admin mutations include `user_delete` for account deletion.
|
- admin mutations include `user_delete` for account deletion.
|
||||||
- `welcome`: initial snapshot with users/items plus server UI/world metadata.
|
- `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.
|
- `signal`: forwarded WebRTC offer/answer/ICE.
|
||||||
- `update_position`, `update_nickname`, `user_left`: presence updates.
|
- `update_position`, `update_nickname`, `user_left`: presence updates.
|
||||||
- `teleport_complete`: peer teleport landing event with spatial coordinates.
|
- `teleport_complete`: peer teleport landing event with spatial coordinates.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class ClientConnection:
|
|||||||
last_position_update_ms: int = 0
|
last_position_update_ms: int = 0
|
||||||
movement_window_index: int = -1
|
movement_window_index: int = -1
|
||||||
movement_window_steps_used: int = 0
|
movement_window_steps_used: int = 0
|
||||||
|
world_ready: bool = False
|
||||||
|
|
||||||
def summary(self) -> dict[str, str | int]:
|
def summary(self) -> dict[str, str | int]:
|
||||||
"""Return a compact serializable snapshot for logs/diagnostics."""
|
"""Return a compact serializable snapshot for logs/diagnostics."""
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ class AuthLogoutPacket(BasePacket):
|
|||||||
type: Literal["auth_logout"]
|
type: Literal["auth_logout"]
|
||||||
|
|
||||||
|
|
||||||
|
class WelcomeReadyPacket(BasePacket):
|
||||||
|
type: Literal["welcome_ready"]
|
||||||
|
|
||||||
|
|
||||||
class AdminRolesListPacket(BasePacket):
|
class AdminRolesListPacket(BasePacket):
|
||||||
type: Literal["admin_roles_list"]
|
type: Literal["admin_roles_list"]
|
||||||
|
|
||||||
@@ -189,6 +193,7 @@ ClientPacket = (
|
|||||||
| AuthLoginPacket
|
| AuthLoginPacket
|
||||||
| AuthResumePacket
|
| AuthResumePacket
|
||||||
| AuthLogoutPacket
|
| AuthLogoutPacket
|
||||||
|
| WelcomeReadyPacket
|
||||||
| AdminRolesListPacket
|
| AdminRolesListPacket
|
||||||
| AdminRoleCreatePacket
|
| AdminRoleCreatePacket
|
||||||
| AdminRoleUpdatePermissionsPacket
|
| AdminRoleUpdatePermissionsPacket
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ from .models import (
|
|||||||
UpdateNicknamePacket,
|
UpdateNicknamePacket,
|
||||||
UpdatePositionPacket,
|
UpdatePositionPacket,
|
||||||
UserLeftPacket,
|
UserLeftPacket,
|
||||||
|
WelcomeReadyPacket,
|
||||||
WelcomePacket,
|
WelcomePacket,
|
||||||
WorldItem,
|
WorldItem,
|
||||||
)
|
)
|
||||||
@@ -1419,11 +1420,9 @@ class SignalingServer:
|
|||||||
)
|
)
|
||||||
await self._send(client.websocket, packet)
|
await self._send(client.websocket, packet)
|
||||||
|
|
||||||
async def _activate_authenticated_client(self, client: ClientConnection) -> None:
|
async def _send_authenticated_welcome(self, client: ClientConnection) -> None:
|
||||||
"""Move an authenticated websocket client into the active world roster."""
|
"""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_x = getattr(client, "saved_x", None)
|
||||||
saved_y = getattr(client, "saved_y", 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):
|
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.last_position_update_ms = now_ms
|
||||||
client.movement_window_index = self._movement_window_index(now_ms)
|
client.movement_window_index = self._movement_window_index(now_ms)
|
||||||
client.movement_window_steps_used = 0
|
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
|
self.clients[client.websocket] = client
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"client authenticated id=%s user_id=%s username=%s total=%d",
|
"client authenticated id=%s user_id=%s username=%s total=%d",
|
||||||
@@ -1445,7 +1454,6 @@ class SignalingServer:
|
|||||||
client.username,
|
client.username,
|
||||||
len(self.clients),
|
len(self.clients),
|
||||||
)
|
)
|
||||||
await self._send_welcome(client)
|
|
||||||
await self._broadcast(
|
await self._broadcast(
|
||||||
BroadcastChatMessagePacket(
|
BroadcastChatMessagePacket(
|
||||||
type="chat_message",
|
type="chat_message",
|
||||||
@@ -1617,7 +1625,7 @@ class SignalingServer:
|
|||||||
authPolicy=self._auth_policy(),
|
authPolicy=self._auth_policy(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await self._activate_authenticated_client(client)
|
await self._send_authenticated_welcome(client)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _build_ui_definitions(self, client: ClientConnection | None = None) -> dict:
|
def _build_ui_definitions(self, client: ClientConnection | None = None) -> dict:
|
||||||
@@ -2082,6 +2090,14 @@ class SignalingServer:
|
|||||||
)
|
)
|
||||||
return
|
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):
|
if await self._handle_admin_packet(client, packet):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_auth_resume_failure_message_is_generic(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_auth_resume_failure_message_is_generic(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
server = SignalingServer("127.0.0.1", 8765, None, None)
|
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||||
|
|||||||
Reference in New Issue
Block a user