From eb3b685af032c2540f7d96b10dedc7204a9e408c Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:34:57 +0100 Subject: [PATCH] feat(multiplayer): expose active ranked season --- multiplayer-next.md | 2 ++ server/api/service.go | 6 +++++- server/api/service_test.go | 4 ++-- server/domain/rating.go | 7 ++++--- server/store/postgres_integration_test.go | 20 ++++++++++++++++++++ server/store/ranked_profile_sql.go | 16 +++++++++------- server/store/ranked_profile_sql_test.go | 11 +++++++++++ 7 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 server/store/ranked_profile_sql_test.go diff --git a/multiplayer-next.md b/multiplayer-next.md index 4f37f1d5..a9898d9d 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1455,6 +1455,8 @@ Ranked maintenance now marks expired seasons with no ranked profiles as rolled o Season rollover now computes from the row locked inside its serializable transaction rather than a stale caller snapshot; the PostgreSQL integration regression deliberately passes a 1900 profile against a durable 2000 rating and verifies the 1875 result is preserved. +The production ranked-profile adapter now projects the active ranked season ID from the durable `seasons` table while keeping rollover history separate; the API prefers that current-season value and retains the legacy in-memory fallback for existing callers. + Ranked proposal admission no longer trusts the matcher’s `--ranked-random-arena` boolean. The Go domain now owns a named allowlist for the three floor-goal `ArenaRegistry` entries, and rejects unknown and elevated IDs before any proposal is created. The arena hand-off is now durable: the matcher deterministically selects an eligible floor-goal arena from the proposal ID, migration 0008 stores that path on proposals and matches and enforces it for new direct SQL writes, migration 0009 retains it on provider allocations, domain/store/provider boundaries and recovery lookups recheck the same allowlist, allocation claims and idempotency digests retain it, Agones applies it as a match-scoped annotation, and the supervisor overlays the allocated child’s `--arena-path`. Godot accepts only the same floor-goal `ArenaRegistry` paths and requires one for allocated ranked matches, so a stale Fleet default, an elevated variant, or an altered retry cannot substitute a ranked arena. The recovery worker also rejects a provider-recovered allocation whose arena differs from the durable request before recording or binding it. diff --git a/server/api/service.go b/server/api/service.go index 5c8a454c..cc2ed52a 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -996,7 +996,11 @@ func (s *Service) rankedProfile(w http.ResponseWriter, r *http.Request) { return } s.logEvent(observability.Event{Event: "ranked_profile_get", Stage: "ok", OccurredAt: s.now()}) - 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}) + seasonID := profile.CurrentSeasonID + if seasonID == "" { + seasonID = profile.LastSeasonID + } + 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: seasonID}) } type probeRequest struct { diff --git a/server/api/service_test.go b/server/api/service_test.go index 9c9d36a6..64d70025 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -1079,7 +1079,7 @@ func TestRankedProfileAPIReturnsBackendTierAndHidesCasualData(t *testing.T) { } 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"}}, + RankedProfiles: map[string]domain.RankedProfile{"player-a": {Rating: domain.Rating{Value: 1600, RD: 200, Volatility: 0.06}, RankedGames: 10, CurrentSeasonID: "season-current", LastSeasonID: "season-1"}}, TierPolicy: policy, Now: func() time.Time { return now }, } @@ -1099,7 +1099,7 @@ func TestRankedProfileAPIReturnsBackendTierAndHidesCasualData(t *testing.T) { 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" { + if body.Tier != string(domain.RankTierGold) || body.Provisional || body.RankedGames != 10 || body.SeasonID != "season-current" { t.Fatalf("ranked profile response = %+v", body) } } diff --git a/server/domain/rating.go b/server/domain/rating.go index 49d3fe8b..87a3f0a1 100644 --- a/server/domain/rating.go +++ b/server/domain/rating.go @@ -60,9 +60,10 @@ func ScoreForPlayer(outcome MatchOutcome, playerID string, team int) (float64, e type RankedProfile struct { Rating - RankedGames int - LastSeasonID string - SeasonHistory []string + RankedGames int + CurrentSeasonID string + LastSeasonID string + SeasonHistory []string } type RankTier string diff --git a/server/store/postgres_integration_test.go b/server/store/postgres_integration_test.go index 462cca69..9028c369 100644 --- a/server/store/postgres_integration_test.go +++ b/server/store/postgres_integration_test.go @@ -1275,6 +1275,26 @@ func TestPostgreSQLEmptyRankedSeasonIsMarkedRolledOver(t *testing.T) { } } +func TestPostgreSQLRankedProfileProjectsActiveSeason(t *testing.T) { + db := openIntegrationPostgres(t) + applyIntegrationMigrations(t, db) + now := time.Now().UTC().Truncate(time.Microsecond) + ctx := context.Background() + if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ('profile-season-player', 'profile-season-steam')`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO ratings (player_id, rating, deviation, volatility, ranked_games) VALUES ('profile-season-player', 1600, 200, 0.06, 10)`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO seasons (season_id, playlist, starts_at, ends_at) VALUES ('profile-season-current', 'ranked', $1, $2)`, now.Add(-time.Hour), now.Add(time.Hour)); err != nil { + t.Fatal(err) + } + profile, found, err := (PostgresRankedProfiles{DB: db}).Get(ctx, "profile-season-player") + if err != nil || !found || profile.CurrentSeasonID != "profile-season-current" { + t.Fatalf("profile=%+v found=%t err=%v", profile, found, err) + } +} + func TestPostgreSQLMigrationsAreForwardExecutable(t *testing.T) { db := openIntegrationPostgres(t) applyIntegrationMigrations(t, db) diff --git a/server/store/ranked_profile_sql.go b/server/store/ranked_profile_sql.go index 00c9eeb3..4e16999c 100644 --- a/server/store/ranked_profile_sql.go +++ b/server/store/ranked_profile_sql.go @@ -8,7 +8,10 @@ import ( "github.com/cosmic-clash/cosmic-clash/server/domain" ) -const RankedProfileSelectSQL = `SELECT rating, deviation, volatility, ranked_games, updated_at +const RankedProfileSelectSQL = `SELECT rating, deviation, volatility, ranked_games, updated_at, + COALESCE((SELECT season_id FROM seasons + WHERE playlist = 'ranked' AND starts_at <= CURRENT_TIMESTAMP AND ends_at > CURRENT_TIMESTAMP + ORDER BY starts_at DESC, season_id DESC LIMIT 1), '') FROM ratings WHERE player_id = $1` @@ -19,11 +22,10 @@ WHERE player_id = $1` // 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. +// LastSeasonID and SeasonHistory remain zero-valued because they describe +// rollover history, while CurrentSeasonID is derived from the active ranked +// season row. Keeping those concepts separate prevents the profile endpoint +// from making a current season look already rolled over to maintenance. type PostgresRankedProfiles struct{ DB *sql.DB } func (p PostgresRankedProfiles) Get(ctx context.Context, playerID string) (domain.RankedProfile, bool, error) { @@ -32,7 +34,7 @@ func (p PostgresRankedProfiles) Get(ctx context.Context, playerID string) (domai } var profile domain.RankedProfile err := p.DB.QueryRowContext(ctx, RankedProfileSelectSQL, playerID). - Scan(&profile.Value, &profile.RD, &profile.Volatility, &profile.RankedGames, &profile.LastRatedAt) + Scan(&profile.Value, &profile.RD, &profile.Volatility, &profile.RankedGames, &profile.LastRatedAt, &profile.CurrentSeasonID) if err == sql.ErrNoRows { return domain.RankedProfile{}, false, nil } diff --git a/server/store/ranked_profile_sql_test.go b/server/store/ranked_profile_sql_test.go new file mode 100644 index 00000000..abd5ab1d --- /dev/null +++ b/server/store/ranked_profile_sql_test.go @@ -0,0 +1,11 @@ +package store + +import "testing" + +func TestRankedProfileQueryProjectsOnlyTheActiveRankedSeason(t *testing.T) { + for _, fragment := range []string{"playlist = 'ranked'", "starts_at <= CURRENT_TIMESTAMP", "ends_at > CURRENT_TIMESTAMP", "ORDER BY starts_at DESC", "LIMIT 1"} { + if !contains(RankedProfileSelectSQL, fragment) { + t.Fatalf("ranked profile query missing %q", fragment) + } + } +}