// File 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 services import ( "errors" "math" "github.com/ascyii/qrank/src/repository" "github.com/ascyii/qrank/src/utils" ) var eloCfg = struct { Mu0 float32 Scale float32 // KNew applies to players during burn-in (first BurnInGames matches). KNew float32 KStd float32 BurnInGames int RatingFloor float32 MaxPerMatchDelta float32 MaxGoals int }{ Mu0: 1500, Scale: 400, KNew: 48, KStd: 24, BurnInGames: 20, RatingFloor: 500, MaxPerMatchDelta: 60, MaxGoals: 10, } type Team struct { Players []*repository.GameUser Score int AverageRating float32 } func NewTeam(players []*repository.GameUser, score int) *Team { var t1s float32 = 0 for _, player := range players { t1s += float32(player.User.Elo) } t1a := t1s / float32(len(players)) return &Team{Players: players, Score: score, AverageRating: t1a} } func GetNewElo(t1, t2 *Team) error { // Update the teams 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) teams := []*Team{t1, t2} for i, t := range teams { otherTeam := teams[1-i] for i, p := range t.Players { newRating, err := CalculateRating(p.User.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.User.GameCount) if err != nil { return err } t.Players[i].User.Elo = utils.RoundFloat(newRating, 1) } } return nil } func CalculateRating(rA, rB float32, goalsA, goalsB, gamesA int) (newrA float32, err error) { // Observed score (no draws) var sA float32 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 float32 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 } func expected(scale, rA, rB float32) float32 { return float32( 1.0 / (1.0 + math.Pow(10.0, float64(-(rA-rB)/scale))), ) } func movFactor(rA, rB float32, diff int) float32 { fd := float64(diff) return float32( math.Log(fd+1.0) * 2.2 / (math.Abs(float64(rA-rB))*0.001 + 2.2), ) }