mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 11:02:03 +00:00
fix(multiplayer): recover assignment handoff
This commit is contained in:
@@ -493,7 +493,8 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
|
||||
request_failed.emit(operation, response_code, "invalid proposal identifier")
|
||||
return
|
||||
if operation.begins_with("queue_"):
|
||||
state.apply_ticket_update(normalize_ticket(payload))
|
||||
if state.apply_ticket_update(normalize_ticket(payload), operation == "queue_recover"):
|
||||
_queue_assignment_if_ready(payload)
|
||||
elif operation.begins_with("proposal_"):
|
||||
state.apply_proposal_update(normalize_proposal(payload))
|
||||
elif operation == "ranked_profile":
|
||||
@@ -518,6 +519,18 @@ func _handle_websocket_packet(packet: PackedByteArray) -> void:
|
||||
websocket_event.emit(event)
|
||||
var event_name := String(event["event"])
|
||||
if event_name == "state_changed":
|
||||
# Allocation and match lifecycle rows are keyed by match ID, not ticket
|
||||
# ID. Recover the owner-scoped ticket projection instead of feeding the
|
||||
# match revision/resource into the ticket reducer. ASSIGNMENT_READY also
|
||||
# carries the durable lookup key, so the assignment fetch can follow the
|
||||
# recovery request without depending on a circular assignment_changed
|
||||
# notification from the assignment GET itself.
|
||||
if event.has("match_id"):
|
||||
var match_id := String(event["match_id"])
|
||||
_on_resync_required(state.ticket_id)
|
||||
if String(event["state"]) == "ASSIGNMENT_READY":
|
||||
_pending_assignment_match_id = match_id
|
||||
return
|
||||
var update := event.duplicate(true)
|
||||
update["ticket_id"] = String(event["resource_id"])
|
||||
if not state.apply_ticket_update(update):
|
||||
@@ -549,7 +562,11 @@ static func _valid_websocket_event(event: Dictionary) -> bool:
|
||||
if event_name == "error":
|
||||
return event.has("code") and String(event["code"]) in ["REVISION_GAP", "NOT_AUTHORISED", "INVALID_STATE", "RATE_LIMITED"]
|
||||
if event_name == "state_changed":
|
||||
return event.has("state") and String(event["state"]) in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]
|
||||
if not event.has("state") or String(event["state"]) not in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
|
||||
return false
|
||||
if event.has("match_id"):
|
||||
return event["match_id"] is String and is_valid_resource_id(String(event["match_id"])) and String(event["match_id"]) == String(event["resource_id"])
|
||||
return true
|
||||
if event_name == "proposal_changed":
|
||||
return event.has("state") and String(event["state"]) in ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]
|
||||
return false
|
||||
@@ -577,11 +594,24 @@ static func _valid_queue_response(payload: Dictionary) -> bool:
|
||||
return false
|
||||
if not payload["state"] is String or not String(payload["state"]) in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
|
||||
return false
|
||||
if payload.has("match_id"):
|
||||
if not payload["match_id"] is String or not is_valid_resource_id(String(payload["match_id"])):
|
||||
return false
|
||||
if String(payload["state"]) not in ["ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "FAILED", "CANCELLED"]:
|
||||
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"]))
|
||||
|
||||
|
||||
func _queue_assignment_if_ready(payload: Dictionary) -> void:
|
||||
if String(payload.get("state", "")) != "ASSIGNMENT_READY":
|
||||
return
|
||||
var match_id := String(payload.get("match_id", ""))
|
||||
if is_valid_resource_id(match_id):
|
||||
_pending_assignment_match_id = match_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
|
||||
|
||||
@@ -48,7 +48,7 @@ func begin_queue(new_ticket_id: String, new_playlist: String) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func apply_ticket_update(update: Dictionary) -> bool:
|
||||
func apply_ticket_update(update: Dictionary, authoritative_snapshot: bool = false) -> bool:
|
||||
if not _has_string(update, "ticket_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"):
|
||||
return _request_resync(self.ticket_id)
|
||||
if update.has("playlist") and not _valid_playlist(String(update["playlist"])):
|
||||
@@ -75,12 +75,15 @@ func apply_ticket_update(update: Dictionary) -> bool:
|
||||
if update.has("enqueued_at_unix"):
|
||||
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
|
||||
return true
|
||||
if incoming_revision > revision + 1:
|
||||
if incoming_revision > revision + 1 and not authoritative_snapshot:
|
||||
return _request_resync(self.ticket_id)
|
||||
var incoming_state := String(update["state"])
|
||||
if not _is_ticket_state(incoming_state):
|
||||
return _request_resync(self.ticket_id)
|
||||
if not _is_legal_ticket_transition(phase, incoming_state):
|
||||
if authoritative_snapshot:
|
||||
if not _can_reach_ticket_state(phase, incoming_state):
|
||||
return _request_resync(self.ticket_id)
|
||||
elif not _is_legal_ticket_transition(phase, incoming_state):
|
||||
return _request_resync(self.ticket_id)
|
||||
revision = incoming_revision
|
||||
phase = incoming_state
|
||||
@@ -319,6 +322,24 @@ func _is_legal_ticket_transition(from: String, to: String) -> bool:
|
||||
return transitions.has(from) and to in transitions[from]
|
||||
|
||||
|
||||
func _can_reach_ticket_state(from: String, to: String) -> bool:
|
||||
if from == to:
|
||||
return true
|
||||
var pending: Array[String] = [from]
|
||||
var visited := {}
|
||||
visited[from] = true
|
||||
while not pending.is_empty():
|
||||
var current: String = pending.pop_front()
|
||||
for candidate in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED]:
|
||||
if visited.has(candidate) or not _is_legal_ticket_transition(current, candidate):
|
||||
continue
|
||||
if candidate == to:
|
||||
return true
|
||||
visited[candidate] = true
|
||||
pending.append(candidate)
|
||||
return false
|
||||
|
||||
|
||||
func _is_legal_proposal_transition(from: String, to: String) -> bool:
|
||||
if from.is_empty():
|
||||
return to == "OPEN"
|
||||
|
||||
@@ -110,6 +110,37 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void
|
||||
var unsafe_resource := envelope.duplicate()
|
||||
unsafe_resource["resource_id"] = "ticket_123456789/secret"
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(unsafe_resource), "resource identifier with separators is rejected")
|
||||
var match_state := {"event": "state_changed", "revision": 4, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_1234567890"}
|
||||
assert_true(ControlPlaneClient._valid_websocket_event(match_state), "match-scoped lifecycle event is accepted")
|
||||
match_state["match_id"] = "different_match_123"
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(match_state), "match lifecycle identity must equal its resource identity")
|
||||
|
||||
|
||||
func test_match_assignment_ready_event_recovers_ticket_and_schedules_assignment_fetch() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.state.begin_queue("ticket_assignment_1", "casual"), "queue setup succeeds")
|
||||
client._operation = "queue_heartbeat"
|
||||
var event := {"event": "state_changed", "revision": 4, "resource_id": "match_assignment_1", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_assignment_1"}
|
||||
client._handle_websocket_packet(JSON.stringify(event).to_utf8_buffer())
|
||||
assert_eq(client._pending_resync_resource_id, "ticket_assignment_1", "match event requests authoritative ticket recovery")
|
||||
assert_eq(client._pending_assignment_match_id, "match_assignment_1", "assignment lookup no longer depends on a prior assignment GET")
|
||||
assert_eq(client.state.ticket_id, "ticket_assignment_1", "match resource is never projected as a ticket identity")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_recovered_assignment_ready_ticket_schedules_fetch_after_missed_revisions() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
client.player_id = "player_1234567890"
|
||||
client.state.begin_queue("ticket_assignment_1", "casual")
|
||||
assert_true(client.state.apply_ticket_update({"ticket_id": "ticket_assignment_1", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "proposal setup applies")
|
||||
client._operation = "queue_recover"
|
||||
var recovered := {"ticket_id": "ticket_assignment_1", "player_id": "player_1234567890", "match_id": "match_assignment_1", "playlist": "casual", "state": "ASSIGNMENT_READY", "revision": 5, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"}
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(recovered).to_utf8_buffer())
|
||||
assert_eq(client.state.phase, MatchmakingState.ASSIGNMENT_READY, "REST recovery applies a forward authoritative snapshot")
|
||||
assert_eq(client._pending_assignment_match_id, "match_assignment_1", "recovered snapshot supplies the assignment lookup key")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() -> void:
|
||||
@@ -188,6 +219,15 @@ func test_queue_response_requires_the_complete_contract_shape() -> void:
|
||||
var malformed_player := valid.duplicate()
|
||||
malformed_player["player_id"] = "player/unsafe"
|
||||
assert_true(not ControlPlaneClient._valid_queue_response(malformed_player), "unsafe queue player id is rejected")
|
||||
var assigned := valid.duplicate()
|
||||
assigned["state"] = "ASSIGNMENT_READY"
|
||||
assigned["match_id"] = "match_1234567890"
|
||||
assert_true(ControlPlaneClient._valid_queue_response(assigned), "recovered assignment-ready ticket carries its match lookup identity")
|
||||
var premature_match := valid.duplicate()
|
||||
premature_match["match_id"] = "match_1234567890"
|
||||
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")
|
||||
|
||||
|
||||
func test_proposal_response_requires_structured_unique_participants() -> void:
|
||||
|
||||
@@ -75,6 +75,17 @@ func test_higher_revision_cannot_jump_or_rewind_the_authoritative_lifecycle() ->
|
||||
assert_eq(state.phase, MatchmakingState.ACCEPTED, "illegal rewind cannot mutate phase")
|
||||
|
||||
|
||||
func test_authoritative_ticket_snapshot_can_cross_missed_forward_revisions_but_not_rewind() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-snapshot", "casual")
|
||||
assert_true(state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "incremental proposal applies")
|
||||
assert_true(state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 5, "state": "ASSIGNMENT_READY", "playlist": "casual"}, true), "owner-scoped REST snapshot crosses missed forward states")
|
||||
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "authoritative recovery reaches assignment readiness")
|
||||
state.needs_resync = false
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 6, "state": "QUEUED", "playlist": "casual"}, true), "authoritative snapshot cannot rewind an assigned match")
|
||||
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "rejected snapshot cannot mutate phase")
|
||||
|
||||
|
||||
func test_ticket_and_proposal_revisions_must_be_nonnegative_integers() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-revision", "casual")
|
||||
|
||||
Reference in New Issue
Block a user