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. 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. 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. 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 return
} }
s.logEvent(observability.Event{Event: "ranked_profile_get", Stage: "ok", OccurredAt: s.now()}) 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 { type probeRequest struct {
+2 -2
View File
@@ -1079,7 +1079,7 @@ func TestRankedProfileAPIReturnsBackendTierAndHidesCasualData(t *testing.T) {
} }
service := &Service{ service := &Service{
Sessions: sessions, 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, TierPolicy: policy,
Now: func() time.Time { return now }, 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 { if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
t.Fatal(err) 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) 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 { type RankedProfile struct {
Rating Rating
RankedGames int RankedGames int
LastSeasonID string CurrentSeasonID string
SeasonHistory []string LastSeasonID string
SeasonHistory []string
} }
type RankTier 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) { func TestPostgreSQLMigrationsAreForwardExecutable(t *testing.T) {
db := openIntegrationPostgres(t) db := openIntegrationPostgres(t)
applyIntegrationMigrations(t, db) applyIntegrationMigrations(t, db)
+9 -7
View File
@@ -8,7 +8,10 @@ import (
"github.com/cosmic-clash/cosmic-clash/server/domain" "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 FROM ratings
WHERE player_id = $1` WHERE player_id = $1`
@@ -19,11 +22,10 @@ WHERE player_id = $1`
// the same way the in-memory RankedProfiles map api.Service still falls // the same way the in-memory RankedProfiles map api.Service still falls
// back to already did: (zero value, false, nil). // back to already did: (zero value, false, nil).
// //
// LastSeasonID and SeasonHistory are deliberately left at their zero values. // LastSeasonID and SeasonHistory remain zero-valued because they describe
// The ratings table has no "current season" column, and reconstructing // rollover history, while CurrentSeasonID is derived from the active ranked
// season history means a second query against ranked_season_rollovers with // season row. Keeping those concepts separate prevents the profile endpoint
// its own display semantics to settle -- a real, separate piece of work, // from making a current season look already rolled over to maintenance.
// not bundled into this read path speculatively.
type PostgresRankedProfiles struct{ DB *sql.DB } type PostgresRankedProfiles struct{ DB *sql.DB }
func (p PostgresRankedProfiles) Get(ctx context.Context, playerID string) (domain.RankedProfile, bool, error) { 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 var profile domain.RankedProfile
err := p.DB.QueryRowContext(ctx, RankedProfileSelectSQL, playerID). 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 { if err == sql.ErrNoRows {
return domain.RankedProfile{}, false, nil 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)
}
}
}