mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 22:22:03 +00:00
fix(multiplayer): converge events through REST
This commit is contained in:
@@ -14,6 +14,7 @@ signal assignment_connection_failed(detail: String)
|
||||
|
||||
const DEFAULT_BASE_URL := "http://127.0.0.1:8080"
|
||||
const PERSIST_PATH := "user://matchmaking_state.cfg"
|
||||
const AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS := 5.0
|
||||
|
||||
var base_url := DEFAULT_BASE_URL
|
||||
var access_token := ""
|
||||
@@ -33,6 +34,8 @@ var _websocket: WebSocketPeer
|
||||
var _websocket_status := "DISCONNECTED"
|
||||
var _websocket_retry_seconds := 0.0
|
||||
var _websocket_backoff := 1.0
|
||||
var _authoritative_recovery_seconds := AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
|
||||
var _pending_proposal_id := ""
|
||||
var _pending_assignment_match_id := ""
|
||||
var _pending_resync_resource_id := ""
|
||||
|
||||
@@ -74,10 +77,15 @@ func _process(_delta: float) -> void:
|
||||
_websocket_retry_seconds = _websocket_backoff
|
||||
_websocket_backoff = minf(_websocket_backoff * 2.0, 30.0)
|
||||
connect_event_stream()
|
||||
if not _pending_assignment_match_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
|
||||
if not _pending_proposal_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
|
||||
var proposal_id := _pending_proposal_id
|
||||
_pending_proposal_id = ""
|
||||
recover_proposal(proposal_id)
|
||||
elif not _pending_assignment_match_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
|
||||
var match_id := _pending_assignment_match_id
|
||||
_pending_assignment_match_id = ""
|
||||
fetch_assignment(match_id)
|
||||
_poll_authoritative_recovery(_delta)
|
||||
|
||||
|
||||
func configure(url: String, token: String) -> bool:
|
||||
@@ -417,8 +425,10 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
|
||||
_last_mutation_retryable = _last_mutation.get("operation", "") == operation
|
||||
if operation == "ranked_profile":
|
||||
ranked_profile.set_error("Ranked profile request failed")
|
||||
elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover":
|
||||
elif operation == "queue_create":
|
||||
state.fail("Control-plane request failed")
|
||||
elif operation == "queue_recover" or operation == "proposal_recover":
|
||||
state.set_notice("Could not refresh matchmaking state; retrying")
|
||||
else:
|
||||
state.set_notice("Control-plane request failed; retrying is safe")
|
||||
request_failed.emit(operation, response_code, "network error")
|
||||
@@ -428,8 +438,10 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
|
||||
_last_mutation_retryable = _last_mutation.get("operation", "") == operation
|
||||
if operation == "ranked_profile":
|
||||
ranked_profile.set_error("Ranked profile returned invalid JSON")
|
||||
elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover":
|
||||
elif operation == "queue_create":
|
||||
state.fail("Control-plane returned invalid JSON")
|
||||
elif operation == "queue_recover" or operation == "proposal_recover":
|
||||
state.set_notice("Could not refresh matchmaking state; retrying")
|
||||
else:
|
||||
state.set_notice("Control-plane returned invalid JSON; retrying is safe")
|
||||
request_failed.emit(operation, response_code, "invalid JSON")
|
||||
@@ -494,6 +506,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
|
||||
return
|
||||
if operation.begins_with("queue_"):
|
||||
if state.apply_ticket_update(normalize_ticket(payload), operation == "queue_recover"):
|
||||
_queue_proposal_if_ready(payload)
|
||||
_queue_assignment_if_ready(payload)
|
||||
elif operation.begins_with("proposal_"):
|
||||
state.apply_proposal_update(normalize_proposal(payload))
|
||||
@@ -538,7 +551,8 @@ func _handle_websocket_packet(packet: PackedByteArray) -> void:
|
||||
elif event_name == "proposal_changed":
|
||||
var proposal_update := event.duplicate(true)
|
||||
proposal_update["proposal_id"] = String(event["resource_id"])
|
||||
state.apply_proposal_update(proposal_update)
|
||||
if state.prepare_proposal_recovery(String(proposal_update["proposal_id"])):
|
||||
state.apply_proposal_update(proposal_update)
|
||||
elif event_name == "assignment_changed":
|
||||
state.mark_assignment_ready()
|
||||
_pending_assignment_match_id = String(event["match_id"])
|
||||
@@ -599,6 +613,13 @@ static func _valid_queue_response(payload: Dictionary) -> bool:
|
||||
return false
|
||||
if String(payload["state"]) not in ["ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "FAILED", "CANCELLED"]:
|
||||
return false
|
||||
if payload.has("proposal_id"):
|
||||
if not payload["proposal_id"] is String or not is_valid_resource_id(String(payload["proposal_id"])):
|
||||
return false
|
||||
if String(payload["state"]) != "PROPOSED":
|
||||
return false
|
||||
if payload.has("match_id") and payload.has("proposal_id"):
|
||||
return false
|
||||
if not _valid_revision(payload["revision"]):
|
||||
return false
|
||||
return payload["enqueued_at"] is String and is_valid_rfc3339_timestamp(String(payload["enqueued_at"])) and payload["expires_at"] is String and is_valid_rfc3339_timestamp(String(payload["expires_at"]))
|
||||
@@ -612,6 +633,26 @@ func _queue_assignment_if_ready(payload: Dictionary) -> void:
|
||||
_pending_assignment_match_id = match_id
|
||||
|
||||
|
||||
func _queue_proposal_if_ready(payload: Dictionary) -> void:
|
||||
if String(payload.get("state", "")) != "PROPOSED":
|
||||
return
|
||||
var proposal_id := String(payload.get("proposal_id", ""))
|
||||
if is_valid_resource_id(proposal_id) and state.prepare_proposal_recovery(proposal_id):
|
||||
_pending_proposal_id = proposal_id
|
||||
|
||||
|
||||
func _poll_authoritative_recovery(delta: float) -> void:
|
||||
if auth_expired or not is_valid_access_token(access_token) or state.ticket_id.is_empty() or state.phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]:
|
||||
_authoritative_recovery_seconds = AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
|
||||
return
|
||||
_authoritative_recovery_seconds -= maxf(0.0, delta)
|
||||
if _authoritative_recovery_seconds > 0.0 or not _operation.is_empty():
|
||||
return
|
||||
_authoritative_recovery_seconds = AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
|
||||
var resource_id := state.proposal_id if state.has_open_proposal() else state.ticket_id
|
||||
_run_resync(resource_id)
|
||||
|
||||
|
||||
static func _valid_proposal_response(payload: Dictionary) -> bool:
|
||||
if not _valid_response_opaque_id(payload, "proposal_id") or not payload.has("expires_at") or not payload["expires_at"] is String or not is_valid_rfc3339_timestamp(String(payload["expires_at"])) or not payload.has("participants") or not payload["participants"] is Array:
|
||||
return false
|
||||
|
||||
@@ -161,6 +161,19 @@ func apply_proposal_update(update: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func prepare_proposal_recovery(new_proposal_id: String) -> bool:
|
||||
if not _valid_opaque_id(new_proposal_id):
|
||||
return false
|
||||
if proposal_id == new_proposal_id:
|
||||
return true
|
||||
if proposal_state not in ["", "DECLINED", "EXPIRED", "CANCELLED"]:
|
||||
return false
|
||||
proposal_id = new_proposal_id
|
||||
proposal_revision = 0
|
||||
proposal_state = ""
|
||||
return true
|
||||
|
||||
|
||||
func mark_assignment_ready() -> void:
|
||||
phase = ASSIGNMENT_READY
|
||||
message = "Match server is ready"
|
||||
|
||||
@@ -153,6 +153,20 @@ func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight()
|
||||
client.free()
|
||||
|
||||
|
||||
func test_transient_rest_recovery_failure_does_not_end_matchmaking() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
client.state.begin_queue("ticket_recovery_123", "casual")
|
||||
client._operation = "queue_recover"
|
||||
client._on_request_completed(HTTPRequest.RESULT_CANT_CONNECT, 0, PackedStringArray(), PackedByteArray())
|
||||
assert_eq(client.state.phase, MatchmakingState.QUEUED, "network failure during recovery keeps the active search")
|
||||
assert_true(client.state.message.contains("retrying"), "recovery failure remains visible and retryable")
|
||||
client._operation = "proposal_recover"
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), "[]".to_utf8_buffer())
|
||||
assert_eq(client.state.phase, MatchmakingState.QUEUED, "malformed transient recovery response does not become terminal")
|
||||
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")
|
||||
@@ -228,6 +242,17 @@ func test_queue_response_requires_the_complete_contract_shape() -> void:
|
||||
assert_true(not ControlPlaneClient._valid_queue_response(premature_match), "pre-match ticket cannot smuggle a match identity")
|
||||
assigned["match_id"] = "match/unsafe"
|
||||
assert_true(not ControlPlaneClient._valid_queue_response(assigned), "unsafe recovered match identity is rejected")
|
||||
var proposed := valid.duplicate()
|
||||
proposed["state"] = "PROPOSED"
|
||||
proposed["proposal_id"] = "proposal_12345678"
|
||||
assert_true(ControlPlaneClient._valid_queue_response(proposed), "recovered proposed ticket carries its proposal lookup identity")
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
client.state.begin_queue("ticket_1234567890", "casual")
|
||||
client._queue_proposal_if_ready(proposed)
|
||||
assert_eq(client._pending_proposal_id, "proposal_12345678", "recovered proposal is queued for authoritative fetch")
|
||||
assert_eq(client.state.proposal_id, "proposal_12345678", "recovered proposal identity becomes the active projection")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_proposal_response_requires_structured_unique_participants() -> void:
|
||||
|
||||
@@ -156,6 +156,10 @@ func test_terminal_proposal_is_not_an_active_recovery_target() -> void:
|
||||
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")
|
||||
assert_true(state.prepare_proposal_recovery("proposal_second_123"), "a later proposal can replace a terminal proposal identity")
|
||||
assert_true(state.apply_proposal_update({"proposal_id": "proposal_second_123", "revision": 4, "state": "OPEN"}), "recovered later proposal accepts its authoritative revision")
|
||||
assert_true(state.has_open_proposal(), "later proposal becomes the active recovery target")
|
||||
assert_true(not state.prepare_proposal_recovery("proposal_third_1234"), "an open proposal cannot be replaced by another identity")
|
||||
|
||||
|
||||
func test_assignment_lifecycle_has_explicit_connecting_and_live_states() -> void:
|
||||
|
||||
Reference in New Issue
Block a user