mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +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.
|
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.
|
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.
|
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
|
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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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