diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index e3287bbf..760b60bc 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -465,7 +465,7 @@ func _set_websocket_status(status: String) -> void: _websocket_status = status websocket_status_changed.emit(status) if status == "CONNECTED": - if not state.ticket_id.is_empty() and state.phase not in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE]: + if not state.ticket_id.is_empty() and state.phase not in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]: var resource_id := state.proposal_id if not state.proposal_id.is_empty() else state.ticket_id if _operation.is_empty(): _run_resync(resource_id) diff --git a/Game/tests/cases/test_matchmaking_ui.gd b/Game/tests/cases/test_matchmaking_ui.gd index cee1e071..b3f9fbe1 100644 --- a/Game/tests/cases/test_matchmaking_ui.gd +++ b/Game/tests/cases/test_matchmaking_ui.gd @@ -5,14 +5,15 @@ const MatchmakingState = preload("res://scripts/matchmaking_state.gd") func test_every_backend_phase_has_a_nonempty_user_message() -> void: - for phase in [MatchmakingState.IDLE, MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.CONNECTING, MatchmakingState.LIVE, MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED]: + for phase in [MatchmakingState.IDLE, MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED, MatchmakingState.CONNECTING, MatchmakingState.LIVE, MatchmakingState.RESULT_PENDING, MatchmakingState.COMPLETED, MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED]: assert_true(not Matchmaking.phase_label(phase).is_empty(), "phase %s has visible copy" % phase) func test_terminal_state_policy_does_not_leave_cancel_or_proposal_actions_enabled() -> void: - for phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE]: + for phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]: assert_true(Matchmaking._is_terminal(phase), "phase %s is terminal" % phase) assert_true(not Matchmaking._is_terminal(MatchmakingState.QUEUED), "queued search remains active") assert_true(not Matchmaking._is_terminal(MatchmakingState.PROPOSED), "proposal remains actionable") assert_true(Matchmaking._can_start_new_search(MatchmakingState.FAILED), "failed search can be retried") assert_true(not Matchmaking._can_start_new_search(MatchmakingState.LIVE), "live match cannot start a second search") + assert_true(Matchmaking._can_start_new_search(MatchmakingState.COMPLETED), "completed match can start a new search") diff --git a/multiplayer-next.md b/multiplayer-next.md index 1bd8e690..71495e0c 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1540,3 +1540,5 @@ The Godot queue projection now includes the contract's `ACCEPTED` ticket phase. The queue projection now also accepts the contract's post-allocation/result states (`ASSIGNED`, `RESULT_PENDING`, and `COMPLETED`). These states remain visible, cannot issue queue cancellation, and completed matches return the search action to a valid new-search state; adversarial lifecycle and WebSocket vocabulary tests cover them. Client ticket updates now enforce the versioned legal transition graph as well as revision ordering. Same-state heartbeat revisions remain valid, while higher-revision jumps and rewinds request authoritative recovery without mutating the visible phase; adversarial tests cover both boundaries. + +Reconnect recovery now treats `COMPLETED` as terminal, avoiding a needless queue read after a finished match. UI policy tests cover the complete expanded lifecycle, including the completed-to-new-search boundary.