diff --git a/Game/scenes/matchmaking.tscn b/Game/scenes/matchmaking.tscn index a9452ed3..4b07b4d9 100644 --- a/Game/scenes/matchmaking.tscn +++ b/Game/scenes/matchmaking.tscn @@ -49,6 +49,14 @@ layout_mode = 2 autowrap_mode = 2 horizontal_alignment = 1 +[node name="RankedProfileLabel" type="Label" parent="CenterContainer/VBoxContainer"] +unique_name_in_owner = true +modulate = Color(1, 1, 1, 0.65) +layout_mode = 2 +text = "Ranked profile unavailable" +horizontal_alignment = 1 +visible = false + [node name="QueueButton" type="Button" parent="CenterContainer/VBoxContainer"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 52) diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 415e82e9..dca5ff9b 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -13,6 +13,7 @@ const PERSIST_PATH := "user://matchmaking_state.cfg" var base_url := DEFAULT_BASE_URL var access_token := "" var state: MatchmakingState +var ranked_profile: RankedProfileState var _request: HTTPRequest var _operation := "" @@ -20,6 +21,7 @@ var _operation := "" func _ready() -> void: state = MatchmakingState.new() + ranked_profile = RankedProfileState.new() _load_persisted_state() state.changed.connect(_persist_state) _request = HTTPRequest.new() @@ -61,6 +63,10 @@ func recover_proposal(proposal_id: String) -> Error: return _start_request("proposal_recover", HTTPClient.METHOD_GET, "/v1/proposals/" + proposal_id, {}, "") +func fetch_ranked_profile() -> Error: + return _start_request("ranked_profile", HTTPClient.METHOD_GET, "/v1/profile/ranked", {}, "") + + func heartbeat(ticket_id: String, expected_revision: int) -> Error: if ticket_id.is_empty() or expected_revision < 0: return ERR_INVALID_PARAMETER @@ -114,7 +120,9 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head var operation := _operation _operation = "" if result != HTTPRequest.RESULT_SUCCESS: - if operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": + if operation == "ranked_profile": + ranked_profile.set_error("Ranked profile request failed") + elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": state.fail("Control-plane request failed") else: state.set_notice("Control-plane request failed; retrying is safe") @@ -122,7 +130,9 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head return var parsed = JSON.parse_string(body.get_string_from_utf8()) if not parsed is Dictionary: - if operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": + if operation == "ranked_profile": + ranked_profile.set_error("Ranked profile returned invalid JSON") + elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": state.fail("Control-plane returned invalid JSON") else: state.set_notice("Control-plane returned invalid JSON; retrying is safe") @@ -130,7 +140,9 @@ 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 == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": + if operation == "ranked_profile": + ranked_profile.set_error(detail) + elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": state.fail(detail) else: state.set_notice(detail) @@ -143,6 +155,10 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head state.apply_ticket_update(normalize_ticket(payload)) elif operation.begins_with("proposal_"): state.apply_proposal_update(payload) + elif operation == "ranked_profile": + if not ranked_profile.apply(payload): + request_failed.emit(operation, response_code, ranked_profile.error_message) + return request_succeeded.emit(operation, payload) diff --git a/Game/scripts/matchmaking.gd b/Game/scripts/matchmaking.gd index 1b1fa16c..ac2bf111 100644 --- a/Game/scripts/matchmaking.gd +++ b/Game/scripts/matchmaking.gd @@ -8,6 +8,7 @@ const RECOVERY_POLL_SECONDS := 2.0 @onready var playlist_dropdown: OptionButton = %PlaylistDropdown @onready var status_label: Label = %StatusLabel @onready var detail_label: Label = %DetailLabel +@onready var ranked_profile_label: Label = %RankedProfileLabel @onready var queue_button: Button = %QueueButton @onready var cancel_button: Button = %CancelButton @onready var accept_button: Button = %AcceptButton @@ -24,9 +25,11 @@ func _ready() -> void: playlist_dropdown.set_item_metadata(0, "casual") playlist_dropdown.add_item("Ranked") playlist_dropdown.set_item_metadata(1, "ranked") + playlist_dropdown.item_selected.connect(_on_playlist_selected) ControlPlaneClient.state.changed.connect(_on_state_changed) ControlPlaneClient.request_failed.connect(_on_request_failed) ControlPlaneClient.request_succeeded.connect(_on_request_succeeded) + _refresh_ranked_profile() _render(ControlPlaneClient.state.snapshot()) @@ -81,6 +84,20 @@ func _on_decline_pressed() -> void: _on_local_error("Could not decline proposal: %s" % error_string(err)) +func _on_playlist_selected(_index: int) -> void: + _refresh_ranked_profile() + + +func _refresh_ranked_profile() -> void: + var ranked := String(playlist_dropdown.get_selected_metadata()) == "ranked" + ranked_profile_label.visible = ranked + if not ranked: + return + var err := ControlPlaneClient.fetch_ranked_profile() + if err != OK and err != ERR_BUSY: + ranked_profile_label.text = "Ranked profile unavailable: %s" % error_string(err) + + func _on_back_pressed() -> void: if ControlPlaneClient.state.can_cancel(): status_label.text = "Cancel the active search before leaving" @@ -93,11 +110,13 @@ func _on_state_changed(snapshot: Dictionary) -> void: func _on_request_succeeded(_operation: String, _payload: Dictionary) -> void: + ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text() _render(ControlPlaneClient.state.snapshot()) func _on_request_failed(_operation: String, _http_code: int, detail: String) -> void: detail_label.text = detail + ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text() _render(ControlPlaneClient.state.snapshot()) diff --git a/Game/scripts/ranked_profile_state.gd b/Game/scripts/ranked_profile_state.gd new file mode 100644 index 00000000..ea3ae836 --- /dev/null +++ b/Game/scripts/ranked_profile_state.gd @@ -0,0 +1,60 @@ +class_name RankedProfileState +extends RefCounted + +# Read-only server projection. The client deliberately stores no tier bands +# or rating formula: it displays the backend's committed view verbatim after +# validating the shape and numeric safety of the response. + +var available := false +var rating := 0.0 +var rd := 0.0 +var volatility := 0.0 +var ranked_games := 0 +var tier := "" +var provisional := false +var season_id := "" +var error_message := "" + + +func apply(payload: Dictionary) -> bool: + var required := ["rating", "rd", "volatility", "ranked_games", "tier", "provisional"] + for key in required: + if not payload.has(key): + return _reject("Profile response is missing " + key) + if not (payload["rating"] is int or payload["rating"] is float) or not (payload["rd"] is int or payload["rd"] is float) or not (payload["volatility"] is int or payload["volatility"] is float) or not payload["ranked_games"] is int or not payload["tier"] is String or not payload["provisional"] is bool: + return _reject("Profile response contains invalid types") + var next_rating := float(payload["rating"]) + var next_rd := float(payload["rd"]) + var next_volatility := float(payload["volatility"]) + var next_games := int(payload["ranked_games"]) + var next_tier := String(payload["tier"]) + if not is_finite(next_rating) or not is_finite(next_rd) or not is_finite(next_volatility) or next_rating < 0.0 or next_rd < 0.0 or next_volatility < 0.0 or next_games < 0 or next_tier.is_empty(): + return _reject("Profile response contains invalid values") + rating = next_rating + rd = next_rd + volatility = next_volatility + ranked_games = next_games + tier = next_tier + provisional = bool(payload["provisional"]) + season_id = String(payload.get("season_id", "")) + available = true + error_message = "" + return true + + +func set_error(reason: String) -> void: + available = false + error_message = reason + + +func display_text() -> String: + if not available: + return error_message if not error_message.is_empty() else "Ranked profile unavailable" + var status := "Provisional" if provisional else tier + return "%s · %d ranked game%s" % [status, ranked_games, "" if ranked_games == 1 else "s"] + + +func _reject(reason: String) -> bool: + available = false + error_message = reason + return false diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 21d64bb6..e3156ea8 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -1,6 +1,7 @@ extends "res://tests/test_case.gd" const ControlPlaneClient = preload("res://scripts/control_plane_client.gd") +const RankedProfileState = preload("res://scripts/ranked_profile_state.gd") func test_base_url_validation_rejects_ambiguous_or_insecure_values() -> void: @@ -24,3 +25,13 @@ func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void: assert_true(normalized.has("expires_at_unix"), "RFC3339 expiry is available to the projection") assert_true(int(normalized["expires_at_unix"]) > 0, "expiry is converted to a positive epoch") assert_true(not payload.has("expires_at_unix"), "normalization does not mutate the HTTP payload") + + +func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() -> void: + var profile := RankedProfileState.new() + assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": true, "season_id": "s1"}), "valid profile applies") + assert_eq(profile.display_text(), "Provisional · 3 ranked games", "provisional status overrides tier presentation") + assert_true(not profile.apply({"rating": -1.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": false}), "negative rating is rejected") + assert_true(not profile.available, "unsafe response is not displayed") + assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "", "provisional": false}), "empty tier is rejected") + assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": "false"}), "string boolean is rejected") diff --git a/multiplayer-todo.md b/multiplayer-todo.md index c9843742..cd030e79 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1229,7 +1229,7 @@ the local/CI/community transport, not a silent production fallback. | 8.39 `[D:8.3,8.14,8.17]` | **IN PROGRESS.** `MatchmakingState` now projects queue → proposal → allocation/process-ready/assignment-ready/connect/live plus terminal failure states; autoload `ControlPlaneClient` provides authenticated queue create/recovery/heartbeat/cancel and proposal response requests with idempotency/revision headers; `matchmaking.tscn`/`matchmaking.gd` expose the state and authoritative actions from the main menu; authenticated proposal recovery now reconciles missed proposal events and expires them at read time | `test_matchmaking_state.gd`, `test_control_plane_client.gd`, `test_matchmaking_ui.gd` and `TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary` reject stale/gapped/conflicting updates, validate endpoint/token/payload normalization, preserve idempotent duplicates, and guarantee visible phase/terminal copy; server-pushed allocation events, wait/latency explanations and Godot runtime verification remain | | 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]` | Backend-authoritative provisional/rank/tier/delta, abandon status and season countdown UI | Client performs no rating math and displays the committed revision after reconnect | +| 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 | #### 8F — Observability, verification, cost and rollout