diff --git a/server/api/service.go b/server/api/service.go index d0ffc3c8..4b7115b2 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -31,6 +31,9 @@ type ProbeRecorder interface { RecordProbe(context.Context, string, string, time.Duration, time.Time) error } type WorkloadVerifier func(string, time.Time) (domain.WorkloadBinding, error) +type RankedProfileProvider interface { + Get(context.Context, string) (domain.RankedProfile, bool, error) +} type ResultSubmitter interface { SubmitResult(context.Context, string, domain.MatchResult, domain.WorkloadBinding, []byte, time.Time) error } @@ -95,28 +98,29 @@ type AssignmentView struct { type AssignmentProvider func(context.Context, string, string, time.Time) (AssignmentView, error) type Service struct { - Sessions *domain.SessionStore - SessionBackend SessionBackend - SessionIssuer SessionIssuer - SteamLogin SteamLoginProvider - Queue *domain.Queue - Candidate CandidateProvider - CandidateV2 CandidateProviderV2 - QueueBackend QueueBackend - CandidateIndex CandidateIndex - Probe ProbeProvider - ProbeRecorder ProbeRecorder - WorkloadVerify WorkloadVerifier - ResultSubmitter ResultSubmitter - ServerRegistrar ServerRegistrar - Assignment AssignmentProvider - Now func() time.Time - Proposals map[string]*domain.Proposal - ProposalBackend ProposalBackend - ProposalPromoter ProposalPromoter - RankedProfiles map[string]domain.RankedProfile - TierPolicy domain.TierPolicy - RateLimiter *RateLimiter + Sessions *domain.SessionStore + SessionBackend SessionBackend + SessionIssuer SessionIssuer + SteamLogin SteamLoginProvider + Queue *domain.Queue + Candidate CandidateProvider + CandidateV2 CandidateProviderV2 + QueueBackend QueueBackend + CandidateIndex CandidateIndex + Probe ProbeProvider + ProbeRecorder ProbeRecorder + WorkloadVerify WorkloadVerifier + ResultSubmitter ResultSubmitter + ServerRegistrar ServerRegistrar + Assignment AssignmentProvider + Now func() time.Time + Proposals map[string]*domain.Proposal + ProposalBackend ProposalBackend + ProposalPromoter ProposalPromoter + RankedProfiles map[string]domain.RankedProfile + RankedProfileProvider RankedProfileProvider + TierPolicy domain.TierPolicy + RateLimiter *RateLimiter // Log receives a credential-safe structured event for lifecycle-relevant // mutations (currently: server registration and result submission). Nil // is a valid, silent no-op -- every call site must stay optional so @@ -127,6 +131,19 @@ type Service struct { 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. func (s *Service) logEvent(event observability.Event) { if s.Log != nil { @@ -793,7 +810,11 @@ func (s *Service) profile(w http.ResponseWriter, r *http.Request) { if !ok { 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 { writeError(w, http.StatusNotFound, "not_found") return @@ -815,7 +836,11 @@ func (s *Service) rankedProfile(w http.ResponseWriter, r *http.Request) { if !ok { 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 { writeError(w, http.StatusNotFound, "not_found") return diff --git a/server/cmd/control-plane/main.go b/server/cmd/control-plane/main.go index 55136f73..ada0bd03 100644 --- a/server/cmd/control-plane/main.go +++ b/server/cmd/control-plane/main.go @@ -82,18 +82,19 @@ func newAPIHandler(db *sql.DB, indexes ...api.CandidateIndex) http.Handler { candidateIndex = indexes[0] } return (&api.Service{ - SessionBackend: store.PostgresSessions{DB: db}, - SessionIssuer: store.PostgresSessions{DB: db}, - QueueBackend: store.PostgresQueue{DB: db}, - ProposalBackend: api.ProposalProviderFromStore(db), - ProposalPromoter: api.ProposalPromoterFromStore(db), - ServerRegistrar: api.ServerRegistrarFromStore(db), - ResultSubmitter: store.PostgresResults{DB: db}, - Assignment: api.AssignmentProviderFromStore(db), - CandidateIndex: candidateIndex, - ProbeRecorder: store.PostgresQueue{DB: db}, - Now: func() time.Time { return time.Now().UTC() }, - Log: logEvent, + SessionBackend: store.PostgresSessions{DB: db}, + SessionIssuer: store.PostgresSessions{DB: db}, + QueueBackend: store.PostgresQueue{DB: db}, + ProposalBackend: api.ProposalProviderFromStore(db), + ProposalPromoter: api.ProposalPromoterFromStore(db), + ServerRegistrar: api.ServerRegistrarFromStore(db), + ResultSubmitter: store.PostgresResults{DB: db}, + RankedProfileProvider: store.PostgresRankedProfiles{DB: db}, + Assignment: api.AssignmentProviderFromStore(db), + CandidateIndex: candidateIndex, + ProbeRecorder: store.PostgresQueue{DB: db}, + Now: func() time.Time { return time.Now().UTC() }, + Log: logEvent, }).Handler() } diff --git a/server/cmd/testkit-api/main.go b/server/cmd/testkit-api/main.go index 481d891a..73650e72 100644 --- a/server/cmd/testkit-api/main.go +++ b/server/cmd/testkit-api/main.go @@ -54,17 +54,18 @@ func main() { fatalf("apply migrations: %v", err) } handler := (&api.Service{ - SessionBackend: store.PostgresSessions{DB: db}, - SessionIssuer: store.PostgresSessions{DB: db}, - SteamLogin: fakeSteamLogin{db: db}, - QueueBackend: store.PostgresQueue{DB: db}, - ProposalBackend: api.ProposalProviderFromStore(db), - ProposalPromoter: api.ProposalPromoterFromStore(db), - ServerRegistrar: api.ServerRegistrarFromStore(db), - ResultSubmitter: store.PostgresResults{DB: db}, - Assignment: api.AssignmentProviderFromStore(db), - ProbeRecorder: store.PostgresQueue{DB: db}, - Now: func() time.Time { return time.Now().UTC() }, + SessionBackend: store.PostgresSessions{DB: db}, + SessionIssuer: store.PostgresSessions{DB: db}, + SteamLogin: fakeSteamLogin{db: db}, + QueueBackend: store.PostgresQueue{DB: db}, + ProposalBackend: api.ProposalProviderFromStore(db), + ProposalPromoter: api.ProposalPromoterFromStore(db), + ServerRegistrar: api.ServerRegistrarFromStore(db), + ResultSubmitter: store.PostgresResults{DB: db}, + RankedProfileProvider: store.PostgresRankedProfiles{DB: db}, + Assignment: api.AssignmentProviderFromStore(db), + ProbeRecorder: store.PostgresQueue{DB: db}, + Now: func() time.Time { return time.Now().UTC() }, }).Handler() listener, err := net.Listen("tcp", *listen) if err != nil { diff --git a/server/store/ranked_profile_sql.go b/server/store/ranked_profile_sql.go new file mode 100644 index 00000000..00c9eeb3 --- /dev/null +++ b/server/store/ranked_profile_sql.go @@ -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 +}