mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 04:02:08 +00:00
fix(multiplayer): project complete queue lifecycle
This commit is contained in:
@@ -161,6 +161,12 @@ static func phase_label(phase: String) -> String:
|
|||||||
return "Connecting to match"
|
return "Connecting to match"
|
||||||
MatchmakingState.LIVE:
|
MatchmakingState.LIVE:
|
||||||
return "Match in progress"
|
return "Match in progress"
|
||||||
|
MatchmakingState.RESULT_PENDING:
|
||||||
|
return "Recording match result"
|
||||||
|
MatchmakingState.COMPLETED:
|
||||||
|
return "Match complete"
|
||||||
|
MatchmakingState.ASSIGNED:
|
||||||
|
return "Match assigned"
|
||||||
MatchmakingState.CANCELLED:
|
MatchmakingState.CANCELLED:
|
||||||
return "Search cancelled"
|
return "Search cancelled"
|
||||||
MatchmakingState.EXPIRED:
|
MatchmakingState.EXPIRED:
|
||||||
@@ -185,6 +191,10 @@ func _render(snapshot: Dictionary) -> void:
|
|||||||
detail_label.text = "Review the proposal before the countdown expires"
|
detail_label.text = "Review the proposal before the countdown expires"
|
||||||
elif phase == MatchmakingState.ACCEPTED:
|
elif phase == MatchmakingState.ACCEPTED:
|
||||||
detail_label.text = "All players accepted; preparing the match server"
|
detail_label.text = "All players accepted; preparing the match server"
|
||||||
|
elif phase == MatchmakingState.RESULT_PENDING:
|
||||||
|
detail_label.text = "The server is confirming the final result"
|
||||||
|
elif phase == MatchmakingState.COMPLETED:
|
||||||
|
detail_label.text = "The match result has been recorded"
|
||||||
elif phase == MatchmakingState.IDLE:
|
elif phase == MatchmakingState.IDLE:
|
||||||
detail_label.text = "Choose a playlist to begin"
|
detail_label.text = "Choose a playlist to begin"
|
||||||
cancel_button.visible = ControlPlaneClient.state.can_cancel()
|
cancel_button.visible = ControlPlaneClient.state.can_cancel()
|
||||||
@@ -197,8 +207,8 @@ func _render(snapshot: Dictionary) -> void:
|
|||||||
|
|
||||||
|
|
||||||
static func _is_terminal(phase: String) -> bool:
|
static func _is_terminal(phase: String) -> bool:
|
||||||
return phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE]
|
return phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]
|
||||||
|
|
||||||
|
|
||||||
static func _can_start_new_search(phase: String) -> bool:
|
static func _can_start_new_search(phase: String) -> bool:
|
||||||
return phase == MatchmakingState.IDLE or phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED]
|
return phase == MatchmakingState.IDLE or phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const PROCESS_READY := "PROCESS_READY"
|
|||||||
const ASSIGNMENT_READY := "ASSIGNMENT_READY"
|
const ASSIGNMENT_READY := "ASSIGNMENT_READY"
|
||||||
const CONNECTING := "CONNECTING"
|
const CONNECTING := "CONNECTING"
|
||||||
const LIVE := "LIVE"
|
const LIVE := "LIVE"
|
||||||
|
const ASSIGNED := "ASSIGNED"
|
||||||
|
const RESULT_PENDING := "RESULT_PENDING"
|
||||||
|
const COMPLETED := "COMPLETED"
|
||||||
const CANCELLED := "CANCELLED"
|
const CANCELLED := "CANCELLED"
|
||||||
const EXPIRED := "EXPIRED"
|
const EXPIRED := "EXPIRED"
|
||||||
const FAILED := "FAILED"
|
const FAILED := "FAILED"
|
||||||
@@ -185,7 +188,7 @@ func restore_snapshot(saved: Dictionary) -> bool:
|
|||||||
proposal_revision = maxi(0, int(saved.get("proposal_revision", 0)))
|
proposal_revision = maxi(0, int(saved.get("proposal_revision", 0)))
|
||||||
proposal_state = String(saved.get("proposal_state", ""))
|
proposal_state = String(saved.get("proposal_state", ""))
|
||||||
message = "Recovering authoritative matchmaking state"
|
message = "Recovering authoritative matchmaking state"
|
||||||
needs_resync = phase != CANCELLED and phase != EXPIRED and phase != FAILED
|
needs_resync = phase != CANCELLED and phase != EXPIRED and phase != FAILED and phase != COMPLETED
|
||||||
_emit_changed()
|
_emit_changed()
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@@ -242,7 +245,7 @@ func _reset() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _is_ticket_state(value: String) -> bool:
|
func _is_ticket_state(value: String) -> bool:
|
||||||
return value in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, CONNECTING, LIVE, CANCELLED, EXPIRED, FAILED]
|
return value in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED]
|
||||||
|
|
||||||
|
|
||||||
func _has_string(value: Dictionary, key: String) -> bool:
|
func _has_string(value: Dictionary, key: String) -> bool:
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void
|
|||||||
var accepted := envelope.duplicate()
|
var accepted := envelope.duplicate()
|
||||||
accepted["state"] = "ACCEPTED"
|
accepted["state"] = "ACCEPTED"
|
||||||
assert_true(ControlPlaneClient._valid_websocket_event(accepted), "authoritative accepted queue event is accepted")
|
assert_true(ControlPlaneClient._valid_websocket_event(accepted), "authoritative accepted queue event is accepted")
|
||||||
|
for phase in ["ASSIGNED", "RESULT_PENDING", "COMPLETED"]:
|
||||||
|
var lifecycle := envelope.duplicate()
|
||||||
|
lifecycle["state"] = phase
|
||||||
|
assert_true(ControlPlaneClient._valid_websocket_event(lifecycle), "post-match queue event is accepted: " + phase)
|
||||||
var bad_state := envelope.duplicate()
|
var bad_state := envelope.duplicate()
|
||||||
bad_state["state"] = "SECRET"
|
bad_state["state"] = "SECRET"
|
||||||
assert_true(not ControlPlaneClient._valid_websocket_event(bad_state), "unknown state event is rejected")
|
assert_true(not ControlPlaneClient._valid_websocket_event(bad_state), "unknown state event is rejected")
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ func test_ticket_projection_accepts_authoritative_accepted_phase() -> void:
|
|||||||
assert_true(not state.can_cancel(), "accepted match cannot be cancelled as a queue ticket")
|
assert_true(not state.can_cancel(), "accepted match cannot be cancelled as a queue ticket")
|
||||||
|
|
||||||
|
|
||||||
|
func test_ticket_projection_accepts_post_match_lifecycle_states() -> void:
|
||||||
|
for phase in ["ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]:
|
||||||
|
var state := MatchmakingState.new()
|
||||||
|
assert_true(state.begin_queue("ticket-" + phase, "casual"), "queue setup succeeds for " + phase)
|
||||||
|
assert_true(state.apply_ticket_update({"ticket_id": "ticket-" + phase, "revision": 1, "state": phase, "playlist": "casual"}), "post-match phase is valid: " + phase)
|
||||||
|
assert_eq(state.phase, phase, "post-match phase remains visible: " + phase)
|
||||||
|
assert_true(not state.can_cancel(), "post-match phase cannot cancel: " + phase)
|
||||||
|
|
||||||
|
|
||||||
func test_ticket_projection_rejects_gap_and_wrong_ticket_without_mutation() -> void:
|
func test_ticket_projection_rejects_gap_and_wrong_ticket_without_mutation() -> void:
|
||||||
var state := MatchmakingState.new()
|
var state := MatchmakingState.new()
|
||||||
assert_true(state.begin_queue("ticket-1", "casual"), "queue setup succeeds")
|
assert_true(state.begin_queue("ticket-1", "casual"), "queue setup succeeds")
|
||||||
|
|||||||
@@ -1536,3 +1536,5 @@ The versioned OpenAPI contract now declares the implemented `/profile/ranked` su
|
|||||||
The Godot client now defers reconnect-triggered authoritative recovery when an HTTP mutation is still in flight, closing the `ERR_BUSY` recovery-drop race. An adversarial client test verifies that the active ticket remains queued for recovery rather than silently staying stale.
|
The Godot client now defers reconnect-triggered authoritative recovery when an HTTP mutation is still in flight, closing the `ERR_BUSY` recovery-drop race. An adversarial client test verifies that the active ticket remains queued for recovery rather than silently staying stale.
|
||||||
|
|
||||||
The Godot queue projection now includes the contract's `ACCEPTED` ticket phase. Accepted events are no longer rejected as an unknown state; the UI keeps the accepted status visible and proceeds through allocation recovery. State and WebSocket vocabulary tests cover the transition.
|
The Godot queue projection now includes the contract's `ACCEPTED` ticket phase. Accepted events are no longer rejected as an unknown state; the UI keeps the accepted status visible and proceeds through allocation recovery. State and WebSocket vocabulary tests cover the transition.
|
||||||
|
|
||||||
|
The queue projection now also accepts the contract's post-allocation/result states (`ASSIGNED`, `RESULT_PENDING`, and `COMPLETED`). These states remain visible, cannot issue queue cancellation, and completed matches return the search action to a valid new-search state; adversarial lifecycle and WebSocket vocabulary tests cover them.
|
||||||
|
|||||||
Reference in New Issue
Block a user