From 277ad4bf981dfd95f49d61aaf8b8b4f8dde54a04 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:02:01 +0100 Subject: [PATCH] fix(multiplayer): validate ticket playlists --- Game/scripts/matchmaking_state.gd | 6 ++++++ Game/tests/cases/test_matchmaking_state.gd | 8 ++++++++ multiplayer-next.md | 2 ++ 3 files changed, 16 insertions(+) diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index bab98c05..95f38ea5 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -51,6 +51,8 @@ func begin_queue(new_ticket_id: String, new_playlist: String) -> bool: func apply_ticket_update(update: Dictionary) -> bool: if not _has_string(update, "ticket_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"): return _request_resync(self.ticket_id) + if update.has("playlist") and not _valid_playlist(String(update["playlist"])): + return _request_resync(self.ticket_id) if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id: return _request_resync(self.ticket_id) var incoming_revision := int(update["revision"]) @@ -299,3 +301,7 @@ func _valid_revision(value: Variant) -> bool: if value is float: return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value)) return false + + +func _valid_playlist(value: String) -> bool: + return value == "casual" or value == "ranked" diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index 85798d4b..c30880b6 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -82,6 +82,14 @@ func test_ticket_and_proposal_revisions_must_be_nonnegative_integers() -> void: assert_true(not state.apply_proposal_update({"proposal_id": "proposal-revision", "revision": -1, "state": "OPEN"}), "negative proposal revision is rejected") +func test_ticket_update_rejects_invalid_playlist_without_mutation() -> void: + var state := MatchmakingState.new() + state.begin_queue("ticket-playlist", "casual") + assert_true(not state.apply_ticket_update({"ticket_id": "ticket-playlist", "revision": 1, "state": "PROPOSED", "playlist": "admin"}), "unknown playlist is rejected") + assert_eq(state.phase, MatchmakingState.QUEUED, "invalid playlist cannot change phase") + assert_eq(state.playlist, "casual", "invalid playlist cannot change playlist") + + func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void: var state := MatchmakingState.new() state.begin_queue("ticket-1", "casual") diff --git a/multiplayer-next.md b/multiplayer-next.md index 420ef903..4f26d308 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1546,3 +1546,5 @@ Reconnect recovery now treats `COMPLETED` as terminal, avoiding a needless queue Ticket, proposal, and WebSocket revisions now fail closed unless they are finite, non-negative integers; fractional values are no longer silently truncated into valid revisions. Adversarial client tests cover fractional and negative inputs. Proposal updates now enforce the documented `OPEN → ACCEPTED/DECLINED/EXPIRED/CANCELLED` graph, including rejecting higher-revision reopen/accept attempts after terminal decisions while preserving same-state duplicates. Adversarial proposal-transition tests cover accepted and declined terminal paths. + +Ticket projections now validate playlist metadata on every update, rejecting unknown values before either phase or playlist state can mutate. An adversarial higher-revision update test covers this boundary.