package domain import ( "fmt" "math" "sort" "time" ) const ( GlickoScale = 173.7178 GlickoTau = 0.5 GlickoEpsilon = 0.000001 GlickoInitialRating = 1500.0 GlickoInitialRD = 350.0 GlickoInitialVolatility = 0.06 RankedSeasonLength = 12 * 7 * 24 * time.Hour ) type Rating struct { Value float64 RD float64 Volatility float64 LastRatedAt time.Time } type Opponent struct { PlayerID string Rating Rating Weight float64 Score float64 } type MatchOutcome struct { Team0Score int Team1Score int Overtime bool Abandoners map[string]bool } func ScoreForPlayer(outcome MatchOutcome, playerID string, team int) (float64, error) { if playerID == "" || (team != 0 && team != 1) || outcome.Team0Score < 0 || outcome.Team1Score < 0 { return 0, fmt.Errorf("invalid match outcome") } if outcome.Abandoners[playerID] { return 0, nil } if outcome.Team0Score == outcome.Team1Score { return 0.5, nil } winner := 0 if outcome.Team1Score > outcome.Team0Score { winner = 1 } if team == winner { return 1, nil } return 0, nil } type RankedProfile struct { Rating RankedGames int LastSeasonID 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 } // DefaultTierPolicy is the backend-owned launch policy used by runnable API // binaries. Callers still serialize only the resulting tier; clients never // receive or reproduce these thresholds. func DefaultTierPolicy() TierPolicy { return TierPolicy{bands: []TierBand{ {Tier: RankTierBronze, MinRating: 0}, {Tier: RankTierSilver, MinRating: 1200}, {Tier: RankTierGold, MinRating: 1500}, {Tier: RankTierPlatinum, MinRating: 1800}, {Tier: RankTierDiamond, MinRating: 2200}, }} } 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 EndsAt time.Time RolledOverAt time.Time } func NewRankedSeason(seasonID string, startsAt time.Time) (RankedSeason, error) { if seasonID == "" || startsAt.IsZero() { return RankedSeason{}, fmt.Errorf("invalid ranked season") } return RankedSeason{SeasonID: seasonID, StartsAt: startsAt, EndsAt: startsAt.Add(RankedSeasonLength)}, nil } func SeasonRolloverDue(season RankedSeason, now time.Time) bool { return season.SeasonID != "" && !season.EndsAt.IsZero() && !now.Before(season.EndsAt) && season.RolledOverAt.IsZero() } func RankedIsProvisional(profile RankedProfile) bool { return profile.RankedGames < 10 } // ApplySeasonRollover is idempotent by season ID. It intentionally accepts a // ranked profile, not the shared/casual rating type, so callers cannot reset a // casual rating accidentally. The transaction adapter must persist the // returned profile and season ID atomically with its idempotency key. func ApplySeasonRollover(profile RankedProfile, seasonID string) (RankedProfile, bool, error) { if seasonID == "" { return RankedProfile{}, false, fmt.Errorf("season ID is required") } if profile.RankedGames < 0 { return RankedProfile{}, false, fmt.Errorf("ranked games cannot be negative") } if err := validateRating(profile.Rating); err != nil { return RankedProfile{}, false, err } if profile.LastSeasonID == seasonID || containsSeason(profile.SeasonHistory, seasonID) { return profile, false, nil } profile.Value = GlickoInitialRating + 0.75*(profile.Value-GlickoInitialRating) profile.RD = math.Min(GlickoInitialRD, math.Max(200.0, profile.RD)) profile.LastSeasonID = seasonID profile.SeasonHistory = append(append([]string(nil), profile.SeasonHistory...), seasonID) return profile, true, nil } func containsSeason(history []string, seasonID string) bool { for _, prior := range history { if prior == seasonID { return true } } return false } // UpdateRating applies canonical Glicko-2 to one player's immutable pre-match // rating snapshot. Weight is 1/3 for ranked 3v3 and 1/N for casual's N human // opponents; bots are simply omitted by the caller. func UpdateRating(current Rating, opponents []Opponent, now time.Time) (Rating, error) { if err := validateRating(current); err != nil { return Rating{}, err } if len(opponents) == 0 { return advanceInactivity(current, now), nil } for _, opponent := range opponents { if err := validateRating(opponent.Rating); err != nil { return Rating{}, err } if opponent.Weight <= 0 || opponent.Score < 0 || opponent.Score > 1 { return Rating{}, fmt.Errorf("invalid opponent weight or score") } } working := advanceInactivity(current, now) mu, phi := toScale(working.Value, working.RD) varianceInverse, deltaSum := 0.0, 0.0 for _, opponent := range opponents { oppMu, oppPhi := toScale(opponent.Rating.Value, opponent.Rating.RD) g := glickoG(oppPhi) expected := expectedScore(mu, oppMu, g) varianceInverse += opponent.Weight * g * g * expected * (1 - expected) deltaSum += opponent.Weight * g * (opponent.Score - expected) } if varianceInverse <= 0 { return Rating{}, fmt.Errorf("opponent information has zero variance") } v := 1 / varianceInverse delta := v * deltaSum sigma, err := solveVolatility(phi, v, delta, working.Volatility) if err != nil { return Rating{}, err } phiStar := math.Sqrt(phi*phi + sigma*sigma) phiPrime := 1 / math.Sqrt(1/(phiStar*phiStar)+1/v) muPrime := mu + phiPrime*phiPrime*deltaSum return Rating{Value: fromScaleRating(muPrime), RD: fromScaleRD(phiPrime), Volatility: sigma, LastRatedAt: now}, nil } func validateRating(r Rating) error { if r.Value < 0 || r.RD <= 0 || r.RD > GlickoInitialRD || r.Volatility <= 0 || r.Volatility >= 1 { return fmt.Errorf("invalid rating state") } return nil } func advanceInactivity(r Rating, now time.Time) Rating { if r.LastRatedAt.IsZero() || !now.After(r.LastRatedAt) { return r } periods := int(now.Sub(r.LastRatedAt) / (24 * time.Hour)) if periods <= 0 { return r } phi := r.RD / GlickoScale phi = math.Min(GlickoInitialRD/GlickoScale, math.Sqrt(phi*phi+float64(periods)*r.Volatility*r.Volatility)) r.RD = fromScaleRD(phi) return r } func toScale(rating, rd float64) (float64, float64) { return (rating - GlickoInitialRating) / GlickoScale, rd / GlickoScale } func fromScaleRating(mu float64) float64 { return mu*GlickoScale + GlickoInitialRating } func fromScaleRD(phi float64) float64 { return phi * GlickoScale } func glickoG(phi float64) float64 { return 1 / math.Sqrt(1+3*phi*phi/(math.Pi*math.Pi)) } func expectedScore(mu, opponentMu, g float64) float64 { return 1 / (1 + math.Exp(-g*(mu-opponentMu))) } func solveVolatility(phi, v, delta, volatility float64) (float64, error) { a := math.Log(volatility * volatility) variance := delta*delta - phi*phi - v var b float64 if variance > 0 { b = math.Log(variance) } else { b = a - GlickoTau for volatilityFunction(b, a, phi, v, delta) < 0 { b -= GlickoTau if b < -100 { return 0, fmt.Errorf("volatility bracket not found") } } } fa := volatilityFunction(a, a, phi, v, delta) fb := volatilityFunction(b, a, phi, v, delta) for math.Abs(b-a) > GlickoEpsilon { c := a + (a-b)*fa/(fb-fa) fc := volatilityFunction(c, a, phi, v, delta) if fc*fb < 0 { a, fa = b, fb } else { fa /= 2 } b, fb = c, fc if math.IsNaN(b) || math.IsInf(b, 0) { return 0, fmt.Errorf("volatility iteration diverged") } } return math.Exp(a / 2), nil } func volatilityFunction(x, a, phi, v, delta float64) float64 { expX := math.Exp(x) denominator := 2 * math.Pow(phi*phi+v+expX, 2) return expX*(delta*delta-phi*phi-v-expX)/denominator - (x-a)/(GlickoTau*GlickoTau) } // RankedOpponents assigns the exact 1/3 contribution to each of three human // opponents. CasualOpponents assigns 1/N; both return lexical order so a // database row-order change cannot affect floating-point accumulation order. func RankedOpponents(opponents []Opponent) ([]Opponent, error) { if len(opponents) != 3 { return nil, fmt.Errorf("ranked 3v3 requires three opponents") } return weightedOpponents(opponents, 1.0/3.0), nil } func CasualOpponents(opponents []Opponent) ([]Opponent, error) { if len(opponents) == 0 { return nil, nil } return weightedOpponents(opponents, 1/float64(len(opponents))), nil } func weightedOpponents(opponents []Opponent, weight float64) []Opponent { result := append([]Opponent(nil), opponents...) sort.Slice(result, func(i, j int) bool { if result[i].Rating.Value != result[j].Rating.Value { return result[i].Rating.Value < result[j].Rating.Value } return result[i].PlayerID < result[j].PlayerID }) for i := range result { result[i].Weight = weight } return result }