diff --git a/multiplayer-next.md b/multiplayer-next.md index f7babc1b..023a66ed 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -217,7 +217,7 @@ are done; everything below is what's left on the tasks still open. | 8.19 `[D:8.18]` | Casual lineup (2–6 humans, bot backfill) | Queue candidate selection, opt-in 10 s backfill proposals, reconnect/leave penalties, live integration remain | | 8.20 `[D:8.18]` | Ranked admission (six unique verified humans) | Done. Allocation wiring was already complete end to end (allocator sets the `cosmic-clash.io/arena-path` annotation → `supervisor.withAllocatedCompatibility` maps it to `--arena-path` → `server_boot.gd` → `ServerMatchLoop.allocated_arena_path`), with coverage at each hop. `ArenaRegistry` integration is now a cross-language guard rather than a shared list: `server/domain/ranked.go` must keep its own ranked-eligible subset (the choice is server-authoritative and made before any Godot process exists), so `arena_registry_sync_test.go` parses `arena_registry.gd` and fails if the two disagree in either direction, if rotation order diverges, or if a ranked path has no scene behind it. Verified against four drift scenarios including promoting an elevated variant, which the registry's own comment anticipates. Live ranked admission against a real cluster remains ([#17](https://github.com/jcreek/CosmicClash/issues/17)) | | 8.21 `[D:8.5,8.20]` | Rating core (Glicko-2, weights, transactional updates) | Live maintenance/DB execution remains | -| 8.22 `[D:8.21]` | Ranked profile (provisional games, tiers) | Persisted tier policy, client UI, reconnect transport remain | +| 8.22 `[D:8.21]` | Ranked profile (provisional games, tiers) | Persisted tier policy done: bands live in `tier_bands`, seeded with the exact compiled launch policy so storage changed without behaviour changing, loaded at startup with a malformed policy failing startup rather than silently mis-tiering, and an empty table falling back to the compiled default so an operator can truncate back to known-good. Retuning is now a rolling restart rather than a rebuilt image. `PROVISIONAL` is rejected as a durable band, being derived from game count rather than rating. Client UI was already built (`RankedProfileState.display_text()` renders tier, provisional status, ranked games and the season countdown). Reconnect transport is tracked by 8.42 and depends on live auth/backend events | | 8.23 `[D:8.21]` | Ranked season policy (compression, rollover) | Live maintenance/DB execution remains | | 8.24 `[D:8.9,8.20,8.21]` | Ranked connection policy, reconnect lease, abandon ladder | Live PostgreSQL execution now verified (`make verify-phase6` and every integration script run clean). Process-restart and outage execution remain | | 8.25 `[D:8.10,8.24]` | Result policy (workload-bound, idempotent, transactional) | Production credentials, Agones annotation persistence/reconciliation, integrity-evidence adapters remain | diff --git a/server/cmd/control-plane/main.go b/server/cmd/control-plane/main.go index 77f80478..f21d9e65 100644 --- a/server/cmd/control-plane/main.go +++ b/server/cmd/control-plane/main.go @@ -94,6 +94,15 @@ func main() { } } service := newAPIService(db, *workloadSecret, candidateIndex) + // Tier thresholds live in the database so they can be retuned with a + // rolling restart rather than a rebuilt image. A malformed durable policy + // stops startup instead of silently mis-tiering every player; an empty + // table is a supported state and falls back to the compiled launch policy. + tierPolicy, err := store.LoadTierPolicy(startupCtx, db) + if err != nil { + fatalf("load tier policy: %v", err) + } + service.TierPolicy = tierPolicy // Player sign-in is configuration-gated rather than always-on: without a // publisher key there is no safe way to verify a ticket, and silently // accepting one would be worse than refusing to authenticate at all. The diff --git a/server/cmd/testkit-api/main.go b/server/cmd/testkit-api/main.go index 6b5f7147..e28341fb 100644 --- a/server/cmd/testkit-api/main.go +++ b/server/cmd/testkit-api/main.go @@ -77,6 +77,14 @@ func main() { Metrics: observability.NewMetrics(), Now: func() time.Time { return time.Now().UTC() }, } + // Load the durable policy here too, so the control-plane integration + // scripts exercise the same path production takes rather than the + // compiled default. + tierPolicy, err := store.LoadTierPolicy(startupCtx, db) + if err != nil { + fatalf("load tier policy: %v", err) + } + service.TierPolicy = tierPolicy handler := service.Handler() listener, err := net.Listen("tcp", *listen) if err != nil { diff --git a/server/migrations/0018_tier_bands.sql b/server/migrations/0018_tier_bands.sql new file mode 100644 index 00000000..8c7ba1af --- /dev/null +++ b/server/migrations/0018_tier_bands.sql @@ -0,0 +1,24 @@ +-- Ranked tier thresholds were compiled into every API binary +-- (domain.DefaultTierPolicy), so retuning a band meant building and rolling a +-- new image. Tier boundaries are a live-ops knob: they get adjusted as the +-- rating distribution settles after launch, which is exactly when shipping a +-- binary is least attractive. +-- +-- Bands stay backend-owned. Clients receive only the resulting tier label and +-- never these thresholds, per docs/MATCHMAKING.md §6. +CREATE TABLE tier_bands ( + tier TEXT PRIMARY KEY, + min_rating DOUBLE PRECISION NOT NULL, + UNIQUE (min_rating) +); + +-- Seeded with the exact launch policy the binaries currently hardcode, so this +-- migration changes durable state without changing behaviour. The loader falls +-- back to the compiled default when this table is empty, so an operator can +-- also truncate it to return to known-good defaults. +INSERT INTO tier_bands (tier, min_rating) VALUES + ('BRONZE', 0), + ('SILVER', 1200), + ('GOLD', 1500), + ('PLATINUM', 1800), + ('DIAMOND', 2200); diff --git a/server/migrations/down/0018_tier_bands.sql b/server/migrations/down/0018_tier_bands.sql new file mode 100644 index 00000000..5b64a8cc --- /dev/null +++ b/server/migrations/down/0018_tier_bands.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tier_bands; diff --git a/server/store/postgres_integration_test.go b/server/store/postgres_integration_test.go index c3e99acc..6985e6fa 100644 --- a/server/store/postgres_integration_test.go +++ b/server/store/postgres_integration_test.go @@ -2338,3 +2338,98 @@ func TestPostgreSQLSteamLoginResolvesDurableIdentities(t *testing.T) { t.Fatal("a banned identity signed in through the production path") } } + +// Tier thresholds were compiled into every API binary, so retuning a band +// meant building and rolling a new image -- least attractive exactly when it +// is most needed, as the rating distribution settles after launch. +func TestPostgreSQLTierPolicyIsDurableAndOverridesTheCompiledDefault(t *testing.T) { + db := openIntegrationPostgres(t) + applyIntegrationMigrations(t, db) + + ctx := context.Background() + // A settled, non-provisional profile, so RankedTier consults the bands + // rather than short-circuiting to PROVISIONAL. + settled := domain.RankedProfile{Rating: domain.Rating{Value: 1550}, RankedGames: 50} + + // The seed migration must reproduce the compiled launch policy exactly, + // so introducing durable bands changes storage without changing behaviour. + loaded, err := LoadTierPolicy(ctx, db) + if err != nil { + t.Fatalf("load seeded policy: %v", err) + } + seededTier, err := domain.RankedTier(settled, loaded) + if err != nil { + t.Fatalf("tier from seeded policy: %v", err) + } + compiledTier, err := domain.RankedTier(settled, domain.DefaultTierPolicy()) + if err != nil { + t.Fatalf("tier from compiled policy: %v", err) + } + if seededTier != compiledTier || seededTier != domain.RankTierGold { + t.Fatalf("seeded policy tier = %q, compiled = %q, want GOLD", seededTier, compiledTier) + } + + // Retuning a band must take effect from the database alone. GOLD moves + // first: UNIQUE(min_rating) rejects two bands sharing a threshold, which + // is a deliberate early guard against an ambiguous policy. + if _, err := db.ExecContext(ctx, `UPDATE tier_bands SET min_rating = 1400 WHERE tier = 'GOLD'`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `UPDATE tier_bands SET min_rating = 1500 WHERE tier = 'PLATINUM'`); err != nil { + t.Fatal(err) + } + retuned, err := LoadTierPolicy(ctx, db) + if err != nil { + t.Fatalf("load retuned policy: %v", err) + } + retunedTier, err := domain.RankedTier(settled, retuned) + if err != nil { + t.Fatalf("tier from retuned policy: %v", err) + } + if retunedTier != domain.RankTierPlatinum { + t.Fatalf("retuned tier = %q, want PLATINUM; durable bands did not take effect", retunedTier) + } + + // An empty table is a supported state: an operator can truncate it to + // return to known-good defaults without a deploy. + if _, err := db.ExecContext(ctx, `DELETE FROM tier_bands`); err != nil { + t.Fatal(err) + } + fallback, err := LoadTierPolicy(ctx, db) + if err != nil { + t.Fatalf("load empty policy: %v", err) + } + fallbackTier, err := domain.RankedTier(settled, fallback) + if err != nil { + t.Fatalf("tier from fallback policy: %v", err) + } + if fallbackTier != domain.RankTierGold { + t.Fatalf("fallback tier = %q, want the compiled GOLD", fallbackTier) + } +} + +// A malformed durable policy must stop startup rather than silently mis-tier +// every player, so these are load errors and not best-effort skips. +func TestPostgreSQLInvalidTierPolicyIsRejectedRatherThanIgnored(t *testing.T) { + db := openIntegrationPostgres(t) + applyIntegrationMigrations(t, db) + ctx := context.Background() + + for name, mutate := range map[string]string{ + // NewTierPolicy requires the lowest band at or below zero; without it + // a player below the floor has no tier at all. + "no floor band": `DELETE FROM tier_bands WHERE tier = 'BRONZE'`, + "unknown tier": `INSERT INTO tier_bands (tier, min_rating) VALUES ('MYTHIC', 3000)`, + "provisional": `INSERT INTO tier_bands (tier, min_rating) VALUES ('PROVISIONAL', 2500)`, + } { + t.Run(name, func(t *testing.T) { + applyIntegrationMigrations(t, db) + if _, err := db.ExecContext(ctx, mutate); err != nil { + t.Fatal(err) + } + if _, err := LoadTierPolicy(ctx, db); err == nil { + t.Fatalf("%s was accepted as a durable tier policy", name) + } + }) + } +} diff --git a/server/store/tier_policy_sql.go b/server/store/tier_policy_sql.go new file mode 100644 index 00000000..3e626155 --- /dev/null +++ b/server/store/tier_policy_sql.go @@ -0,0 +1,79 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + + "github.com/cosmic-clash/cosmic-clash/server/domain" +) + +// TierBandSelectSQL returns bands in evaluation order. domain.NewTierPolicy +// requires strictly ascending thresholds, so ordering here is part of the +// contract rather than a convenience. +const TierBandSelectSQL = `SELECT tier, min_rating +FROM tier_bands +ORDER BY min_rating` + +// validTierBandTiers is the closed set a durable band may name. PROVISIONAL is +// deliberately absent: it is derived from a player's ranked game count, not +// from their rating, so a band claiming it would be unreachable at best and +// would mask a real tier at worst. +var validTierBandTiers = map[domain.RankTier]struct{}{ + domain.RankTierBronze: {}, + domain.RankTierSilver: {}, + domain.RankTierGold: {}, + domain.RankTierPlatinum: {}, + domain.RankTierDiamond: {}, +} + +// LoadTierPolicy reads the durable tier bands, falling back to the compiled +// launch policy when none are configured. +// +// Tier thresholds used to be compiled into every API binary, so retuning a +// band meant building and rolling a new image -- least attractive exactly when +// it is most needed, as the rating distribution settles after launch. The +// fallback means an empty table is a supported state: an operator can truncate +// it to return to known-good defaults, and a fresh database works before the +// seed migration has been reviewed. +// +// Bands are read once at startup, matching how every other operational input +// to this binary is supplied. Changing them takes a rolling restart, not a +// rebuild, which is the actual gain here. +func LoadTierPolicy(ctx context.Context, db *sql.DB) (domain.TierPolicy, error) { + if db == nil { + return domain.TierPolicy{}, fmt.Errorf("invalid tier policy database") + } + rows, err := db.QueryContext(ctx, TierBandSelectSQL) + if err != nil { + return domain.TierPolicy{}, err + } + defer rows.Close() + var bands []domain.TierBand + for rows.Next() { + var tier string + var minRating float64 + if err := rows.Scan(&tier, &minRating); err != nil { + return domain.TierPolicy{}, err + } + if _, known := validTierBandTiers[domain.RankTier(tier)]; !known { + return domain.TierPolicy{}, fmt.Errorf("tier_bands contains unknown tier %q", tier) + } + bands = append(bands, domain.TierBand{Tier: domain.RankTier(tier), MinRating: minRating}) + } + if err := rows.Err(); err != nil { + return domain.TierPolicy{}, err + } + if len(bands) == 0 { + return domain.DefaultTierPolicy(), nil + } + // Validated rather than trusted: a malformed durable policy must fail + // loudly at startup, not silently mis-tier every player. NewTierPolicy + // enforces a band at or below zero and strictly ascending finite + // thresholds. + policy, err := domain.NewTierPolicy(bands) + if err != nil { + return domain.TierPolicy{}, fmt.Errorf("durable tier policy is invalid: %w", err) + } + return policy, nil +} diff --git a/server/store/tier_policy_sql_test.go b/server/store/tier_policy_sql_test.go new file mode 100644 index 00000000..5dd92f3e --- /dev/null +++ b/server/store/tier_policy_sql_test.go @@ -0,0 +1,38 @@ +package store + +import ( + "testing" + + "github.com/cosmic-clash/cosmic-clash/server/domain" +) + +func TestLoadTierPolicyRejectsMissingDatabase(t *testing.T) { + if _, err := LoadTierPolicy(nil, nil); err == nil { + t.Fatal("a nil database was accepted") + } +} + +func TestTierBandSelectIsOrderedByThreshold(t *testing.T) { + // domain.NewTierPolicy requires strictly ascending thresholds, so the + // ORDER BY is part of the contract rather than presentation. + if !contains(TierBandSelectSQL, "ORDER BY min_rating") { + t.Fatalf("tier band query is not ordered: %q", TierBandSelectSQL) + } +} + +// PROVISIONAL is derived from a player's ranked game count, not their rating. +// A durable band claiming it would be unreachable at best, and would shadow a +// real tier at worst. +func TestProvisionalIsNotAValidDurableBand(t *testing.T) { + if _, ok := validTierBandTiers[domain.RankTierProvisional]; ok { + t.Fatal("PROVISIONAL is accepted as a durable tier band") + } + for _, tier := range []domain.RankTier{ + domain.RankTierBronze, domain.RankTierSilver, domain.RankTierGold, + domain.RankTierPlatinum, domain.RankTierDiamond, + } { + if _, ok := validTierBandTiers[tier]; !ok { + t.Fatalf("%q is not accepted as a durable tier band", tier) + } + } +}