mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +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:
+49
-24
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user