Files
Josh Creek 2702e53068 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.
2026-09-05 15:38:24 +01:00

80 lines
2.8 KiB
Go

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
}