From 39ebfce1bbc751bfe016a3a787e74810f4b559e4 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:33:33 +0100 Subject: [PATCH] feat: add revisioned matchmaking client state --- Game/scripts/matchmaking_state.gd | 176 +++++++++++++++++++++ Game/tests/cases/test_matchmaking_state.gd | 66 ++++++++ multiplayer-todo.md | 2 +- 3 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 Game/scripts/matchmaking_state.gd create mode 100644 Game/tests/cases/test_matchmaking_state.gd diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd new file mode 100644 index 00000000..389a515b --- /dev/null +++ b/Game/scripts/matchmaking_state.gd @@ -0,0 +1,176 @@ +class_name MatchmakingState +extends RefCounted + +# Client-side projection of the authenticated control-plane lifecycle. The +# server remains authoritative; this object only decides what the UI may show +# and refuses stale, gapped, or conflicting revisions instead of guessing. + +signal changed(snapshot: Dictionary) +signal resync_required(resource_id: String) + +const IDLE := "IDLE" +const QUEUED := "QUEUED" +const PROPOSED := "PROPOSED" +const ALLOCATING := "ALLOCATING" +const PROCESS_READY := "PROCESS_READY" +const ASSIGNMENT_READY := "ASSIGNMENT_READY" +const CONNECTING := "CONNECTING" +const LIVE := "LIVE" +const CANCELLED := "CANCELLED" +const EXPIRED := "EXPIRED" +const FAILED := "FAILED" + +var phase := IDLE +var ticket_id := "" +var playlist := "" +var revision := 0 +var expires_at_unix := 0 +var proposal_id := "" +var proposal_revision := 0 +var proposal_state := "" +var message := "" +var needs_resync := false + + +func begin_queue(new_ticket_id: String, new_playlist: String) -> bool: + if new_ticket_id.is_empty() or (new_playlist != "casual" and new_playlist != "ranked"): + return false + _reset() + ticket_id = new_ticket_id + playlist = new_playlist + phase = QUEUED + _emit_changed() + return true + + +func apply_ticket_update(update: Dictionary) -> bool: + if not _has_string(update, "ticket_id") or not update.has("revision") or not update.has("state"): + return _request_resync(ticket_id) + if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id: + return _request_resync(ticket_id) + var incoming_revision := int(update["revision"]) + if incoming_revision < revision: + return false + if incoming_revision == revision: + if _ticket_differs(update): + return _request_resync(ticket_id) + return true + if incoming_revision > revision + 1: + return _request_resync(ticket_id) + var incoming_state := String(update["state"]) + if not _is_ticket_state(incoming_state): + return _request_resync(ticket_id) + revision = incoming_revision + phase = incoming_state + if update.has("playlist"): + playlist = String(update["playlist"]) + if update.has("expires_at_unix"): + expires_at_unix = int(update["expires_at_unix"]) + if update.has("message"): + message = String(update["message"]) + _emit_changed() + return true + + +func apply_proposal_update(update: Dictionary) -> bool: + if not _has_string(update, "proposal_id") or not update.has("revision") or not update.has("state"): + return _request_resync(proposal_id) + var incoming_id := String(update["proposal_id"]) + if proposal_id.is_empty(): + proposal_id = incoming_id + elif proposal_id != incoming_id: + return _request_resync(proposal_id) + var incoming_revision := int(update["revision"]) + if incoming_revision < proposal_revision: + return false + if incoming_revision == proposal_revision and not proposal_state.is_empty(): + if String(update["state"]) != proposal_state: + return _request_resync(proposal_id) + return true + if not proposal_state.is_empty() and incoming_revision > proposal_revision + 1: + return _request_resync(proposal_id) + var incoming_proposal_state := String(update["state"]) + if incoming_proposal_state == "OPEN": + phase = PROPOSED + elif incoming_proposal_state == "ACCEPTED": + phase = ALLOCATING + elif incoming_proposal_state == "DECLINED": + phase = FAILED + message = "A player declined the match proposal" + elif incoming_proposal_state == "EXPIRED": + phase = EXPIRED + message = "The match proposal expired" + else: + return _request_resync(proposal_id) + proposal_revision = incoming_revision + proposal_state = incoming_proposal_state + if update.has("expires_at_unix"): + expires_at_unix = int(update["expires_at_unix"]) + _emit_changed() + return true + + +func mark_assignment_ready() -> void: + phase = ASSIGNMENT_READY + message = "Match server is ready" + _emit_changed() + + +func mark_connecting() -> void: + phase = CONNECTING + message = "Connecting to match server" + _emit_changed() + + +func mark_live() -> void: + phase = LIVE + message = "Match in progress" + _emit_changed() + + +func fail(reason: String) -> void: + phase = FAILED + message = reason if not reason.is_empty() else "Matchmaking failed" + _emit_changed() + + +func can_cancel() -> bool: + return phase == QUEUED or phase == PROPOSED or phase == ALLOCATING + + +func snapshot() -> Dictionary: + return {"phase": phase, "ticket_id": ticket_id, "playlist": playlist, "revision": revision, "expires_at_unix": expires_at_unix, "proposal_id": proposal_id, "proposal_revision": proposal_revision, "proposal_state": proposal_state, "message": message, "needs_resync": needs_resync} + + +func _ticket_differs(update: Dictionary) -> bool: + return String(update["state"]) != phase or (update.has("playlist") and String(update["playlist"]) != playlist) or (update.has("expires_at_unix") and int(update["expires_at_unix"]) != expires_at_unix) + + +func _request_resync(resource_id: String) -> bool: + needs_resync = true + resync_required.emit(resource_id) + return false + + +func _emit_changed() -> void: + changed.emit(snapshot()) + + +func _reset() -> void: + phase = IDLE + playlist = "" + revision = 0 + expires_at_unix = 0 + proposal_id = "" + proposal_revision = 0 + proposal_state = "" + message = "" + needs_resync = false + + +func _is_ticket_state(value: String) -> bool: + return value in [QUEUED, PROPOSED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, CONNECTING, LIVE, CANCELLED, EXPIRED, FAILED] + + +func _has_string(value: Dictionary, key: String) -> bool: + return value.has(key) and value[key] is String and not String(value[key]).is_empty() diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd new file mode 100644 index 00000000..a5f8e747 --- /dev/null +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -0,0 +1,66 @@ +extends "res://tests/test_case.gd" + +const MatchmakingState = preload("res://scripts/matchmaking_state.gd") + + +func test_ticket_projection_accepts_ordered_updates_and_exposes_cancel() -> void: + var state := MatchmakingState.new() + assert_true(state.begin_queue("ticket-1", "ranked"), "valid queue starts in QUEUED") + assert_true(state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 1, "state": "PROPOSED", "playlist": "ranked"}), "next revision applies") + assert_eq(state.phase, MatchmakingState.PROPOSED, "proposal is visible") + assert_true(state.can_cancel(), "authoritative cancel remains available before allocation") + + +func test_ticket_projection_rejects_gap_and_wrong_ticket_without_mutation() -> void: + var state := MatchmakingState.new() + state.begin_queue("ticket-1", "casual") + var resync_id := "" + state.resync_required.connect(func(id: String) -> void: resync_id = id) + assert_true(not state.apply_ticket_update({"ticket_id": "ticket-2", "revision": 1, "state": "PROPOSED"}), "another player's ticket is rejected") + assert_eq(resync_id, "ticket-1", "wrong resource requests recovery for current ticket") + assert_eq(state.phase, MatchmakingState.QUEUED, "invalid update cannot mutate phase") + assert_true(state.needs_resync, "invalid identity is visible to recovery") + + state.needs_resync = false + assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 3, "state": "ALLOCATING"}), "revision gap is rejected") + assert_eq(state.phase, MatchmakingState.QUEUED, "gap cannot skip authoritative state") + + +func test_duplicate_conflict_and_stale_updates_are_safe() -> void: + var state := MatchmakingState.new() + state.begin_queue("ticket-1", "casual") + var update := {"ticket_id": "ticket-1", "revision": 1, "state": "PROPOSED", "playlist": "casual", "expires_at_unix": 100} + assert_true(state.apply_ticket_update(update), "first update applies") + assert_true(state.apply_ticket_update(update), "identical duplicate is idempotent") + assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 1, "state": "QUEUED", "playlist": "casual", "expires_at_unix": 100}), "same-revision conflict requests recovery") + assert_eq(state.phase, MatchmakingState.PROPOSED, "conflicting replay cannot rewind state") + assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 0, "state": "QUEUED"}), "stale update is ignored") + assert_eq(state.phase, MatchmakingState.PROPOSED, "stale update cannot mutate state") + + +func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void: + var state := MatchmakingState.new() + state.begin_queue("ticket-1", "casual") + assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 1, "state": "OPEN"}), "open proposal applies") + assert_eq(state.phase, MatchmakingState.PROPOSED, "open proposal is visible") + assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 2, "state": "DECLINED"}), "declined proposal applies") + assert_eq(state.phase, MatchmakingState.FAILED, "decline is terminal and visible") + assert_true(not state.can_cancel(), "terminal proposal cannot issue queue cancel") + + var expired := MatchmakingState.new() + expired.begin_queue("ticket-2", "casual") + assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 1, "state": "OPEN"}), "second proposal opens") + assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 2, "state": "EXPIRED"}), "expired proposal applies") + assert_eq(expired.phase, MatchmakingState.EXPIRED, "expiry is visible") + assert_true(not expired.can_cancel(), "expired proposal cannot be cancelled") + + +func test_assignment_lifecycle_has_explicit_connecting_and_live_states() -> void: + var state := MatchmakingState.new() + state.begin_queue("ticket-1", "ranked") + state.mark_assignment_ready() + assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "assignment readiness is visible") + state.mark_connecting() + assert_eq(state.phase, MatchmakingState.CONNECTING, "transport connection is visible") + state.mark_live() + assert_eq(state.phase, MatchmakingState.LIVE, "live match is visible") diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 9fbc3406..e12a97cc 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1226,7 +1226,7 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| -| 8.39 `[D:8.3,8.14,8.17]` | Queue UI: playlist/quality, elapsed and estimated wait, proposal countdown, allocation/connect state, cancel and latency/capacity explanations | Every backend state and terminal failure has a non-stuck visible state; cancel/decline is acknowledged authoritatively | +| 8.39 `[D:8.3,8.14,8.17]` | **IN PROGRESS.** New pure Godot `MatchmakingState` projection models queue → proposal → allocation/process-ready/assignment-ready/connect/live plus terminal failure states, and exposes authoritative cancel availability | `test_matchmaking_state.gd` rejects wrong-ticket, revision-gap, same-revision conflict and stale updates, preserves idempotent duplicates, and keeps decline/expiry visible; HTTP client, queue UI wiring, wait/latency explanations and end-to-end backend events remain | | 8.40 `[D:8.3,8.14]` | **IN PROGRESS.** Pure Go revisioned replica reducer rejects gaps for REST resync, makes duplicate/out-of-order events idempotent, and resumes from the authoritative snapshot revision; authenticated queue-ticket recovery now has an owner-checked REST read | `server/domain/sync.go` and `server/api/service.go` cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery and expired-ticket terminal handling; authenticated WebSocket transport, client restart persistence and duplicate-ticket integration remain | | 8.41 `[D:7.8,8.9,8.31,8.40]` | After assignment-ready, install SDR relay ticket before connect and send match-scoped join authorisation in `hello`; retain ENet assignments locally | Production connects/reconnects/fences old generation through SDR, never before assignment-ready; allocated/direct ENet and community flows remain compatible | | 8.42 `[D:8.22,8.23,8.24,8.40]` | Backend-authoritative provisional/rank/tier/delta, abandon status and season countdown UI | Client performs no rating math and displays the committed revision after reconnect |