mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
333 lines
10 KiB
Go
333 lines
10 KiB
Go
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
|
|
CurrentSeasonID string
|
|
CurrentSeasonEndsAt time.Time
|
|
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
|
|
}
|