From 03bc723b704ff03fc07417ae6ebe058dc87f11d9 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:36:05 +0100 Subject: [PATCH] fix(multiplayer): reject fractional ranked games --- Game/scripts/ranked_profile_state.gd | 10 +++++++++- Game/tests/cases/test_control_plane_client.gd | 1 + multiplayer-next.md | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Game/scripts/ranked_profile_state.gd b/Game/scripts/ranked_profile_state.gd index 63d4c2d8..d218a359 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 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 next_tier.is_empty(): return _reject("Profile response contains invalid values") rating = next_rating rd = next_rd @@ -69,6 +69,14 @@ static func is_valid_opaque_id(value: String) -> bool: return id_pattern.search(value) != null +static func _valid_nonnegative_integer(value: Variant) -> bool: + if value is int: + return int(value) >= 0 + if value is float: + return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value)) + return false + + 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 404afc0c..66242309 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -156,6 +156,7 @@ func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() -> 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") + 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") func test_ranked_profile_projects_and_bounds_season_countdown() -> void: diff --git a/multiplayer-next.md b/multiplayer-next.md index 5db67535..7d2a09e8 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1583,6 +1583,8 @@ Ranked profile `season_id` now enforces the OpenAPI opaque-ID shape and exact st Persisted matchmaking snapshots now validate field types, non-negative integral revisions/epochs, and proposal identity/state consistency before restoration; malformed restart data cannot be coerced into an active projection. +Ranked profile projection now rejects fractional `ranked_games` values instead of silently truncating them, matching the OpenAPI integer contract. + 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.