mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(ranked): make tier thresholds durable instead of compiled in
Task 8.22. Tier bands lived in domain.DefaultTierPolicy(), compiled into every API binary, so retuning one meant building and rolling a new image -- least attractive exactly when it is most needed, as the rating distribution settles after launch. Bands now live in a tier_bands table, seeded by the migration with the exact policy the binaries hardcode, so this changes durable state without changing behaviour. Retuning is a rolling restart rather than a rebuild. Three properties the loader deliberately holds: - A malformed durable policy stops startup. Falling back on error would silently mis-tier every player, which is worse than not starting. - An empty table is supported and falls back to the compiled default, so an operator can truncate back to known-good without a deploy, and a fresh database works before the seed is reviewed. - PROVISIONAL is rejected as a band. It is derived from ranked game count, not rating, so a band claiming it would be unreachable at best and would shadow a real tier at worst. Bands stay backend-owned; clients still receive only the resulting label, per docs/MATCHMAKING.md. UNIQUE(min_rating) rejects two bands sharing a threshold, catching an ambiguous policy before NewTierPolicy does. testkit-api loads it too, so the control-plane integration scripts exercise the durable path rather than the compiled default. Integration tests cover the seeded policy matching the compiled one, retuning taking effect from the database alone, truncation falling back, and each invalid-policy shape being rejected. Verified they fail against a loader that ignores durable bands. The other two parts of 8.22 needed no work: the client UI already renders tier, provisional status, ranked games and the season countdown, and reconnect transport is 8.42's, dependent on live backend events.
This commit is contained in:
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS tier_bands;
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user