// Package elo implements a practical Elo rating update for head-to-head sports // such as table soccer. This variant has: // - No home/away asymmetry // - Margin of victory (MoV) factor using a robust bounded form // - Simple burn-in: higher K for the first N games // - No draws (inputs must produce a clear winner) // - Rating floor // - Per-match delta cap // - No time weighting and no explicit uncertainty modeling package main import ( "errors" "math" ) var eloCfg = Config{ Mu0: 1500, Scale: 400, KNew: 48, KStd: 24, BurnInGames: 20, RatingFloor: 500, MaxPerMatchDelta: 60, MaxGoals: 10, } type Config struct { Mu0 float64 Scale float64 // KNew applies to players during burn-in (first BurnInGames matches). KNew float64 KStd float64 BurnInGames int RatingFloor float64 MaxPerMatchDelta float64 MaxGoals int } type Team struct { Players []Player Score int AverageRating float64 } // Collect players by side type Player struct { u *User side string dElo float64 } func NewTeam(players []Player, score int) *Team { var t1s float64 = 0 for _, player := range players { t1s += float64(player.u.Elo) } t1a := t1s / float64(len(players)) return &Team{Players: players, Score: score, AverageRating: t1a} } func GetNewElo(teams []*Team) error { // Update the teams t1 := teams[0] t2 := teams[1] if t2.Score < 0 || t1.Score < 0 || t2.Score > eloCfg.MaxGoals || t1.Score > eloCfg.MaxGoals { return errors.New("goals out of allowed range") } if t2.Score == t1.Score { return errors.New("draws are not supported by this variant") } // Expected score (Bradley–Terry with logistic base-10) for i, t := range teams { otherTeam := teams[1-i] for i, p := range t.Players { newRating, err := CalculateRating(p.u.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.u.GameCount) if err != nil { return err } t.Players[i].u.Elo = roundFloat(newRating, 1) } } return nil } func CalculateRating(rA, rB float64, goalsA, goalsB, gamesA int) (newrA float64, err error) { // Observed score (no draws) var sA float64 if goalsA > goalsB { sA = 1.0 } else { sA = 0.0 } // Margin of victory factor with clamped goal difference diff := goalsA - goalsB if diff < 0 { diff = -diff } if diff < 1 { diff = 1 } if diff > eloCfg.MaxGoals { diff = eloCfg.MaxGoals } // Raw delta before caps eA := expected(eloCfg.Scale, rA, rB) mov := movFactor(rA, rB, diff) var Keff float64 if gamesA <= eloCfg.BurnInGames { Keff = eloCfg.KNew } else { Keff = eloCfg.KStd } delta := Keff * mov * (sA - eA) // Apply symmetric per-match cap if delta > eloCfg.MaxPerMatchDelta { delta = eloCfg.MaxPerMatchDelta } else if delta < -eloCfg.MaxPerMatchDelta { delta = -eloCfg.MaxPerMatchDelta } return rA + delta, nil } // expected returns the win probability for player A against B func expected(scale, rA, rB float64) float64 { return 1.0 / (1.0 + math.Pow(10.0, -(rA-rB)/scale)) } // movFactor returns a bounded margin-of-victory multiplier. func movFactor(rA, rB float64, diff int) float64 { fd := float64(diff) return math.Log(fd+1.0) * 2.2 / (math.Abs(rA-rB)*0.001 + 2.2) }