diff --git a/multiplayer-next.md b/multiplayer-next.md index 744b06a2..191e4234 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -69,6 +69,8 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). - [ ] **IN PROGRESS:** Add one PostgreSQL-owned queue ticket/player with 10 s heartbeat, 30 s expiry, Redis candidate cache and restart/failover repair. + Authenticated owner-only recovery reads now return terminal expiry correctly; + PostgreSQL/Redis wiring remains. - [ ] **IN PROGRESS:** Validate opaque Steam ping locations and nonce-bound probes server-side; require <=100 ms, enforce discrepancy quarantine and the locked widening/ region/team tie-break rules. diff --git a/multiplayer-todo.md b/multiplayer-todo.md index bf1c1474..31f14560 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1191,7 +1191,7 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| -| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter | `server/domain/queue.go`, `server/store/candidates.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, server-owned candidate resolution, bounded/strict JSON input, cache loss and atomic rebuild; PostgreSQL row adapter, real Redis index/TTLs and restart/failover integration remain | +| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel, owner-only recovery reads and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter | `server/domain/queue.go`, `server/store/candidates.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, expired recovery as a terminal error, server-owned candidate resolution, bounded/strict JSON input and cache loss/atomic rebuild; PostgreSQL row adapter, real Redis index/TTLs and restart/failover integration remain | | 8.15 `[D:7.8,8.3]` | **IN PROGRESS.** Pure Go probe validation treats Steam location as opaque, requires nonce/freshness/region and server-computed RTT, and implements discrepancy quarantine/release | `server/domain/probes.go` and adversarial fixtures cover stale/wrong/forged evidence, the 25 ms/30% threshold, three-sample quarantine and five-clean release; Steam coordinator and regional probe adapters remain | | 8.16 `[D:8.14,8.15]` | **IN PROGRESS.** Pure Go candidate/team selection implements the <=100 ms ceiling, pairwise widening tolerance, anchor inclusion, deterministic set/region scoring and balanced team partitioning | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance and input-order independence; queue-backed candidate loading and full population fixtures remain | | 8.17 `[D:8.14,8.16]` | **IN PROGRESS.** Pure Go proposal policy sends a 10-second response window to every selected human, requires unanimous acceptance, applies exact decline/timeout cooldowns and ranked escalation; authenticated API now exposes revisioned accept/decline mutations | `server/domain/proposal.go` and `server/api/service.go` plus adversarial fixtures cover partial/unanimous response, expiry, replay/conflict, stale API revision and ranked six-player/cooldown rules; casual 6→2 composition, queue precedence and allocation integration remain | @@ -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]` | 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.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 | `server/domain/sync.go` covers gap, snapshot, replay and same-revision conflict behavior; authenticated WebSocket/REST 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 | `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 | | 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 | diff --git a/server/api/service.go b/server/api/service.go index ca1843c9..a6a8b927 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -98,7 +98,7 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) { } func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { + if r.Method != http.MethodPost && r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") return } @@ -111,6 +111,19 @@ func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) { return } parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/queue/"), "/") + if r.Method == http.MethodGet { + if len(parts) != 1 || parts[0] == "" { + writeError(w, http.StatusNotFound, "not_found") + return + } + ticket, err := s.Queue.Get(playerID, parts[0], s.now()) + if err != nil { + writeDomainError(w, err) + return + } + writeJSON(w, http.StatusOK, toQueueResponse(ticket)) + return + } if len(parts) != 2 || parts[0] == "" || (parts[1] != "heartbeat" && parts[1] != "cancel") { writeError(w, http.StatusNotFound, "not_found") return diff --git a/server/api/service_test.go b/server/api/service_test.go index a25c00e4..c345e750 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -112,6 +112,53 @@ func TestQueueAPIRejectsUnauthenticatedUnknownAndOversizedInput(t *testing.T) { _ = response.Body.Close() } +func TestQueueRecoveryAPIIsAuthenticatedOwnerOnlyAndExpiresStaleTickets(t *testing.T) { + now := time.Unix(1000, 0).UTC() + sessions := domain.NewSessionStore() + ownerSession, ownerToken, err := sessions.Issue("player-1", time.Hour, now) + if err != nil { + t.Fatal(err) + } + otherSession, otherToken, err := sessions.Issue("player-2", time.Hour, now) + if err != nil { + t.Fatal(err) + } + queue := domain.NewQueue() + service := &Service{Sessions: sessions, Queue: queue, 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() + create, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(`{"ticket_id":"ticket-recovery-123456"}`)) + create.Header.Set("Authorization", "Bearer "+ownerSession.SessionID+":"+ownerToken) + create.Header.Set("Idempotency-Key", "queue-create-recovery-123456") + response, err := http.DefaultClient.Do(create) + if err != nil || response.StatusCode != http.StatusCreated { + t.Fatalf("create status=%v err=%v", response.StatusCode, err) + } + _ = response.Body.Close() + get, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/queue/ticket-recovery-123456", nil) + get.Header.Set("Authorization", "Bearer "+ownerSession.SessionID+":"+ownerToken) + response, err = http.DefaultClient.Do(get) + if err != nil || response.StatusCode != http.StatusOK { + t.Fatalf("owner recovery status=%v err=%v", response.StatusCode, err) + } + _ = response.Body.Close() + get.Header.Set("Authorization", "Bearer "+otherSession.SessionID+":"+otherToken) + response, err = http.DefaultClient.Do(get) + if err != nil || response.StatusCode != http.StatusForbidden { + t.Fatalf("cross-player recovery status=%v err=%v", response.StatusCode, err) + } + _ = response.Body.Close() + service.Now = func() time.Time { return now.Add(domain.QueueExpiryWindow) } + get.Header.Set("Authorization", "Bearer "+ownerSession.SessionID+":"+ownerToken) + response, err = http.DefaultClient.Do(get) + if err != nil || response.StatusCode != http.StatusGone { + t.Fatalf("expired recovery status=%v err=%v", response.StatusCode, err) + } + _ = response.Body.Close() +} + func TestAuthenticatedProposalAPIUsesRevisionAndIdempotencyPolicy(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 81fd272e..9e0f87a1 100644 --- a/server/domain/queue.go +++ b/server/domain/queue.go @@ -139,6 +139,23 @@ func (q *Queue) Cancel(playerID, ticketID, idempotencyKey string, expectedRevisi return ticket, nil } +// Get is the recovery read used after a client restart or missed event. It +// never returns another player's ticket and expires stale queue presence before +// deciding what the caller may resume. +func (q *Queue) Get(playerID, ticketID string, now time.Time) (QueueTicket, error) { + q.mu.Lock() + defer q.mu.Unlock() + q.expireLocked(now) + ticket, err := q.ownedTicket(playerID, ticketID) + if err != nil { + return QueueTicket{}, err + } + if ticket.State == Expired { + return QueueTicket{}, ErrTicketExpired + } + return ticket, nil +} + func (q *Queue) Expire(now time.Time) []QueueTicket { q.mu.Lock() defer q.mu.Unlock()