mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 14:42:04 +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.
|
random-enabled non-elevated arenas only, 60 s reconnect grace and escalating abandons.
|
||||||
- [ ] **IN PROGRESS:** Implement the documented exact Glicko-2 equations, fractional 3v3
|
- [ ] **IN PROGRESS:** Implement the documented exact Glicko-2 equations, fractional 3v3
|
||||||
weights, inactivity/update locking/golden vectors and ten provisional games.
|
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
|
- [ ] **IN PROGRESS:** Add ranked-only exactly-once 12-week soft seasons; distinguish retryable
|
||||||
result-delivery outages from match-integrity failures and rating exemptions.
|
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.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.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.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.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.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 |
|
| 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
|
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 {
|
type RankedSeason struct {
|
||||||
SeasonID string
|
SeasonID string
|
||||||
StartsAt time.Time
|
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