diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index dad4c6bc..9efc1d56 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -186,6 +186,14 @@ func recover_proposal(proposal_id: String) -> Error: return _start_request("proposal_recover", HTTPClient.METHOD_GET, "/v1/proposals/" + proposal_id, {}, "") +static func resync_target(resource_id: String, ticket_id: String, proposal_id: String, proposal_open: bool) -> String: + if resource_id == ticket_id and not ticket_id.is_empty(): + return ticket_id + if resource_id == proposal_id and not proposal_id.is_empty(): + return proposal_id if proposal_open else ticket_id + return "" + + func fetch_ranked_profile() -> Error: return _start_request("ranked_profile", HTTPClient.METHOD_GET, "/v1/profile/ranked", {}, "") @@ -492,9 +500,10 @@ func _run_pending_resync() -> void: func _run_resync(resource_id: String) -> void: - if resource_id == state.ticket_id and not state.ticket_id.is_empty(): + var target := resync_target(resource_id, state.ticket_id, state.proposal_id, state.has_open_proposal()) + if target == state.ticket_id and not state.ticket_id.is_empty(): recover_queue(state.ticket_id) - elif resource_id == state.proposal_id and not state.proposal_id.is_empty(): + elif target == state.proposal_id and not state.proposal_id.is_empty(): recover_proposal(state.proposal_id) diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 1ab1e075..b527b0d2 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -85,6 +85,11 @@ func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() client.free() +func test_resync_of_terminal_proposal_recovers_the_ticket() -> void: + assert_eq(ControlPlaneClient.resync_target("proposal-terminal-resync", "ticket-terminal-resync", "proposal-terminal-resync", false), "ticket-terminal-resync", "terminal proposal resync targets the requeued ticket") + assert_eq(ControlPlaneClient.resync_target("proposal-terminal-resync", "ticket-terminal-resync", "proposal-terminal-resync", true), "proposal-terminal-resync", "open proposal resync retains the proposal target") + + func test_retryable_mutation_policy_only_retries_safe_failures() -> void: assert_true(ControlPlaneClient.is_retryable_mutation_response(0), "transport failure is retryable") assert_true(ControlPlaneClient.is_retryable_mutation_response(408), "request timeout is retryable") diff --git a/multiplayer-next.md b/multiplayer-next.md index b66c7fe9..bb7f2fa4 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1553,6 +1553,8 @@ Recovery targeting now follows the same boundary: only an `OPEN` proposal is pol Client queue/proposal expiry and enqueue epoch metadata now fail closed on malformed, negative, or fractional values instead of being silently coerced to zero. Adversarial metadata tests cover string, negative, and fractional timestamps. +All client resync entry points now apply the open-proposal boundary: a terminal proposal always recovers the durable ticket instead of polling the finished proposal. A direct-resync regression test covers this path. + 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. Client sessions now fail closed at the expiry boundary and proactively clear credentials before reconnects or authenticated requests. Boundary and malformed-expiry tests cover the lifecycle guard.