From 62c1478913a3d3166034d22a5a7c039e4e079081 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:42:43 +0100 Subject: [PATCH] feat: persist matchmaking recovery state --- Game/scripts/control_plane_client.gd | 21 +++++++++++++++++ Game/scripts/matchmaking_state.gd | 27 ++++++++++++++++++++++ Game/tests/cases/test_matchmaking_state.gd | 10 ++++++++ multiplayer-todo.md | 2 +- 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 0710b4c5..415e82e9 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -8,6 +8,7 @@ signal request_succeeded(operation: String, payload: Dictionary) signal request_failed(operation: String, http_code: int, detail: String) const DEFAULT_BASE_URL := "http://127.0.0.1:8080" +const PERSIST_PATH := "user://matchmaking_state.cfg" var base_url := DEFAULT_BASE_URL var access_token := "" @@ -19,6 +20,8 @@ var _operation := "" func _ready() -> void: state = MatchmakingState.new() + _load_persisted_state() + state.changed.connect(_persist_state) _request = HTTPRequest.new() _request.timeout = 10.0 add_child(_request) @@ -145,3 +148,21 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head func _idempotency_key(prefix: String) -> String: return "%s-%s-%s" % [prefix, str(Time.get_ticks_usec()), str(randi())] + + +func _persist_state(snapshot: Dictionary) -> void: + var config := ConfigFile.new() + config.set_value("matchmaking", "snapshot", JSON.stringify(snapshot)) + config.save(PERSIST_PATH) + + +func _load_persisted_state() -> void: + var config := ConfigFile.new() + if config.load(PERSIST_PATH) != OK: + return + var raw = config.get_value("matchmaking", "snapshot", "") + if not raw is String or String(raw).is_empty(): + return + var parsed = JSON.parse_string(String(raw)) + if parsed is Dictionary and not state.restore_snapshot(parsed): + state.fail("Saved matchmaking state is invalid") diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index 3ae4ad05..70db7dcc 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -70,6 +70,7 @@ func apply_ticket_update(update: Dictionary) -> bool: message = String(update["message"]) else: message = "" + needs_resync = false _emit_changed() return true @@ -106,6 +107,7 @@ func apply_proposal_update(update: Dictionary) -> bool: return _request_resync(proposal_id) proposal_revision = incoming_revision proposal_state = incoming_proposal_state + needs_resync = false if incoming_proposal_state == "OPEN" or incoming_proposal_state == "ACCEPTED": message = "" if update.has("expires_at_unix"): @@ -143,6 +145,31 @@ func set_notice(notice: String) -> void: _emit_changed() +func restore_snapshot(saved: Dictionary) -> bool: + _reset() + if saved.is_empty(): + return true + var saved_phase := String(saved.get("phase", IDLE)) + var saved_ticket_id := String(saved.get("ticket_id", "")) + if saved_ticket_id.is_empty() or not _is_ticket_state(saved_phase): + return false + var saved_playlist := String(saved.get("playlist", "")) + if saved_playlist != "casual" and saved_playlist != "ranked": + return false + ticket_id = saved_ticket_id + playlist = saved_playlist + phase = saved_phase + revision = maxi(0, int(saved.get("revision", 0))) + expires_at_unix = maxi(0, int(saved.get("expires_at_unix", 0))) + proposal_id = String(saved.get("proposal_id", "")) + proposal_revision = maxi(0, int(saved.get("proposal_revision", 0))) + proposal_state = String(saved.get("proposal_state", "")) + message = "Recovering authoritative matchmaking state" + needs_resync = phase != CANCELLED and phase != EXPIRED and phase != FAILED + _emit_changed() + return true + + func can_cancel() -> bool: return phase == QUEUED or phase == PROPOSED or phase == ALLOCATING diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index a5f8e747..8ada6f55 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -64,3 +64,13 @@ func test_assignment_lifecycle_has_explicit_connecting_and_live_states() -> void assert_eq(state.phase, MatchmakingState.CONNECTING, "transport connection is visible") state.mark_live() assert_eq(state.phase, MatchmakingState.LIVE, "live match is visible") + + +func test_restart_restore_requires_valid_identity_and_requests_authoritative_recovery() -> void: + var state := MatchmakingState.new() + assert_true(state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "revision": 2}), "valid active snapshot restores") + assert_true(state.needs_resync, "restored active state must recover from the server") + assert_eq(state.revision, 2, "revision is retained for diagnostics") + assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "", "playlist": "casual"}), "missing ticket identity is rejected") + assert_eq(state.phase, MatchmakingState.IDLE, "invalid restore cannot leave stale active state") + assert_true(not state.restore_snapshot({"phase": "NOT_A_STATE", "ticket_id": "ticket-1", "playlist": "casual"}), "unknown state is rejected") diff --git a/multiplayer-todo.md b/multiplayer-todo.md index be9bdbaa..c9843742 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1227,7 +1227,7 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| | 8.39 `[D:8.3,8.14,8.17]` | **IN PROGRESS.** `MatchmakingState` now projects queue → proposal → allocation/process-ready/assignment-ready/connect/live plus terminal failure states; autoload `ControlPlaneClient` provides authenticated queue create/recovery/heartbeat/cancel and proposal response requests with idempotency/revision headers; `matchmaking.tscn`/`matchmaking.gd` expose the state and authoritative actions from the main menu; authenticated proposal recovery now reconciles missed proposal events and expires them at read time | `test_matchmaking_state.gd`, `test_control_plane_client.gd`, `test_matchmaking_ui.gd` and `TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary` reject stale/gapped/conflicting updates, validate endpoint/token/payload normalization, preserve idempotent duplicates, and guarantee visible phase/terminal copy; server-pushed allocation events, wait/latency explanations and Godot runtime verification 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.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; Godot client projection persists non-secret ticket/proposal state and forces authoritative recovery after restart | `server/domain/sync.go`, `server/api/service.go`, `matchmaking_state.gd` and `control_plane_client.gd` cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery, expired-ticket terminal handling and malformed restart snapshots; authenticated WebSocket transport 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 | | 8.43 `[D:8.39,8.40,8.41]` | Recovery paths for decline, expiry, startup failure, version mismatch, auth expiry, regional outage and failed reconnect | Automated UI/state tests prove every case returns to a usable queue/menu or resumes the match without a duplicate action |