From 0c4ad6a5aa37b93b7a021e7870af82805a55d4ad Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:04:53 +0100 Subject: [PATCH] fix(multiplayer): preserve proposal requeues --- Game/scripts/matchmaking_state.gd | 9 ++++++--- Game/tests/cases/test_matchmaking_state.gd | 15 +++++++++++---- multiplayer-next.md | 2 ++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index 95f38ea5..3648bcb6 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -124,17 +124,20 @@ func apply_proposal_update(update: Dictionary) -> bool: elif incoming_proposal_state == "DECLINED": if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state): return _request_resync(proposal_id) - phase = FAILED + if phase == PROPOSED: + phase = QUEUED 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 + if phase == PROPOSED: + phase = QUEUED 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 + if phase == PROPOSED: + phase = QUEUED message = "The match proposal was cancelled" else: return _request_resync(proposal_id) diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index c30880b6..b42136af 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -96,15 +96,22 @@ func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void: assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 1, "state": "OPEN"}), "open proposal applies") assert_eq(state.phase, MatchmakingState.PROPOSED, "open proposal is visible") assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 2, "state": "DECLINED"}), "declined proposal applies") - assert_eq(state.phase, MatchmakingState.FAILED, "decline is terminal and visible") - assert_true(not state.can_cancel(), "terminal proposal cannot issue queue cancel") + assert_eq(state.phase, MatchmakingState.QUEUED, "decline returns the requeued ticket to search") + assert_true(state.can_cancel(), "requeued ticket can be cancelled") var expired := MatchmakingState.new() expired.begin_queue("ticket-2", "casual") assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 1, "state": "OPEN"}), "second proposal opens") assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 2, "state": "EXPIRED"}), "expired proposal applies") - assert_eq(expired.phase, MatchmakingState.EXPIRED, "expiry is visible") - assert_true(not expired.can_cancel(), "expired proposal cannot be cancelled") + assert_eq(expired.phase, MatchmakingState.QUEUED, "expiry returns the requeued ticket to search") + assert_true(expired.can_cancel(), "requeued ticket can be cancelled") + + var cancelled := MatchmakingState.new() + cancelled.begin_queue("ticket-3", "casual") + assert_true(cancelled.apply_proposal_update({"proposal_id": "proposal-3", "revision": 1, "state": "OPEN"}), "third proposal opens") + cancelled.phase = MatchmakingState.CANCELLED + assert_true(cancelled.apply_proposal_update({"proposal_id": "proposal-3", "revision": 2, "state": "DECLINED"}), "decline after ticket cancellation is accepted") + assert_eq(cancelled.phase, MatchmakingState.CANCELLED, "proposal decline cannot resurrect a cancelled ticket") func test_proposal_projection_rejects_illegal_higher_revision_transitions() -> void: diff --git a/multiplayer-next.md b/multiplayer-next.md index 4f26d308..d03226a1 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1547,4 +1547,6 @@ Ticket, proposal, and WebSocket revisions now fail closed unless they are finite 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. +Proposal decline/expiry/cancellation now leaves a still-proposed ticket in `QUEUED`, matching the durable server requeue transaction; the proposal’s terminal message remains visible without making the ticket terminal. A cancelled ticket is never resurrected by a later proposal event, covered by adversarial cross-aggregate tests. + 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.