fix(multiplayer): recover requeued tickets

This commit is contained in:
Josh Creek
2026-09-01 22:06:30 +01:00
parent 0c4ad6a5aa
commit c08c761af3
5 changed files with 17 additions and 2 deletions
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+4
View File
@@ -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
@@ -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")
+2
View File
@@ -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 proposals 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.