From 69f1ef6be1a8aa5d19ba13dea8251f16e74eeaed Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:54:16 +0100 Subject: [PATCH] feat: add player-scoped assignment recovery --- Game/scripts/assignment_state.gd | 45 ++++++++++++++++++ Game/scripts/control_plane_client.gd | 12 +++++ Game/tests/cases/test_assignment_state.gd | 19 ++++++++ multiplayer-todo.md | 2 +- server/api/service.go | 46 ++++++++++++++++++ server/api/service_test.go | 58 +++++++++++++++++++++++ 6 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 Game/scripts/assignment_state.gd create mode 100644 Game/tests/cases/test_assignment_state.gd diff --git a/Game/scripts/assignment_state.gd b/Game/scripts/assignment_state.gd new file mode 100644 index 00000000..4288e30a --- /dev/null +++ b/Game/scripts/assignment_state.gd @@ -0,0 +1,45 @@ +class_name AssignmentState +extends RefCounted + +# Verified assignment-ready manifest returned by the control plane. The join +# authorisation is retained in memory only and is never written to the restart +# snapshot; transport installation belongs to the explicit ENet/Steam layer. + +var available := false +var match_id := "" +var server_id := "" +var slot := -1 +var expires_at := "" +var protocol_version := 0 +var transport := "" +var join_authorisation := "" +var error_message := "" + + +func apply(payload: Dictionary) -> bool: + for key in ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "join_authorisation"]: + if not payload.has(key): + return _reject("Assignment response is missing " + key) + if not payload["match_id"] is String or not payload["server_id"] is String or not payload["player_id"] is String or not payload["slot"] is int or not payload["expires_at"] is String or not payload["protocol_version"] is int or not payload["transport"] is String or not payload["join_authorisation"] is String: + return _reject("Assignment response contains invalid types") + var next_match_id := String(payload["match_id"]) + var next_server_id := String(payload["server_id"]) + var next_transport := String(payload["transport"]) + if next_match_id.is_empty() or next_server_id.is_empty() or String(payload["player_id"]).is_empty() or int(payload["slot"]) < 0 or int(payload["slot"]) > 5 or int(payload["protocol_version"]) < 1 or (next_transport != "enet" and next_transport != "steam_sdr") or String(payload["expires_at"]).is_empty() or String(payload["join_authorisation"]).is_empty(): + return _reject("Assignment response contains invalid values") + match_id = next_match_id + server_id = next_server_id + slot = int(payload["slot"]) + expires_at = String(payload["expires_at"]) + protocol_version = int(payload["protocol_version"]) + transport = next_transport + join_authorisation = String(payload["join_authorisation"]) + available = true + error_message = "" + return true + + +func _reject(reason: String) -> bool: + available = false + error_message = reason + return false diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 8dc5a381..4860a3c3 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -19,6 +19,7 @@ var player_id := "" var session_expires_at := "" var state: MatchmakingState var ranked_profile: RankedProfileState +var assignment: AssignmentState var _request: HTTPRequest var _operation := "" @@ -28,6 +29,7 @@ var _last_queue_create: Dictionary = {} func _ready() -> void: state = MatchmakingState.new() ranked_profile = RankedProfileState.new() + assignment = AssignmentState.new() _load_persisted_state() state.changed.connect(_persist_state) _request = HTTPRequest.new() @@ -99,6 +101,12 @@ func fetch_ranked_profile() -> Error: return _start_request("ranked_profile", HTTPClient.METHOD_GET, "/v1/profile/ranked", {}, "") +func fetch_assignment(match_id: String) -> Error: + if match_id.is_empty(): + return ERR_INVALID_PARAMETER + return _start_request("assignment", HTTPClient.METHOD_GET, "/v1/assignments/" + match_id, {}, "") + + func heartbeat(ticket_id: String, expected_revision: int) -> Error: if ticket_id.is_empty() or expected_revision < 0: return ERR_INVALID_PARAMETER @@ -227,6 +235,10 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head if not ranked_profile.apply(payload): request_failed.emit(operation, response_code, ranked_profile.error_message) return + elif operation == "assignment": + if not assignment.apply(payload): + request_failed.emit(operation, response_code, assignment.error_message) + return request_succeeded.emit(operation, payload) diff --git a/Game/tests/cases/test_assignment_state.gd b/Game/tests/cases/test_assignment_state.gd new file mode 100644 index 00000000..e40275fc --- /dev/null +++ b/Game/tests/cases/test_assignment_state.gd @@ -0,0 +1,19 @@ +extends "res://tests/test_case.gd" + +const AssignmentState = preload("res://scripts/assignment_state.gd") + + +func test_assignment_projection_accepts_verified_enet_manifest() -> void: + var assignment := AssignmentState.new() + assert_true(assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 2, "expires_at": "2026-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "join_authorisation": "signed"}), "valid assignment applies") + assert_true(assignment.available, "assignment becomes available only after validation") + assert_eq(assignment.transport, "enet", "transport is explicit") + assert_eq(assignment.slot, 2, "slot is preserved") + + +func test_assignment_projection_rejects_wrong_shape_or_unsafe_transport() -> void: + var assignment := AssignmentState.new() + assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 6, "expires_at": "future", "protocol_version": 1, "transport": "enet", "join_authorisation": "signed"}), "out-of-range slot is rejected") + assert_true(not assignment.available, "invalid assignment is not exposed") + assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "future", "protocol_version": 1, "transport": "udp", "join_authorisation": "signed"}), "unknown transport is rejected") + assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "future", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": ""}), "empty authorisation is rejected") diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 2ae80932..c8efbec7 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1228,7 +1228,7 @@ the local/CI/community transport, not a silent production fallback. |---|---|---| | 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; Godot client projection persists non-secret ticket/proposal state, forces authoritative recovery after restart, and can replay a lost queue-create response with the original ticket/idempotency key | `server/domain/sync.go`, `server/api/service.go`, `service_test.go`, `matchmaking_state.gd` and `control_plane_client.gd` cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery, expired-ticket terminal handling, malformed restart snapshots and API-level duplicate-create replay/conflict; authenticated WebSocket transport and live Godot verification 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.41 `[D:7.8,8.9,8.31,8.40]` | **IN PROGRESS.** Authenticated `GET /v1/assignments/{matchId}` now exposes only a validated, player-scoped assignment view; Godot `AssignmentState`/`ControlPlaneClient.fetch_assignment()` preserve explicit transport, slot and join authorisation without connecting before assignment-ready | `server/api/service.go`, `service_test.go`, `assignment_state.gd` and `test_assignment_state.gd` cover participant/expiry/shape/transport boundaries and assignment recovery; signed manifest-to-player persistence, SDR relay-ticket installation, `hello` join-authorisation wiring, fencing integration and live Godot verification remain | | 8.42 `[D:8.22,8.23,8.24,8.40]` | **IN PROGRESS.** `RankedProfileState` and `ControlPlaneClient.fetch_ranked_profile()` expose the backend-authoritative rating/RD/volatility/games/tier/provisional/season view; matchmaking UI displays provisional/tier status without client-side rating math | `test_control_plane_client.gd` validates profile shape, numeric safety and provisional display; ranked profile fetch/display, committed revision after reconnect, abandon status and season countdown remain dependent on live auth/backend events and Godot runtime verification | | 8.43 `[D:8.39,8.40,8.41]` | **IN PROGRESS.** Matchmaking client now distinguishes expired queue recovery, session expiry, missing records and retryable control-plane outages; a 401 clears the in-memory token, emits `session_expired` and disables retry until a new session is configured; terminal messages remain visible and active searches are not falsely failed on transient errors | `MatchmakingState` and `ControlPlaneClient` tests cover explicit expiry and the existing terminal/retry-safe state paths; decline, version mismatch, regional outage retry UI, failed reconnect, duplicate-action recovery and live Godot verification remain | diff --git a/server/api/service.go b/server/api/service.go index 97b5fcc0..dc4d5998 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -40,6 +40,19 @@ type SessionIssuer interface { Issue(context.Context, string, time.Duration, time.Time) (domain.Session, string, error) } +type AssignmentView struct { + MatchID string `json:"match_id"` + ServerID string `json:"server_id"` + PlayerID string `json:"player_id"` + Slot int `json:"slot"` + ExpiresAt time.Time `json:"expires_at"` + ProtocolVersion int `json:"protocol_version"` + Transport string `json:"transport"` + JoinAuthorisation string `json:"join_authorisation"` +} + +type AssignmentProvider func(context.Context, string, string, time.Time) (AssignmentView, error) + type Service struct { Sessions *domain.SessionStore SessionBackend SessionBackend @@ -50,6 +63,7 @@ type Service struct { CandidateV2 CandidateProviderV2 QueueBackend QueueBackend Probe ProbeProvider + Assignment AssignmentProvider Now func() time.Time Proposals map[string]*domain.Proposal RankedProfiles map[string]domain.RankedProfile @@ -64,6 +78,7 @@ func (s *Service) Handler() http.Handler { mux.HandleFunc("/v1/queue", s.queueCreate) mux.HandleFunc("/v1/queue/", s.queueMutation) mux.HandleFunc("/v1/proposals/", s.proposalMutation) + mux.HandleFunc("/v1/assignments/", s.assignment) mux.HandleFunc("/v1/profile/ranked", s.rankedProfile) mux.HandleFunc("/v1/probes/", s.probe) return mux @@ -331,6 +346,37 @@ func (s *Service) proposalMutation(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, toProposalResponse(updated)) } +func (s *Service) assignment(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + playerID, ok := s.authenticate(w, r) + if !ok { + return + } + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/assignments/"), "/") + if len(parts) != 1 || parts[0] == "" { + writeError(w, http.StatusNotFound, "not_found") + return + } + if s.Assignment == nil { + writeError(w, http.StatusServiceUnavailable, "assignment_unavailable") + return + } + now := s.now() + view, err := s.Assignment(r.Context(), playerID, parts[0], now) + if err != nil || view.MatchID != parts[0] || view.PlayerID != playerID { + writeError(w, http.StatusNotFound, "not_found") + return + } + if view.ServerID == "" || view.Slot < 0 || view.Slot > 5 || view.ProtocolVersion < 1 || (view.Transport != "enet" && view.Transport != "steam_sdr") || view.JoinAuthorisation == "" || view.ExpiresAt.IsZero() || !now.Before(view.ExpiresAt) { + writeError(w, http.StatusServiceUnavailable, "assignment_unavailable") + return + } + writeJSON(w, http.StatusOK, view) +} + type rankedProfileResponse struct { Rating float64 `json:"rating"` RD float64 `json:"rd"` diff --git a/server/api/service_test.go b/server/api/service_test.go index 113bdcd0..82a1f282 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -600,3 +600,61 @@ func TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary(t *testing. t.Fatalf("expired recovery status=%d body=%+v", status, recovered) } } + +func TestAssignmentRecoveryIsPlayerScopedAndRejectsExpiredOrMismatchedViews(t *testing.T) { + now := time.Unix(1000, 0).UTC() + sessions := domain.NewSessionStore() + session, token, err := sessions.Issue("player-a", time.Hour, now) + if err != nil { + t.Fatal(err) + } + other, otherToken, err := sessions.Issue("player-z", time.Hour, now) + if err != nil { + t.Fatal(err) + } + current := now + service := &Service{Sessions: sessions, Now: func() time.Time { return current }, Assignment: func(_ context.Context, _ string, matchID string, _ time.Time) (AssignmentView, error) { + return AssignmentView{MatchID: matchID, ServerID: "server-1", PlayerID: "player-a", Slot: 2, ExpiresAt: now.Add(time.Minute), ProtocolVersion: 1, Transport: "enet", JoinAuthorisation: "signed-join"}, nil + }} + server := httptest.NewServer(service.Handler()) + defer server.Close() + get := func(path string) (int, AssignmentView) { + req, _ := http.NewRequest(http.MethodGet, server.URL+path, nil) + req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) + response, requestErr := http.DefaultClient.Do(req) + if requestErr != nil { + t.Fatal(requestErr) + } + defer response.Body.Close() + var view AssignmentView + if response.StatusCode == http.StatusOK { + if err := json.NewDecoder(response.Body).Decode(&view); err != nil { + t.Fatal(err) + } + } + return response.StatusCode, view + } + status, view := get("/v1/assignments/match-1") + if status != http.StatusOK || view.PlayerID != "player-a" || view.Slot != 2 { + t.Fatalf("assignment status=%d view=%+v", status, view) + } + req, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/assignments/match-1", nil) + req.Header.Set("Authorization", "Bearer "+other.SessionID+":"+otherToken) + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusNotFound { + t.Fatalf("misbound assignment status=%d, want 404", response.StatusCode) + } + response.Body.Close() + status, _ = get("/v1/assignments/") + if status != http.StatusNotFound { + t.Fatalf("malformed assignment path status=%d", status) + } + current = now.Add(time.Minute) + status, _ = get("/v1/assignments/match-1") + if status != http.StatusServiceUnavailable { + t.Fatalf("expired assignment status=%d", status) + } +}