Made the elo system work. Colorized the leaderboard. Better data structure. Improved the HTML. Custom form system. Added Sessions

This commit is contained in:
Jonas Hahn
2025-08-25 14:56:59 +02:00
parent 4b4377a24e
commit f5e5d5632d
20 changed files with 492 additions and 289 deletions

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"log"
"math"
"net/http"
"strings"
"time"
@@ -13,7 +14,6 @@ import (
)
func getEnter(c *gin.Context) {
// Simple render for now
tm.Render(c, "enter", gin.H{})
}
@@ -34,8 +34,9 @@ func postEnter(c *gin.Context) {
// Require a winner
if scoreA == scoreB {
tm.Render(c, "enter", gin.H{"Error": "Score A score must be different from score B",
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
SaveForm(c)
SetMessage(c, "There must be a winner")
c.Redirect(http.StatusSeeOther, "/enter")
return
}
@@ -46,8 +47,9 @@ func postEnter(c *gin.Context) {
}
u, err := findUserByHandle(handle)
if err != nil {
tm.Render(c, "enter", gin.H{"Error": fmt.Sprintf("User %q not found", handle),
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
SaveForm(c)
SetMessage(c, fmt.Sprintf(`Player "%v" does not exist`, handle))
c.Redirect(http.StatusSeeOther, "/enter")
return nil, err
}
return u, nil
@@ -72,8 +74,9 @@ func postEnter(c *gin.Context) {
// Check for at least one player on each side
if b1 == nil && b2 == nil || a1 == nil && a2 == nil {
tm.Render(c, "enter", gin.H{"Error": "At least one player required on each side",
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
SaveForm(c)
SetMessage(c, "There must be at least one player on each side")
c.Redirect(http.StatusSeeOther, "/enter")
return
}
@@ -83,52 +86,78 @@ func postEnter(c *gin.Context) {
for i, u := range users {
if u != nil {
if seen[u.ID] {
tm.Render(c, "enter", gin.H{"Error": fmt.Sprintf("User %q specified multiple times", users[i].Username),
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
SaveForm(c)
SetMessage(c, fmt.Sprintf(`Player "%v" cannot play twice in one game`, users[i].Username))
c.Redirect(http.StatusSeeOther, "/enter")
return
}
seen[u.ID] = true
}
}
// Look up table if provided
var tableID *uint
if slug := c.Param("tslug"); slug != "" {
var t Table
if err := db.Where("slug = ?", slug).First(&t).Error; err == nil {
tableID = &t.ID
}
}
// Create game
g := Game{
ScoreA: scoreA,
ScoreB: scoreB,
TableID: tableID,
WinnerIsA: scoreA > scoreB,
ScoreA: scoreA,
ScoreB: scoreB,
}
if err := db.Create(&g).Error; err != nil {
c.String(http.StatusInternalServerError, "game create error")
return
}
// Collect players by side
players := []struct {
u *User
side string
}{
{a1, "A"},
{a2, "A"},
{b1, "B"},
{b2, "B"},
var players []Player
var playerbuf []float64
for i, user := range users {
if user != nil {
var side string
if i > 1 {
side = "B"
} else {
side = "A"
}
players = append(players, Player{u: user, side: side, dElo: 0})
playerbuf = append(playerbuf, user.Elo)
}
}
for _, p := range players {
if p.u != nil {
if err := db.Create(&GamePlayer{GameID: g.ID, UserID: p.u.ID, Side: p.side}).Error; err != nil {
c.String(http.StatusInternalServerError, "failed to assign player")
return
}
team1 := NewTeam(players[:len(players)/2], g.ScoreA)
team2 := NewTeam(players[len(players)/2:], g.ScoreB)
// Set new elo for all players
GetNewElo([]*Team{team1, team2})
var winningSide string
if g.ScoreA > g.ScoreB {
winningSide = "A"
} else {
winningSide = "B"
}
for i, p := range players {
db.Save(p.u)
p.dElo = players[i].u.Elo - playerbuf[i]
var updateString string
if p.side == winningSide {
updateString = "win_count"
} else {
updateString = "loss_count"
}
var expr = gorm.Expr(updateString+" + ?", 1)
// Update win or loss
err = db.Model(&User{}).
Where("id = ?", p.u.ID).
UpdateColumn(updateString, expr).UpdateColumn("game_count", gorm.Expr("game_count + ?", 1)).Error
if err != nil {
fmt.Println(err)
}
if err := db.Create(&GameUser{GameID: g.ID, UserID: p.u.ID, Side: p.side, DeltaElo: p.dElo}).Error; err != nil {
c.String(http.StatusInternalServerError, "failed to assign player")
return
}
}
@@ -143,122 +172,78 @@ func getIndex(c *gin.Context) {
getLogin(c)
}
func roundFloat(val float64, precision uint) float64 {
ratio := math.Pow(10, float64(precision))
return math.Round(val*ratio) / ratio
}
func getHistory(c *gin.Context) {
// Load recent games with players
// Load recent games
var games []Game
db.Order("created_at desc").Limit(100).Preload("Table").Find(&games)
db.Order("created_at desc").Find(&games)
type UserElo struct {
User
DeltaElo float64
}
type GRow struct {
Game
PlayersA []User
PlayersB []User
PlayersA []UserElo
PlayersB []UserElo
WinnerIsA bool
}
var rows []GRow
for _, g := range games {
var gps []GamePlayer
db.Preload("User").Where("game_id = ?", g.ID).Find(&gps)
var a, b []User
var gps []GameUser
db.Model(&GameUser{}).Preload("User").Where("game_id = ?", g.ID).Find(&gps)
var a, b []UserElo
for _, gp := range gps {
if gp.Side == "A" {
a = append(a, gp.User)
a = append(a, UserElo{gp.User, roundFloat(gp.DeltaElo, 2)})
} else {
b = append(b, gp.User)
b = append(b, UserElo{gp.User, roundFloat(gp.DeltaElo, 2)})
}
}
rows = append(rows, GRow{Game: g, PlayersA: a, PlayersB: b})
rows = append(rows, GRow{Game: g, PlayersA: a, PlayersB: b, WinnerIsA: g.ScoreA > g.ScoreB})
log.Printf("%+v", rows[0].PlayersA[0].Username)
}
tm.Render(c, "history", gin.H{"Games": rows})
}
func getLeaderboard(c *gin.Context) {
// Simple metric: wins = games where player's side has higher score
type Row struct {
Username, Slug string
Wins, Games int
var users []User
if err := db.Order("elo DESC").Find(&users).Error; err != nil {
c.String(http.StatusInternalServerError, "Error: %v", err)
return
}
// Load last 1000 games for performance (simple MVP)
var games []Game
db.Order("created_at desc").Limit(1000).Find(&games)
// Build map of gameID->winnerSide
winner := map[uint]string{}
for _, g := range games {
side := ""
if g.ScoreA > g.ScoreB {
side = "A"
} else if g.ScoreB > g.ScoreA {
side = "B"
}
winner[g.ID] = side
}
// Count per user
type Cnt struct{ Wins, Games int }
counts := map[uint]*Cnt{}
users := map[uint]User{}
for _, g := range games {
var gps []GamePlayer
db.Preload("User").Where("game_id = ?", g.ID).Find(&gps)
for _, gp := range gps {
if counts[gp.UserID] == nil {
counts[gp.UserID] = &Cnt{}
users[gp.UserID] = gp.User
}
counts[gp.UserID].Games++
if winner[g.ID] != "" && winner[g.ID] == gp.Side {
counts[gp.UserID].Wins++
}
}
}
rows := []Row{}
for uid, cnt := range counts {
u := users[uid]
rows = append(rows, Row{Username: u.Username, Slug: u.Slug, Wins: cnt.Wins, Games: cnt.Games})
}
// Sort by wins desc, then games desc, then username
// Simple insertion sort to avoid extra imports
for i := 1; i < len(rows); i++ {
j := i
for j > 0 && (rows[j-1].Wins < rows[j].Wins || (rows[j-1].Wins == rows[j].Wins && (rows[j-1].Games < rows[j].Games || (rows[j-1].Games == rows[j].Games && rows[j-1].Username > rows[j].Username)))) {
rows[j-1], rows[j] = rows[j], rows[j-1]
j--
}
}
if len(rows) > 100 {
rows = rows[:100]
}
tm.Render(c, "leaderboard", gin.H{"Rows": rows})
tm.Render(c, "leaderboard", gin.H{
"Users": users,
})
}
func getUserView(c *gin.Context) {
slug := c.Param("slug")
slug := c.Param("userSlug")
u, err := userBySlug(slug)
if err != nil {
c.String(404, "user not found")
return
}
own := false
if cu := getSessionUser(c); cu != nil && cu.ID == u.ID {
// Check if own user
var own bool
if slug == u.Slug {
own = true
}
tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": own})
tm.Render(c, "user", gin.H{"User": u, "Own": own})
}
func getMe(c *gin.Context) {
u := getSessionUser(c)
if u == nil {
c.Redirect(302, "/login")
return
}
tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": true})
tm.Render(c, "user", gin.H{"User": u, "Own": true})
}
func postMe(c *gin.Context) {