mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
feat: derive authoritative ranked tiers
This commit is contained in:
@@ -83,6 +83,8 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md).
|
||||
random-enabled non-elevated arenas only, 60 s reconnect grace and escalating abandons.
|
||||
- [ ] **IN PROGRESS:** Implement the documented exact Glicko-2 equations, fractional 3v3
|
||||
weights, inactivity/update locking/golden vectors and ten provisional games.
|
||||
Backend-owned provisional status and validated ranked-tier derivation now exist;
|
||||
authoritative profile transport and client display remain.
|
||||
- [ ] **IN PROGRESS:** Add ranked-only exactly-once 12-week soft seasons; distinguish retryable
|
||||
result-delivery outages from match-integrity failures and rating exemptions.
|
||||
|
||||
|
||||
+1
-1
@@ -1199,7 +1199,7 @@ the local/CI/community transport, not a silent production fallback.
|
||||
| 8.19 `[D:8.18]` | **IN PROGRESS.** Pure Go casual lineup requires 2–6 humans with at least one per team, fills missing slots with explicit bots, permits kickoff-only bot-slot backfill and assigns no backfill penalty/rating update | `server/domain/casual.go` covers both-team minimum, bot shape, live-play rejection and zero-penalty backfill; queue candidate selection, opt-in 10 s backfill proposals, reconnect/leave penalties and live integration remain |
|
||||
| 8.20 `[D:8.18]` | **IN PROGRESS.** Pure Go ranked admission requires six unique verified solo humans, rejects bots/backfill/parties, and allows only random-enabled non-elevated arenas | `server/domain/ranked.go` covers count, identity, party, bot/backfill and arena eligibility rejection; `ArenaRegistry` integration, proposal/allocation wiring and innocent-ticket restoration remain |
|
||||
| 8.21 `[D:8.5,8.20]` | **IN PROGRESS.** Pure Go rating core implements canonical Glicko-2, daily inactivity, ranked 1/3 and casual 1/N human-opponent weights, deterministic opponent ordering, and authoritative draw/overtime/abandon scoring | `server/domain/rating.go` has canonical/inactivity/weight/invalid-input plus draw/OT/abandon fixtures; PostgreSQL snapshot locking, rating transaction integration, seasons and concurrent result transaction tests remain |
|
||||
| 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional and keeps casual ratings outside the API | `RankedIsProvisional` covers the 0–9/10 boundary; authoritative tier derivation and UI remain |
|
||||
| 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional, derives tiers only through validated backend-owned rating bands, and keeps casual ratings outside the API | `server/domain/rating.go` and `tier_test.go` cover provisional override, exact band boundaries and malformed policy rejection; authoritative response transport, persisted tier policy and UI remain |
|
||||
| 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season policy compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history, is idempotent by season ID, and defines exact 12-week windows/due detection | `server/domain/rating.go` and `season_test.go` cover compression, floor/cap, duplicate replay, window boundary and completed-season idempotence; PostgreSQL locking, persisted rollover transaction and maintenance scheduler remain |
|
||||
| 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Pure Go ranked connection policy binds match/server/player/team/slot/protocol, supports 60 s reclaim with server-owned generations, fences old connections, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder | `server/domain/reconnect.go` covers repeated backend-independent reclaim, binding rejection, old-generation fencing, grace boundary and deterministic cooldown audit ordering; signed authorisations, persistent lease fencing, join transport and full match/result integration remain |
|
||||
| 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Pure Go result policy binds match/server/workload identity, hashes canonical payloads, makes identical retries idempotent, leaves conflicts inert, separates integrity eligibility, classifies roster/simulation/result/fairness evidence, validates annotation signatures/digests, and exposes 5 m alert/30 m review delivery thresholds; Go store SQL defines conflict-safe receipt insert, deterministic match/rating locks and atomic completion/outbox boundaries | `server/domain/result.go`, `server/domain/workload.go` and `server/store/result_sql.go` plus adversarial fixtures cover credential binding, duplicate/conflict, annotation forgery, delivery-outage-versus-integrity classification, commit and lock ordering; production credential verification, Agones annotation persistence/reconciliation, live PostgreSQL execution and integrity evidence adapters remain |
|
||||
|
||||
@@ -65,6 +65,62 @@ type RankedProfile struct {
|
||||
SeasonHistory []string
|
||||
}
|
||||
|
||||
type RankTier string
|
||||
|
||||
const (
|
||||
RankTierProvisional RankTier = "PROVISIONAL"
|
||||
RankTierBronze RankTier = "BRONZE"
|
||||
RankTierSilver RankTier = "SILVER"
|
||||
RankTierGold RankTier = "GOLD"
|
||||
RankTierPlatinum RankTier = "PLATINUM"
|
||||
RankTierDiamond RankTier = "DIAMOND"
|
||||
)
|
||||
|
||||
// TierBand is backend configuration, not client input. Bands are evaluated in
|
||||
// ascending minimum-rating order and the highest matching band wins.
|
||||
type TierBand struct {
|
||||
Tier RankTier
|
||||
MinRating float64
|
||||
}
|
||||
|
||||
type TierPolicy struct {
|
||||
bands []TierBand
|
||||
}
|
||||
|
||||
func NewTierPolicy(bands []TierBand) (TierPolicy, error) {
|
||||
if len(bands) == 0 || bands[0].MinRating > 0 {
|
||||
return TierPolicy{}, fmt.Errorf("tier policy must start at or below zero")
|
||||
}
|
||||
copyBands := append([]TierBand(nil), bands...)
|
||||
for i, band := range copyBands {
|
||||
if band.Tier == "" || math.IsNaN(band.MinRating) || math.IsInf(band.MinRating, 0) || (i > 0 && band.MinRating <= copyBands[i-1].MinRating) {
|
||||
return TierPolicy{}, fmt.Errorf("tier bands must have unique ascending finite thresholds")
|
||||
}
|
||||
}
|
||||
return TierPolicy{bands: copyBands}, nil
|
||||
}
|
||||
|
||||
// RankedTier is the only tier derivation entry point. It deliberately accepts
|
||||
// RankedProfile rather than Rating, so a casual rating cannot be accidentally
|
||||
// exposed as a ranked tier. The caller serializes this result from the
|
||||
// authoritative backend response; clients do not reproduce these thresholds.
|
||||
func RankedTier(profile RankedProfile, policy TierPolicy) (RankTier, error) {
|
||||
if profile.RankedGames < 0 || len(policy.bands) == 0 || math.IsNaN(profile.Value) || math.IsInf(profile.Value, 0) {
|
||||
return "", fmt.Errorf("invalid ranked tier input")
|
||||
}
|
||||
if RankedIsProvisional(profile) {
|
||||
return RankTierProvisional, nil
|
||||
}
|
||||
tier := policy.bands[0].Tier
|
||||
for _, band := range policy.bands {
|
||||
if profile.Value < band.MinRating {
|
||||
break
|
||||
}
|
||||
tier = band.Tier
|
||||
}
|
||||
return tier, nil
|
||||
}
|
||||
|
||||
type RankedSeason struct {
|
||||
SeasonID string
|
||||
StartsAt time.Time
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func testTierPolicy(t *testing.T) TierPolicy {
|
||||
t.Helper()
|
||||
policy, err := NewTierPolicy([]TierBand{
|
||||
{Tier: RankTierBronze, MinRating: 0},
|
||||
{Tier: RankTierSilver, MinRating: 1200},
|
||||
{Tier: RankTierGold, MinRating: 1500},
|
||||
{Tier: RankTierPlatinum, MinRating: 1800},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func TestRankedTierUsesAuthoritativeBandsAndExactBoundaries(t *testing.T) {
|
||||
policy := testTierPolicy(t)
|
||||
for _, test := range []struct {
|
||||
rating float64
|
||||
games int
|
||||
want RankTier
|
||||
}{
|
||||
{rating: 2000, games: 0, want: RankTierProvisional},
|
||||
{rating: 1199.99, games: 10, want: RankTierBronze},
|
||||
{rating: 1200, games: 10, want: RankTierSilver},
|
||||
{rating: 1499.99, games: 10, want: RankTierSilver},
|
||||
{rating: 1500, games: 10, want: RankTierGold},
|
||||
{rating: 1800, games: 10, want: RankTierPlatinum},
|
||||
} {
|
||||
got, err := RankedTier(RankedProfile{Rating: Rating{Value: test.rating}, RankedGames: test.games}, policy)
|
||||
if err != nil || got != test.want {
|
||||
t.Errorf("rating %.2f games %d = %s, err=%v; want %s", test.rating, test.games, got, err, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTierPolicyRejectsUnorderedOrUnboundedConfiguration(t *testing.T) {
|
||||
for _, bands := range [][]TierBand{
|
||||
{},
|
||||
{{Tier: RankTierBronze, MinRating: 1}},
|
||||
{{Tier: RankTierBronze, MinRating: 0}, {Tier: RankTierSilver, MinRating: 0}},
|
||||
{{Tier: RankTierBronze, MinRating: 0}, {Tier: RankTierSilver, MinRating: -1}},
|
||||
{{Tier: RankTierBronze, MinRating: 0}, {Tier: RankTier(""), MinRating: 1200}},
|
||||
} {
|
||||
if _, err := NewTierPolicy(bands); err == nil {
|
||||
t.Errorf("invalid tier policy accepted: %+v", bands)
|
||||
}
|
||||
}
|
||||
policy := testTierPolicy(t)
|
||||
if _, err := RankedTier(RankedProfile{Rating: Rating{Value: 1500}, RankedGames: -1}, policy); err == nil {
|
||||
t.Fatal("negative ranked games accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user