mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 17:22:07 +00:00
feat: derive authoritative ranked tiers
This commit is contained in:
@@ -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