diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index dca5ff9b..57b72764 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -140,8 +140,17 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head return if response_code < 200 or response_code >= 300: var detail := String(parsed.get("error", "request rejected")) - if operation == "ranked_profile": + if response_code == HTTPClient.RESPONSE_UNAUTHORIZED: + state.fail("Session expired; sign in again") + ranked_profile.set_error("Session expired; sign in again") + elif response_code == HTTPClient.RESPONSE_GONE and operation == "queue_recover": + state.expire("Queue ticket expired") + elif response_code == HTTPClient.RESPONSE_SERVICE_UNAVAILABLE: + state.set_notice("Matchmaking is temporarily unavailable; retrying is safe") + elif operation == "ranked_profile": ranked_profile.set_error(detail) + elif response_code == HTTPClient.RESPONSE_NOT_FOUND and (operation == "queue_recover" or operation == "proposal_recover"): + state.fail("Matchmaking record is no longer available") elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": state.fail(detail) else: diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index 70db7dcc..52443e87 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -140,6 +140,12 @@ func fail(reason: String) -> void: _emit_changed() +func expire(reason: String = "Matchmaking expired") -> void: + phase = EXPIRED + message = reason + _emit_changed() + + func set_notice(notice: String) -> void: message = notice _emit_changed() diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index 8ada6f55..440848e2 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -66,6 +66,15 @@ func test_assignment_lifecycle_has_explicit_connecting_and_live_states() -> void assert_eq(state.phase, MatchmakingState.LIVE, "live match is visible") +func test_expiry_is_distinct_from_generic_failure_and_remains_visible() -> void: + var state := MatchmakingState.new() + state.begin_queue("ticket-1", "casual") + state.expire("Queue ticket expired") + assert_eq(state.phase, MatchmakingState.EXPIRED, "expired ticket has a terminal expiry state") + assert_eq(state.message, "Queue ticket expired", "expiry reason is visible") + assert_true(not state.can_cancel(), "expired ticket cannot be cancelled") + + func test_restart_restore_requires_valid_identity_and_requests_authoritative_recovery() -> void: var state := MatchmakingState.new() assert_true(state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "revision": 2}), "valid active snapshot restores") diff --git a/multiplayer-todo.md b/multiplayer-todo.md index cd030e79..0a59d674 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1230,7 +1230,7 @@ the local/CI/community transport, not a silent production fallback. | 8.40 `[D:8.3,8.14]` | **IN PROGRESS.** Pure Go revisioned replica reducer rejects gaps for REST resync, makes duplicate/out-of-order events idempotent, and resumes from the authoritative snapshot revision; authenticated queue-ticket recovery now has an owner-checked REST read; Godot client projection persists non-secret ticket/proposal state and forces authoritative recovery after restart | `server/domain/sync.go`, `server/api/service.go`, `matchmaking_state.gd` and `control_plane_client.gd` cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery, expired-ticket terminal handling and malformed restart snapshots; authenticated WebSocket transport and duplicate-ticket integration remain | | 8.41 `[D:7.8,8.9,8.31,8.40]` | After assignment-ready, install SDR relay ticket before connect and send match-scoped join authorisation in `hello`; retain ENet assignments locally | Production connects/reconnects/fences old generation through SDR, never before assignment-ready; allocated/direct ENet and community flows remain compatible | | 8.42 `[D:8.22,8.23,8.24,8.40]` | **IN PROGRESS.** `RankedProfileState` and `ControlPlaneClient.fetch_ranked_profile()` expose the backend-authoritative rating/RD/volatility/games/tier/provisional/season view; matchmaking UI displays provisional/tier status without client-side rating math | `test_control_plane_client.gd` validates profile shape, numeric safety and provisional display; ranked profile fetch/display, committed revision after reconnect, abandon status and season countdown remain dependent on live auth/backend events and Godot runtime verification | -| 8.43 `[D:8.39,8.40,8.41]` | Recovery paths for decline, expiry, startup failure, version mismatch, auth expiry, regional outage and failed reconnect | Automated UI/state tests prove every case returns to a usable queue/menu or resumes the match without a duplicate action | +| 8.43 `[D:8.39,8.40,8.41]` | **IN PROGRESS.** Matchmaking client now distinguishes expired queue recovery, session expiry, missing records and retryable control-plane outages; terminal messages remain visible and active searches are not falsely failed on transient errors | `MatchmakingState` and `ControlPlaneClient` tests cover explicit expiry and the existing terminal/retry-safe state paths; decline, version mismatch, regional outage retry UI, failed reconnect, duplicate-action recovery and live Godot verification remain | #### 8F — Observability, verification, cost and rollout