feat(multiplayer): expose active ranked season

This commit is contained in:
Josh Creek
2026-09-01 21:34:57 +01:00
parent 3151e54ab3
commit eb3b685af0
7 changed files with 53 additions and 13 deletions
+2
View File
@@ -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 matchers `--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 childs `--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.
+5 -1
View File
@@ -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 {
+2 -2
View File
@@ -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)
}
}
+4 -3
View File
@@ -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
+20
View File
@@ -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)
+9 -7
View File
@@ -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
}
+11
View File
@@ -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)
}
}
}