diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index a4a9e027..bab98c05 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -112,15 +112,28 @@ func apply_proposal_update(update: Dictionary) -> bool: return _request_resync(proposal_id) var incoming_proposal_state := String(update["state"]) if incoming_proposal_state == "OPEN": + if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state): + return _request_resync(proposal_id) phase = PROPOSED elif incoming_proposal_state == "ACCEPTED": + if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state): + return _request_resync(proposal_id) phase = ALLOCATING elif incoming_proposal_state == "DECLINED": + if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state): + return _request_resync(proposal_id) phase = FAILED message = "A player declined the match proposal" elif incoming_proposal_state == "EXPIRED": + if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state): + return _request_resync(proposal_id) phase = EXPIRED message = "The match proposal expired" + elif incoming_proposal_state == "CANCELLED": + if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state): + return _request_resync(proposal_id) + phase = CANCELLED + message = "The match proposal was cancelled" else: return _request_resync(proposal_id) proposal_revision = incoming_revision @@ -268,6 +281,14 @@ func _is_legal_ticket_transition(from: String, to: String) -> bool: return transitions.has(from) and to in transitions[from] +func _is_legal_proposal_transition(from: String, to: String) -> bool: + if from.is_empty(): + return to == "OPEN" + if from == to: + return true + return from == "OPEN" and to in ["ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"] + + 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 8c982a9a..85798d4b 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -99,6 +99,21 @@ func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void: assert_true(not expired.can_cancel(), "expired proposal cannot be cancelled") +func test_proposal_projection_rejects_illegal_higher_revision_transitions() -> void: + var state := MatchmakingState.new() + state.begin_queue("ticket-proposal-transition", "casual") + assert_true(state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 1, "state": "OPEN"}), "proposal opens") + assert_true(state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 2, "state": "ACCEPTED"}), "proposal accepts") + assert_true(not state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 3, "state": "OPEN"}), "accepted proposal cannot reopen") + assert_eq(state.proposal_state, "ACCEPTED", "illegal proposal transition cannot mutate state") + state.needs_resync = false + var declined := MatchmakingState.new() + declined.begin_queue("ticket-proposal-declined", "casual") + assert_true(declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 1, "state": "OPEN"}), "second proposal opens") + assert_true(declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 2, "state": "DECLINED"}), "second proposal declines") + assert_true(not declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 3, "state": "ACCEPTED"}), "declined proposal cannot accept") + + func test_assignment_lifecycle_has_explicit_connecting_and_live_states() -> void: var state := MatchmakingState.new() state.begin_queue("ticket-1", "ranked") diff --git a/multiplayer-next.md b/multiplayer-next.md index 6fc50395..420ef903 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1544,3 +1544,5 @@ Client ticket updates now enforce the versioned legal transition graph as well a Reconnect recovery now treats `COMPLETED` as terminal, avoiding a needless queue read after a finished match. UI policy tests cover the complete expanded lifecycle, including the completed-to-new-search boundary. 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.