diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 78284ddb..98695dc1 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -474,7 +474,7 @@ func _set_websocket_status(status: String) -> void: websocket_status_changed.emit(status) if status == "CONNECTED": if not state.ticket_id.is_empty() and state.phase not in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]: - var resource_id := state.proposal_id if not state.proposal_id.is_empty() else state.ticket_id + var resource_id := state.proposal_id if state.has_open_proposal() else state.ticket_id if _operation.is_empty(): _run_resync(resource_id) else: diff --git a/Game/scripts/matchmaking.gd b/Game/scripts/matchmaking.gd index edc7a903..57289ac6 100644 --- a/Game/scripts/matchmaking.gd +++ b/Game/scripts/matchmaking.gd @@ -41,7 +41,7 @@ func _process(delta: float) -> void: _recovery_poll_seconds += delta if _recovery_poll_seconds >= RECOVERY_POLL_SECONDS: _recovery_poll_seconds = 0.0 - var recovery_err := ControlPlaneClient.recover_proposal(ControlPlaneClient.state.proposal_id) if not ControlPlaneClient.state.proposal_id.is_empty() else ControlPlaneClient.recover_queue(ControlPlaneClient.state.ticket_id) + var recovery_err := ControlPlaneClient.recover_proposal(ControlPlaneClient.state.proposal_id) if ControlPlaneClient.state.has_open_proposal() else ControlPlaneClient.recover_queue(ControlPlaneClient.state.ticket_id) if recovery_err != OK and recovery_err != ERR_BUSY: _on_local_error("State recovery unavailable: %s" % error_string(recovery_err)) if ControlPlaneClient.state.phase == MatchmakingState.QUEUED and _heartbeat_seconds >= HEARTBEAT_SECONDS: diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index 3648bcb6..0c98f56c 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -217,6 +217,10 @@ func can_cancel() -> bool: return phase == QUEUED or phase == PROPOSED or phase == ALLOCATING +func has_open_proposal() -> bool: + return not proposal_id.is_empty() and proposal_state == "OPEN" + + func waited_seconds(now_unix: int) -> int: if enqueued_at_unix <= 0: return 0 diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index b42136af..e3ab1ba0 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -129,6 +129,15 @@ func test_proposal_projection_rejects_illegal_higher_revision_transitions() -> v assert_true(not declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 3, "state": "ACCEPTED"}), "declined proposal cannot accept") +func test_terminal_proposal_is_not_an_active_recovery_target() -> void: + var state := MatchmakingState.new() + state.begin_queue("ticket-terminal-proposal", "casual") + assert_true(state.apply_proposal_update({"proposal_id": "proposal-terminal", "revision": 1, "state": "OPEN"}), "proposal opens") + assert_true(state.has_open_proposal(), "open proposal is an active recovery target") + assert_true(state.apply_proposal_update({"proposal_id": "proposal-terminal", "revision": 2, "state": "EXPIRED"}), "proposal expires") + assert_true(not state.has_open_proposal(), "terminal proposal uses ticket recovery instead") + + 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 d03226a1..4c59d4c7 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1549,4 +1549,6 @@ Proposal updates now enforce the documented `OPEN → ACCEPTED/DECLINED/EXPIRED/ 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. +Recovery targeting now follows the same boundary: only an `OPEN` proposal is polled as a proposal; terminal proposal outcomes fall back to the ticket recovery endpoint. This prevents repeated reads of a finished proposal from starving recovery of the requeued ticket. + 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.