From 0f1864f8bc0debdffdb914ed5e7c7d8722123206 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:57:13 +0100 Subject: [PATCH] fix(multiplayer): enforce client state transitions --- Game/scripts/matchmaking_state.gd | 20 +++++++++++++++++++ Game/tests/cases/test_matchmaking_state.gd | 23 ++++++++++++++++++++-- multiplayer-next.md | 2 ++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index 722245f4..a6b79822 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -74,6 +74,8 @@ func apply_ticket_update(update: Dictionary) -> bool: var incoming_state := String(update["state"]) if not _is_ticket_state(incoming_state): return _request_resync(self.ticket_id) + if not _is_legal_ticket_transition(phase, incoming_state): + return _request_resync(self.ticket_id) revision = incoming_revision phase = incoming_state if update.has("playlist"): @@ -248,5 +250,23 @@ func _is_ticket_state(value: String) -> bool: return value in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED] +func _is_legal_ticket_transition(from: String, to: String) -> bool: + if from == to: + return true + var transitions := { + QUEUED: [PROPOSED, CANCELLED, EXPIRED], + PROPOSED: [QUEUED, ACCEPTED, CANCELLED, EXPIRED], + ACCEPTED: [QUEUED, ALLOCATING, CANCELLED, FAILED], + ALLOCATING: [PROCESS_READY, FAILED, CANCELLED], + PROCESS_READY: [ASSIGNMENT_READY, FAILED, CANCELLED], + ASSIGNMENT_READY: [ASSIGNED, FAILED, CANCELLED], + ASSIGNED: [CONNECTING, FAILED, CANCELLED], + CONNECTING: [LIVE, FAILED, EXPIRED], + LIVE: [RESULT_PENDING, FAILED], + RESULT_PENDING: [COMPLETED, FAILED], + } + return transitions.has(from) and to in transitions[from] + + func _has_string(value: Dictionary, key: String) -> bool: return value.has(key) and value[key] is String and not String(value[key]).is_empty() diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index dba80599..879ae361 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -14,7 +14,8 @@ func test_ticket_projection_accepts_ordered_updates_and_exposes_cancel() -> void func test_ticket_projection_accepts_authoritative_accepted_phase() -> void: var state := MatchmakingState.new() assert_true(state.begin_queue("ticket-accepted", "ranked"), "queue setup succeeds") - assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 1, "state": "ACCEPTED", "playlist": "ranked"}), "accepted queue phase is valid") + assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 1, "state": "PROPOSED", "playlist": "ranked"}), "proposal phase applies") + assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 2, "state": "ACCEPTED", "playlist": "ranked"}), "accepted queue phase is valid") assert_eq(state.phase, MatchmakingState.ACCEPTED, "accepted phase remains visible instead of forcing resync") assert_true(not state.can_cancel(), "accepted match cannot be cancelled as a queue ticket") @@ -23,7 +24,12 @@ 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) + var revision := 1 + for next_phase in ["PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]: + assert_true(state.apply_ticket_update({"ticket_id": "ticket-" + phase, "revision": revision, "state": next_phase, "playlist": "casual"}), "lifecycle phase applies: " + next_phase) + revision += 1 + if next_phase == phase: + break assert_eq(state.phase, phase, "post-match phase remains visible: " + phase) assert_true(not state.can_cancel(), "post-match phase cannot cancel: " + phase) @@ -56,6 +62,19 @@ func test_duplicate_conflict_and_stale_updates_are_safe() -> void: assert_eq(state.phase, MatchmakingState.PROPOSED, "stale update cannot mutate state") +func test_higher_revision_cannot_jump_or_rewind_the_authoritative_lifecycle() -> void: + var state := MatchmakingState.new() + state.begin_queue("ticket-transition", "casual") + assert_true(state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "legal transition applies") + assert_true(not state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 2, "state": "LIVE", "playlist": "casual"}), "higher revision cannot jump phases") + assert_eq(state.phase, MatchmakingState.PROPOSED, "illegal jump cannot mutate phase") + state.needs_resync = false + assert_true(state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 2, "state": "ACCEPTED", "playlist": "casual"}), "legal next transition applies") + state.needs_resync = false + assert_true(not state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 3, "state": "PROPOSED", "playlist": "casual"}), "higher revision cannot rewind after acceptance") + assert_eq(state.phase, MatchmakingState.ACCEPTED, "illegal rewind cannot mutate phase") + + 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 888db6d7..1bd8e690 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1538,3 +1538,5 @@ The Godot client now defers reconnect-triggered authoritative recovery when an H 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. + +Client ticket updates now enforce the versioned legal transition graph as well as revision ordering. Same-state heartbeat revisions remain valid, while higher-revision jumps and rewinds request authoritative recovery without mutating the visible phase; adversarial tests cover both boundaries.