diff --git a/multiplayer-next.md b/multiplayer-next.md index dc325802..744b06a2 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -84,7 +84,8 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). - [ ] **IN PROGRESS:** Implement the documented exact Glicko-2 equations, fractional 3v3 weights, inactivity/update locking/golden vectors and ten provisional games. Backend-owned provisional status and validated ranked-tier derivation now exist; - authoritative profile transport and client display remain. + authenticated ranked-profile transport now exists; client display and + persisted tier configuration remain. - [ ] **IN PROGRESS:** Add ranked-only exactly-once 12-week soft seasons; distinguish retryable result-delivery outages from match-integrity failures and rating exemptions. diff --git a/multiplayer-todo.md b/multiplayer-todo.md index c760f850..bf1c1474 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1199,7 +1199,7 @@ the local/CI/community transport, not a silent production fallback. | 8.19 `[D:8.18]` | **IN PROGRESS.** Pure Go casual lineup requires 2–6 humans with at least one per team, fills missing slots with explicit bots, permits kickoff-only bot-slot backfill and assigns no backfill penalty/rating update | `server/domain/casual.go` covers both-team minimum, bot shape, live-play rejection and zero-penalty backfill; queue candidate selection, opt-in 10 s backfill proposals, reconnect/leave penalties and live integration remain | | 8.20 `[D:8.18]` | **IN PROGRESS.** Pure Go ranked admission requires six unique verified solo humans, rejects bots/backfill/parties, and allows only random-enabled non-elevated arenas | `server/domain/ranked.go` covers count, identity, party, bot/backfill and arena eligibility rejection; `ArenaRegistry` integration, proposal/allocation wiring and innocent-ticket restoration remain | | 8.21 `[D:8.5,8.20]` | **IN PROGRESS.** Pure Go rating core implements canonical Glicko-2, daily inactivity, ranked 1/3 and casual 1/N human-opponent weights, deterministic opponent ordering, and authoritative draw/overtime/abandon scoring | `server/domain/rating.go` has canonical/inactivity/weight/invalid-input plus draw/OT/abandon fixtures; PostgreSQL snapshot locking, rating transaction integration, seasons and concurrent result transaction tests remain | -| 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional, derives tiers only through validated backend-owned rating bands, and keeps casual ratings outside the API | `server/domain/rating.go` and `tier_test.go` cover provisional override, exact band boundaries and malformed policy rejection; authoritative response transport, persisted tier policy and UI remain | +| 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional, derives tiers only through validated backend-owned rating bands, and keeps casual ratings outside the API; authenticated HTTP now returns the authoritative ranked view | `server/domain/rating.go`, `tier_test.go` and `server/api/service.go` cover provisional override, exact band boundaries, malformed policy rejection, session authentication and ranked-only response fields; persisted tier policy, client UI and reconnect transport remain | | 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season policy compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history, is idempotent by season ID, and defines exact 12-week windows/due detection | `server/domain/rating.go` and `season_test.go` cover compression, floor/cap, duplicate replay, window boundary and completed-season idempotence; PostgreSQL locking, persisted rollover transaction and maintenance scheduler remain | | 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Pure Go ranked connection policy binds match/server/player/team/slot/protocol, supports 60 s reclaim with server-owned generations, fences old connections, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder | `server/domain/reconnect.go` covers repeated backend-independent reclaim, binding rejection, old-generation fencing, grace boundary and deterministic cooldown audit ordering; signed authorisations, persistent lease fencing, join transport and full match/result integration remain | | 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Pure Go result policy binds match/server/workload identity, hashes canonical payloads, makes identical retries idempotent, leaves conflicts inert, separates integrity eligibility, classifies roster/simulation/result/fairness evidence, validates annotation signatures/digests, and exposes 5 m alert/30 m review delivery thresholds; Go store SQL defines conflict-safe receipt insert, deterministic match/rating locks and atomic completion/outbox boundaries | `server/domain/result.go`, `server/domain/workload.go` and `server/store/result_sql.go` plus adversarial fixtures cover credential binding, duplicate/conflict, annotation forgery, delivery-outage-versus-integrity classification, commit and lock ordering; production credential verification, Agones annotation persistence/reconciliation, live PostgreSQL execution and integrity evidence adapters remain | diff --git a/server/api/service.go b/server/api/service.go index 50848cdf..ca1843c9 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -21,12 +21,14 @@ const maxBodyBytes = 8 << 10 type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error) type Service struct { - Sessions *domain.SessionStore - Queue *domain.Queue - Candidate CandidateProvider - Now func() time.Time - Proposals map[string]*domain.Proposal - proposalMu sync.Mutex + Sessions *domain.SessionStore + Queue *domain.Queue + Candidate CandidateProvider + Now func() time.Time + Proposals map[string]*domain.Proposal + RankedProfiles map[string]domain.RankedProfile + TierPolicy domain.TierPolicy + proposalMu sync.Mutex } func (s *Service) Handler() http.Handler { @@ -35,6 +37,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/profile/ranked", s.rankedProfile) return mux } @@ -184,6 +187,38 @@ func (s *Service) proposalMutation(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, toProposalResponse(updated)) } +type rankedProfileResponse struct { + Rating float64 `json:"rating"` + RD float64 `json:"rd"` + Volatility float64 `json:"volatility"` + RankedGames int `json:"ranked_games"` + Tier string `json:"tier"` + Provisional bool `json:"provisional"` + SeasonID string `json:"season_id,omitempty"` +} + +func (s *Service) rankedProfile(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 + } + profile, exists := s.RankedProfiles[playerID] + if !exists { + writeError(w, http.StatusNotFound, "not_found") + return + } + tier, err := domain.RankedTier(profile, s.TierPolicy) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "ranked_profile_unavailable") + return + } + writeJSON(w, http.StatusOK, rankedProfileResponse{Rating: profile.Value, RD: profile.RD, Volatility: profile.Volatility, RankedGames: profile.RankedGames, Tier: string(tier), Provisional: domain.RankedIsProvisional(profile), SeasonID: profile.LastSeasonID}) +} + func (s *Service) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) { if s.Sessions == nil { writeError(w, http.StatusServiceUnavailable, "auth_unavailable") diff --git a/server/api/service_test.go b/server/api/service_test.go index 1cf9042e..a25c00e4 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -151,3 +151,41 @@ func TestAuthenticatedProposalAPIUsesRevisionAndIdempotencyPolicy(t *testing.T) } _ = response.Body.Close() } + +func TestRankedProfileAPIReturnsBackendTierAndHidesCasualData(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) + } + policy, err := domain.NewTierPolicy([]domain.TierBand{{Tier: domain.RankTierBronze, MinRating: 0}, {Tier: domain.RankTierGold, MinRating: 1500}}) + if err != nil { + t.Fatal(err) + } + service := &Service{ + Sessions: sessions, + RankedProfiles: map[string]domain.RankedProfile{"player-a": {Rating: domain.Rating{Value: 1600, RD: 200, Volatility: 0.06}, RankedGames: 10, LastSeasonID: "season-1"}}, + TierPolicy: policy, + Now: func() time.Time { return now }, + } + server := httptest.NewServer(service.Handler()) + defer server.Close() + req, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/profile/ranked", nil) + req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("ranked profile status = %d", response.StatusCode) + } + var body rankedProfileResponse + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Tier != string(domain.RankTierGold) || body.Provisional || body.RankedGames != 10 || body.SeasonID != "season-1" { + t.Fatalf("ranked profile response = %+v", body) + } +}