feat: add player-scoped assignment recovery

This commit is contained in:
Josh Creek
2026-08-31 22:54:16 +01:00
parent 99680dbf6f
commit 69f1ef6be1
6 changed files with 181 additions and 1 deletions
+45
View File
@@ -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
+12
View File
@@ -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)
+19
View File
@@ -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")
+1 -1
View File
@@ -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 |
+46
View File
@@ -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"`
+58
View File
@@ -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)
}
}