From dac414274ea39f2b10426affa0208e20eeb4007f Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:39:06 +0100 Subject: [PATCH] fix(multiplayer): enforce ranked tier enum --- Game/scripts/ranked_profile_state.gd | 6 +++++- Game/tests/cases/test_control_plane_client.gd | 1 + multiplayer-next.md | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Game/scripts/ranked_profile_state.gd b/Game/scripts/ranked_profile_state.gd index d218a359..fe806c24 100644 --- a/Game/scripts/ranked_profile_state.gd +++ b/Game/scripts/ranked_profile_state.gd @@ -29,7 +29,7 @@ func apply(payload: Dictionary) -> bool: 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 not _valid_nonnegative_integer(payload["ranked_games"]) or next_rating < 0.0 or next_rd < 0.0 or next_volatility < 0.0 or next_games < 0 or next_tier.is_empty(): + if not is_finite(next_rating) or not is_finite(next_rd) or not is_finite(next_volatility) or not _valid_nonnegative_integer(payload["ranked_games"]) or next_rating < 0.0 or next_rd < 0.0 or next_volatility < 0.0 or next_games < 0 or not _valid_tier(next_tier): return _reject("Profile response contains invalid values") rating = next_rating rd = next_rd @@ -77,6 +77,10 @@ static func _valid_nonnegative_integer(value: Variant) -> bool: return false +static func _valid_tier(value: String) -> bool: + return value in ["PROVISIONAL", "BRONZE", "SILVER", "GOLD", "PLATINUM", "DIAMOND"] + + func set_error(reason: String) -> void: available = false error_message = reason diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 161d99d1..4f594f08 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -157,6 +157,7 @@ func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() -> 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": "MASTER", "provisional": false}), "unknown 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") assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3.5, "tier": "GOLD", "provisional": false}), "fractional ranked games is rejected") diff --git a/multiplayer-next.md b/multiplayer-next.md index 5f01d7ce..1fdf3fa8 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1587,6 +1587,8 @@ Ticket timestamp normalization now preserves an invalid sentinel for malformed o Ranked profile projection now rejects fractional `ranked_games` values instead of silently truncating them, matching the OpenAPI integer contract. +Ranked profile projection now enforces the OpenAPI tier enum, rejecting unknown tier labels before they reach the HUD. + Signed MatchNet claims now also require exact JSON string/integer types for every identity, protocol, expiry, slot, team, and generation field; string-number coercion is rejected before canonical signature verification. Presentation progress: a shared `Game/themes/cosmic_clash_theme.tres` now gives the menu, lobby, matchmaking, and settings surfaces consistent button, input, option, and label styling. The custom-font portion of `TODO.md` remains open until a distributable font asset is selected.