Full refactor of codebase

This commit is contained in:
Jonas Hahn
2025-09-18 16:51:57 +02:00
parent 0e6e48cd7b
commit 4168e92601
34 changed files with 1176 additions and 1062 deletions

136
src/services/elo.go Normal file
View File

@@ -0,0 +1,136 @@
// 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 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
}{
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 float64
}
func NewTeam(players []*repository.GameUser, score int) *Team {
var t1s float64 = 0
for _, player := range players {
t1s += float64(player.User.Elo)
}
t1a := t1s / float64(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 (BradleyTerry 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 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)
}