139 lines
3.2 KiB
Go
139 lines
3.2 KiB
Go
// 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),
|
||
)
|
||
}
|