From 99680dbf6f22f7ac6dbad4034ace43a6603cb63e Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:51:00 +0100 Subject: [PATCH] fix: bind queue idempotency to compatibility --- multiplayer-todo.md | 2 +- server/api/service_test.go | 45 +++++++++++++++++++++++++++++++++++++ server/domain/queue.go | 2 +- server/domain/queue_test.go | 15 +++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 9c115f9e..2ae80932 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; 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`, `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 duplicate-create retry identity; authenticated WebSocket transport and live Godot 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.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_test.go b/server/api/service_test.go index 8c3e7040..113bdcd0 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -221,6 +221,51 @@ func TestQueueCreateRejectsCandidateMetadataMismatch(t *testing.T) { } } +func TestQueueCreateAPIRetriesIdenticallyAndRejectsKeyReuseWithChangedPayload(t *testing.T) { + now := time.Unix(1000, 0).UTC() + sessions := domain.NewSessionStore() + session, token, err := sessions.Issue("player-1", time.Hour, now) + if err != nil { + t.Fatal(err) + } + service := &Service{Sessions: sessions, Queue: domain.NewQueue(), Now: func() time.Time { return now }, Candidate: func(playerID, ticketID string) (domain.Candidate, error) { + return domain.Candidate{PlayerID: playerID, TicketID: ticketID, EnqueuedAt: now}, nil + }} + server := httptest.NewServer(service.Handler()) + defer server.Close() + request := func(body, key string) (int, queueResponse) { + req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) + req.Header.Set("Idempotency-Key", key) + response, requestErr := http.DefaultClient.Do(req) + if requestErr != nil { + t.Fatal(requestErr) + } + defer response.Body.Close() + var decoded queueResponse + if response.StatusCode == http.StatusCreated { + if err := json.NewDecoder(response.Body).Decode(&decoded); err != nil { + t.Fatal(err) + } + } + return response.StatusCode, decoded + } + body := `{"ticket_id":"ticket-idempotent","playlist":"casual","client_build":"build-1","protocol_version":1}` + status, first := request(body, "idempotency-key-123456") + if status != http.StatusCreated { + t.Fatalf("first create status=%d", status) + } + status, replay := request(body, "idempotency-key-123456") + if status != http.StatusCreated || replay != first { + t.Fatalf("identical replay status=%d first=%+v replay=%+v", status, first, replay) + } + changed := `{"ticket_id":"ticket-idempotent","playlist":"casual","client_build":"build-2","protocol_version":1}` + status, _ = request(changed, "idempotency-key-123456") + if status != http.StatusConflict { + t.Fatalf("changed-payload replay status=%d, want conflict", status) + } +} + func TestQueueAPIUsesInjectedPersistentBackendWithoutCandidateProvider(t *testing.T) { now := time.Unix(1000, 0).UTC() sessions := domain.NewSessionStore() diff --git a/server/domain/queue.go b/server/domain/queue.go index c7105f1c..67ff80f3 100644 --- a/server/domain/queue.go +++ b/server/domain/queue.go @@ -218,5 +218,5 @@ func createPayload(playerID, ticketID string, candidate Candidate) string { for _, region := range regions { rtts = append(rtts, fmt.Sprintf("%s=%.9f", region, candidate.PredictedRTT[region])) } - return strings.Join([]string{playerID, ticketID, candidate.PlayerID, candidate.TicketID, fmt.Sprintf("%.9f", candidate.Rating), candidate.EnqueuedAt.UTC().Format(time.RFC3339Nano), strings.Join(rtts, ",")}, "\x00") + return strings.Join([]string{playerID, ticketID, candidate.PlayerID, candidate.TicketID, string(candidate.Playlist), candidate.ClientBuild, fmt.Sprintf("%d", candidate.ProtocolVersion), fmt.Sprintf("%.9f", candidate.Rating), candidate.EnqueuedAt.UTC().Format(time.RFC3339Nano), strings.Join(rtts, ",")}, "\x00") } diff --git a/server/domain/queue_test.go b/server/domain/queue_test.go index 515e1cf1..0eb9b046 100644 --- a/server/domain/queue_test.go +++ b/server/domain/queue_test.go @@ -77,6 +77,21 @@ func TestQueueCreateIdempotencyIncludesCandidatePayload(t *testing.T) { if _, err := q.Create("player-a", "ticket-a", "create-key-123456", changed, now); !errors.Is(err, ErrConflict) { t.Fatalf("changed create payload error = %v", err) } + changed = base + changed.Playlist = Ranked + if _, err := q.Create("player-a", "ticket-a", "create-key-123456", changed, now); !errors.Is(err, ErrConflict) { + t.Fatalf("changed playlist payload error = %v", err) + } + changed = base + changed.ClientBuild = "build-2" + if _, err := q.Create("player-a", "ticket-a", "create-key-123456", changed, now); !errors.Is(err, ErrConflict) { + t.Fatalf("changed build payload error = %v", err) + } + changed = base + changed.ProtocolVersion = 2 + if _, err := q.Create("player-a", "ticket-a", "create-key-123456", changed, now); !errors.Is(err, ErrConflict) { + t.Fatalf("changed protocol payload error = %v", err) + } } func TestQueueCreateRejectsCandidateOwnedByAnotherPlayer(t *testing.T) {