feat: derive authoritative ranked tiers

This commit is contained in:
Josh Creek
2026-08-31 21:25:53 +01:00
parent 726fe1ce2e
commit 846663e320
4 changed files with 115 additions and 1 deletions
+56
View File
@@ -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
+56
View File
@@ -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")
}
}