mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-12 00:33:44 +00:00
feat(multiplayer): expose active ranked season
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user