From 6ac2d0fbb12757895816489eca72dc550f84317b Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:54:10 +0100 Subject: [PATCH] fix(multiplayer): project accepted queue state --- Game/scripts/matchmaking.gd | 6 +++++- Game/scripts/matchmaking_state.gd | 3 ++- Game/tests/cases/test_control_plane_client.gd | 3 +++ Game/tests/cases/test_matchmaking_state.gd | 8 ++++++++ multiplayer-next.md | 2 ++ 5 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Game/scripts/matchmaking.gd b/Game/scripts/matchmaking.gd index d64e85b2..f18c6765 100644 --- a/Game/scripts/matchmaking.gd +++ b/Game/scripts/matchmaking.gd @@ -35,7 +35,7 @@ func _ready() -> void: func _process(delta: float) -> void: - if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ALLOCATING]: + if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING]: _elapsed_seconds += delta _heartbeat_seconds += delta _recovery_poll_seconds += delta @@ -149,6 +149,8 @@ static func phase_label(phase: String) -> String: return "Searching for players" MatchmakingState.PROPOSED: return "Match found — confirm" + MatchmakingState.ACCEPTED: + return "Match accepted — preparing server" MatchmakingState.ALLOCATING: return "Preparing match server" MatchmakingState.PROCESS_READY: @@ -181,6 +183,8 @@ func _render(snapshot: Dictionary) -> void: detail_label.text = "Waiting %.0fs · revision %d" % [waited, int(snapshot.get("revision", 0))] elif phase == MatchmakingState.PROPOSED: detail_label.text = "Review the proposal before the countdown expires" + elif phase == MatchmakingState.ACCEPTED: + detail_label.text = "All players accepted; preparing the match server" elif phase == MatchmakingState.IDLE: detail_label.text = "Choose a playlist to begin" cancel_button.visible = ControlPlaneClient.state.can_cancel() diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index b59aadd5..43d29fd4 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -11,6 +11,7 @@ signal resync_required(resource_id: String) const IDLE := "IDLE" const QUEUED := "QUEUED" const PROPOSED := "PROPOSED" +const ACCEPTED := "ACCEPTED" const ALLOCATING := "ALLOCATING" const PROCESS_READY := "PROCESS_READY" const ASSIGNMENT_READY := "ASSIGNMENT_READY" @@ -241,7 +242,7 @@ func _reset() -> void: func _is_ticket_state(value: String) -> bool: - return value in [QUEUED, PROPOSED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, CONNECTING, LIVE, CANCELLED, EXPIRED, FAILED] + return value in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, CONNECTING, LIVE, CANCELLED, EXPIRED, FAILED] func _has_string(value: Dictionary, key: String) -> bool: diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 8523176e..8e22ae64 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -46,6 +46,9 @@ func test_ticket_normalization_derives_authoritative_enqueue_time() -> void: func test_websocket_event_validation_requires_contract_specific_fields() -> void: var envelope := {"event": "state_changed", "revision": 1, "resource_id": "ticket-1", "occurred_at": "2026-08-31T12:00:00Z", "state": "QUEUED"} assert_true(ControlPlaneClient._valid_websocket_event(envelope), "valid state event is accepted") + var accepted := envelope.duplicate() + accepted["state"] = "ACCEPTED" + assert_true(ControlPlaneClient._valid_websocket_event(accepted), "authoritative accepted queue event is accepted") var bad_state := envelope.duplicate() bad_state["state"] = "SECRET" assert_true(not ControlPlaneClient._valid_websocket_event(bad_state), "unknown state event is rejected") diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index 5d76fe62..4925ae7b 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -11,6 +11,14 @@ func test_ticket_projection_accepts_ordered_updates_and_exposes_cancel() -> void assert_true(state.can_cancel(), "authoritative cancel remains available before allocation") +func test_ticket_projection_accepts_authoritative_accepted_phase() -> void: + var state := MatchmakingState.new() + assert_true(state.begin_queue("ticket-accepted", "ranked"), "queue setup succeeds") + assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 1, "state": "ACCEPTED", "playlist": "ranked"}), "accepted queue phase is valid") + assert_eq(state.phase, MatchmakingState.ACCEPTED, "accepted phase remains visible instead of forcing resync") + assert_true(not state.can_cancel(), "accepted match cannot be cancelled as a queue ticket") + + func test_ticket_projection_rejects_gap_and_wrong_ticket_without_mutation() -> void: var state := MatchmakingState.new() assert_true(state.begin_queue("ticket-1", "casual"), "queue setup succeeds") diff --git a/multiplayer-next.md b/multiplayer-next.md index 57b3b3ef..7f0d164d 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1534,3 +1534,5 @@ The ranked profile projection now also carries the active season's authoritative The versioned OpenAPI contract now declares the implemented `/profile/ranked` surface and its server-authoritative ranked profile schema, including optional active-season metadata. Contract tests reject omission of this operation, extra response fields, and credential leakage. 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.