diff --git a/Game/scripts/matchmaking.gd b/Game/scripts/matchmaking.gd index f18c6765..edc7a903 100644 --- a/Game/scripts/matchmaking.gd +++ b/Game/scripts/matchmaking.gd @@ -161,6 +161,12 @@ static func phase_label(phase: String) -> String: return "Connecting to match" MatchmakingState.LIVE: return "Match in progress" + MatchmakingState.RESULT_PENDING: + return "Recording match result" + MatchmakingState.COMPLETED: + return "Match complete" + MatchmakingState.ASSIGNED: + return "Match assigned" MatchmakingState.CANCELLED: return "Search cancelled" MatchmakingState.EXPIRED: @@ -185,6 +191,10 @@ func _render(snapshot: Dictionary) -> void: detail_label.text = "Review the proposal before the countdown expires" elif phase == MatchmakingState.ACCEPTED: detail_label.text = "All players accepted; preparing the match server" + elif phase == MatchmakingState.RESULT_PENDING: + detail_label.text = "The server is confirming the final result" + elif phase == MatchmakingState.COMPLETED: + detail_label.text = "The match result has been recorded" elif phase == MatchmakingState.IDLE: detail_label.text = "Choose a playlist to begin" cancel_button.visible = ControlPlaneClient.state.can_cancel() @@ -197,8 +207,8 @@ func _render(snapshot: Dictionary) -> void: static func _is_terminal(phase: String) -> bool: - return phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE] + return phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED] static func _can_start_new_search(phase: String) -> bool: - return phase == MatchmakingState.IDLE or phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED] + return phase == MatchmakingState.IDLE or phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED] diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index 43d29fd4..722245f4 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -17,6 +17,9 @@ const PROCESS_READY := "PROCESS_READY" const ASSIGNMENT_READY := "ASSIGNMENT_READY" const CONNECTING := "CONNECTING" const LIVE := "LIVE" +const ASSIGNED := "ASSIGNED" +const RESULT_PENDING := "RESULT_PENDING" +const COMPLETED := "COMPLETED" const CANCELLED := "CANCELLED" const EXPIRED := "EXPIRED" const FAILED := "FAILED" @@ -185,7 +188,7 @@ func restore_snapshot(saved: Dictionary) -> bool: proposal_revision = maxi(0, int(saved.get("proposal_revision", 0))) proposal_state = String(saved.get("proposal_state", "")) message = "Recovering authoritative matchmaking state" - needs_resync = phase != CANCELLED and phase != EXPIRED and phase != FAILED + needs_resync = phase != CANCELLED and phase != EXPIRED and phase != FAILED and phase != COMPLETED _emit_changed() return true @@ -242,7 +245,7 @@ func _reset() -> void: func _is_ticket_state(value: String) -> bool: - return value in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, CONNECTING, LIVE, CANCELLED, EXPIRED, FAILED] + return value in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED] func _has_string(value: Dictionary, key: String) -> bool: diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 8e22ae64..d7053688 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -49,6 +49,10 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void var accepted := envelope.duplicate() accepted["state"] = "ACCEPTED" assert_true(ControlPlaneClient._valid_websocket_event(accepted), "authoritative accepted queue event is accepted") + for phase in ["ASSIGNED", "RESULT_PENDING", "COMPLETED"]: + var lifecycle := envelope.duplicate() + lifecycle["state"] = phase + assert_true(ControlPlaneClient._valid_websocket_event(lifecycle), "post-match queue event is accepted: " + phase) var bad_state := envelope.duplicate() bad_state["state"] = "SECRET" assert_true(not ControlPlaneClient._valid_websocket_event(bad_state), "unknown state event is rejected") diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index 4925ae7b..dba80599 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -19,6 +19,15 @@ func test_ticket_projection_accepts_authoritative_accepted_phase() -> void: assert_true(not state.can_cancel(), "accepted match cannot be cancelled as a queue ticket") +func test_ticket_projection_accepts_post_match_lifecycle_states() -> void: + for phase in ["ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]: + var state := MatchmakingState.new() + assert_true(state.begin_queue("ticket-" + phase, "casual"), "queue setup succeeds for " + phase) + assert_true(state.apply_ticket_update({"ticket_id": "ticket-" + phase, "revision": 1, "state": phase, "playlist": "casual"}), "post-match phase is valid: " + phase) + assert_eq(state.phase, phase, "post-match phase remains visible: " + phase) + assert_true(not state.can_cancel(), "post-match phase cannot cancel: " + phase) + + func test_ticket_projection_rejects_gap_and_wrong_ticket_without_mutation() -> void: var state := MatchmakingState.new() assert_true(state.begin_queue("ticket-1", "casual"), "queue setup succeeds") diff --git a/multiplayer-next.md b/multiplayer-next.md index 7f0d164d..888db6d7 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1536,3 +1536,5 @@ The versioned OpenAPI contract now declares the implemented `/profile/ranked` su The Godot client now defers reconnect-triggered authoritative recovery when an HTTP mutation is still in flight, closing the `ERR_BUSY` recovery-drop race. An adversarial client test verifies that the active ticket remains queued for recovery rather than silently staying stale. The Godot queue projection now includes the contract's `ACCEPTED` ticket phase. Accepted events are no longer rejected as an unknown state; the UI keeps the accepted status visible and proceeds through allocation recovery. State and WebSocket vocabulary tests cover the transition. + +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.