mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 14:32:02 +00:00
fix(multiplayer): wire a durable RankedProfileProvider
Same discovery pattern as ResultSubmitter/SessionIssuer, one level deeper: api.Service.rankedProfile and .profile both only ever read from an in-memory RankedProfiles map with no durable-store equivalent at all -- not "adapter exists but unwired" this time, there was no adapter. Every real request to GET /v1/profile/ranked or /api/v1/profile always 404'd regardless of a player's actual rating. Add RankedProfileProvider (an interface, not a struct-literal adapter this time) and store.PostgresRankedProfiles reading the ratings table; Service.rankedProfileFor prefers it when set and falls back to the map otherwise, so every existing test/direct Service literal keeps compiling and passing unchanged. A missing ratings row maps to the exact same (zero value, false, nil) the map lookup already produced, preserving existing not-found semantics rather than reinterpreting them. LastSeasonID/SeasonHistory are deliberately left unset -- the ratings table has no season pointer, and reconstructing history needs its own query and display semantics, not bundled in here speculatively. Wired into both cmd/control-plane and cmd/testkit-api. Verified against real PostgreSQL via curl: a fresh identity's ranked profile correctly 404s through the real adapter (same behavior as before, now for a real reason instead of an empty map).
This commit is contained in:
+27
-2
@@ -31,6 +31,9 @@ type ProbeRecorder interface {
|
|||||||
RecordProbe(context.Context, string, string, time.Duration, time.Time) error
|
RecordProbe(context.Context, string, string, time.Duration, time.Time) error
|
||||||
}
|
}
|
||||||
type WorkloadVerifier func(string, time.Time) (domain.WorkloadBinding, error)
|
type WorkloadVerifier func(string, time.Time) (domain.WorkloadBinding, error)
|
||||||
|
type RankedProfileProvider interface {
|
||||||
|
Get(context.Context, string) (domain.RankedProfile, bool, error)
|
||||||
|
}
|
||||||
type ResultSubmitter interface {
|
type ResultSubmitter interface {
|
||||||
SubmitResult(context.Context, string, domain.MatchResult, domain.WorkloadBinding, []byte, time.Time) error
|
SubmitResult(context.Context, string, domain.MatchResult, domain.WorkloadBinding, []byte, time.Time) error
|
||||||
}
|
}
|
||||||
@@ -115,6 +118,7 @@ type Service struct {
|
|||||||
ProposalBackend ProposalBackend
|
ProposalBackend ProposalBackend
|
||||||
ProposalPromoter ProposalPromoter
|
ProposalPromoter ProposalPromoter
|
||||||
RankedProfiles map[string]domain.RankedProfile
|
RankedProfiles map[string]domain.RankedProfile
|
||||||
|
RankedProfileProvider RankedProfileProvider
|
||||||
TierPolicy domain.TierPolicy
|
TierPolicy domain.TierPolicy
|
||||||
RateLimiter *RateLimiter
|
RateLimiter *RateLimiter
|
||||||
// Log receives a credential-safe structured event for lifecycle-relevant
|
// Log receives a credential-safe structured event for lifecycle-relevant
|
||||||
@@ -127,6 +131,19 @@ type Service struct {
|
|||||||
events *eventHub
|
events *eventHub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rankedProfileFor prefers the durable RankedProfileProvider when set,
|
||||||
|
// falling back to the in-memory RankedProfiles map for existing tests/direct
|
||||||
|
// Service literals that construct it that way. Both return the same
|
||||||
|
// (profile, exists) shape either way, so callers don't need to know which
|
||||||
|
// source answered.
|
||||||
|
func (s *Service) rankedProfileFor(ctx context.Context, playerID string) (domain.RankedProfile, bool, error) {
|
||||||
|
if s.RankedProfileProvider != nil {
|
||||||
|
return s.RankedProfileProvider.Get(ctx, playerID)
|
||||||
|
}
|
||||||
|
profile, exists := s.RankedProfiles[playerID]
|
||||||
|
return profile, exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
// logEvent is a nil-safe wrapper so call sites never need their own guard.
|
// logEvent is a nil-safe wrapper so call sites never need their own guard.
|
||||||
func (s *Service) logEvent(event observability.Event) {
|
func (s *Service) logEvent(event observability.Event) {
|
||||||
if s.Log != nil {
|
if s.Log != nil {
|
||||||
@@ -793,7 +810,11 @@ func (s *Service) profile(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
profile, exists := s.RankedProfiles[playerID]
|
profile, exists, err := s.rankedProfileFor(r.Context(), playerID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "ranked_profile_unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
writeError(w, http.StatusNotFound, "not_found")
|
writeError(w, http.StatusNotFound, "not_found")
|
||||||
return
|
return
|
||||||
@@ -815,7 +836,11 @@ func (s *Service) rankedProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
profile, exists := s.RankedProfiles[playerID]
|
profile, exists, err := s.rankedProfileFor(r.Context(), playerID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "ranked_profile_unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
writeError(w, http.StatusNotFound, "not_found")
|
writeError(w, http.StatusNotFound, "not_found")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ func newAPIHandler(db *sql.DB, indexes ...api.CandidateIndex) http.Handler {
|
|||||||
ProposalPromoter: api.ProposalPromoterFromStore(db),
|
ProposalPromoter: api.ProposalPromoterFromStore(db),
|
||||||
ServerRegistrar: api.ServerRegistrarFromStore(db),
|
ServerRegistrar: api.ServerRegistrarFromStore(db),
|
||||||
ResultSubmitter: store.PostgresResults{DB: db},
|
ResultSubmitter: store.PostgresResults{DB: db},
|
||||||
|
RankedProfileProvider: store.PostgresRankedProfiles{DB: db},
|
||||||
Assignment: api.AssignmentProviderFromStore(db),
|
Assignment: api.AssignmentProviderFromStore(db),
|
||||||
CandidateIndex: candidateIndex,
|
CandidateIndex: candidateIndex,
|
||||||
ProbeRecorder: store.PostgresQueue{DB: db},
|
ProbeRecorder: store.PostgresQueue{DB: db},
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func main() {
|
|||||||
ProposalPromoter: api.ProposalPromoterFromStore(db),
|
ProposalPromoter: api.ProposalPromoterFromStore(db),
|
||||||
ServerRegistrar: api.ServerRegistrarFromStore(db),
|
ServerRegistrar: api.ServerRegistrarFromStore(db),
|
||||||
ResultSubmitter: store.PostgresResults{DB: db},
|
ResultSubmitter: store.PostgresResults{DB: db},
|
||||||
|
RankedProfileProvider: store.PostgresRankedProfiles{DB: db},
|
||||||
Assignment: api.AssignmentProviderFromStore(db),
|
Assignment: api.AssignmentProviderFromStore(db),
|
||||||
ProbeRecorder: store.PostgresQueue{DB: db},
|
ProbeRecorder: store.PostgresQueue{DB: db},
|
||||||
Now: func() time.Time { return time.Now().UTC() },
|
Now: func() time.Time { return time.Now().UTC() },
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const RankedProfileSelectSQL = `SELECT rating, deviation, volatility, ranked_games, updated_at
|
||||||
|
FROM ratings
|
||||||
|
WHERE player_id = $1`
|
||||||
|
|
||||||
|
// PostgresRankedProfiles reads the durable rating row api.Service's
|
||||||
|
// RankedProfileProvider needs. A missing row means "this player has no
|
||||||
|
// ranked profile yet" (never queued ranked, or their identity predates any
|
||||||
|
// result) -- that's a real, expected state, not an error, and is reported
|
||||||
|
// the same way the in-memory RankedProfiles map api.Service still falls
|
||||||
|
// back to already did: (zero value, false, nil).
|
||||||
|
//
|
||||||
|
// LastSeasonID and SeasonHistory are deliberately left at their zero values.
|
||||||
|
// The ratings table has no "current season" column, and reconstructing
|
||||||
|
// season history means a second query against ranked_season_rollovers with
|
||||||
|
// its own display semantics to settle -- a real, separate piece of work,
|
||||||
|
// not bundled into this read path speculatively.
|
||||||
|
type PostgresRankedProfiles struct{ DB *sql.DB }
|
||||||
|
|
||||||
|
func (p PostgresRankedProfiles) Get(ctx context.Context, playerID string) (domain.RankedProfile, bool, error) {
|
||||||
|
if p.DB == nil || playerID == "" {
|
||||||
|
return domain.RankedProfile{}, false, fmt.Errorf("invalid ranked profile lookup")
|
||||||
|
}
|
||||||
|
var profile domain.RankedProfile
|
||||||
|
err := p.DB.QueryRowContext(ctx, RankedProfileSelectSQL, playerID).
|
||||||
|
Scan(&profile.Value, &profile.RD, &profile.Volatility, &profile.RankedGames, &profile.LastRatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return domain.RankedProfile{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.RankedProfile{}, false, err
|
||||||
|
}
|
||||||
|
return profile, true, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user