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)
}

48
src/services/mail.go Normal file
View File

@@ -0,0 +1,48 @@
package services
import (
"bytes"
"fmt"
"html/template"
"log"
"os"
"github.com/ascyii/qrank/src/config"
"github.com/ascyii/qrank/src/utils"
"github.com/gin-gonic/gin"
"gopkg.in/gomail.v2"
)
func SendEmail(email string, token string) error {
// Parse the email body template
tmpl, err := template.ParseFiles("other/email.txt")
if err != nil {
panic(err)
}
// Execute template with provided data
var body bytes.Buffer
if err := tmpl.Execute(&body, gin.H{"Token": token, "Name": utils.NameFromEmail(email)}); err != nil {
panic(err)
}
if config.Config.Debug {
fmt.Println(body.String())
return nil
}
// Compose email
m := gomail.NewMessage()
m.SetHeader("From", os.Getenv("SMTP_MAIL"))
m.SetHeader("To", email)
m.SetHeader("Subject", "Registration/Login to QRank")
m.SetBody("text/plain", body.String())
// Dial and send
d := gomail.NewDialer(os.Getenv("SMTP_HOST"), 587, os.Getenv("SMTP_MAIL"), os.Getenv("SMTP_PASS"))
if err := d.DialAndSend(m); err != nil {
log.Fatalln(err)
return err
}
return nil
}