diff --git a/multiplayer-next.md b/multiplayer-next.md index 7d53a177..dc325802 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -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. diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 8ebc0364..c760f850 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -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 | diff --git a/server/domain/rating.go b/server/domain/rating.go index a5afd4eb..250755a7 100644 --- a/server/domain/rating.go +++ b/server/domain/rating.go @@ -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 diff --git a/server/domain/tier_test.go b/server/domain/tier_test.go new file mode 100644 index 00000000..4085e268 --- /dev/null +++ b/server/domain/tier_test.go @@ -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") + } +}