Made the elo system work. Colorized the leaderboard. Better data structure. Improved the HTML. Custom form system. Added Sessions
This commit is contained in:
25
src/auth.go
25
src/auth.go
@@ -8,19 +8,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func getSessionUser(c *gin.Context) *User {
|
||||
cookie, err := c.Cookie("session")
|
||||
if err != nil || cookie == "" {
|
||||
return nil
|
||||
}
|
||||
var s Session
|
||||
if err := db.Preload("User").Where("token = ?", cookie).First(&s).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &s.User
|
||||
}
|
||||
|
||||
func requireAuth() gin.HandlerFunc {
|
||||
func RequireAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if u := getSessionUser(c); u != nil {
|
||||
c.Set("user", u)
|
||||
@@ -32,17 +20,6 @@ func requireAuth() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func setSessionCookie(c *gin.Context, token string) {
|
||||
// Very long-lived cookie for one year
|
||||
maxAge := 365 * 24 * 60 * 60
|
||||
httpOnly := true
|
||||
secure := strings.HasPrefix(baseURL, "https://")
|
||||
sameSite := http.SameSiteLaxMode
|
||||
c.SetCookie("session", token, maxAge, "/", cookieDomain, secure, httpOnly)
|
||||
// Workaround to set SameSite explicitly via header
|
||||
c.Header("Set-Cookie", (&http.Cookie{Name: "session", Value: token, Path: "/", Domain: cookieDomain, MaxAge: maxAge, Secure: secure, HttpOnly: httpOnly, SameSite: sameSite}).String())
|
||||
}
|
||||
|
||||
func findUserByHandle(handle string) (*User, error) {
|
||||
h := strings.TrimSpace(handle)
|
||||
if h == "" {
|
||||
|
||||
143
src/elo.go
Normal file
143
src/elo.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Package 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 main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
)
|
||||
|
||||
var eloCfg = Config{
|
||||
Mu0: 1500,
|
||||
Scale: 400,
|
||||
KNew: 48,
|
||||
KStd: 24,
|
||||
BurnInGames: 20,
|
||||
RatingFloor: 500,
|
||||
MaxPerMatchDelta: 60,
|
||||
MaxGoals: 10,
|
||||
}
|
||||
|
||||
type Config 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
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
Players []Player
|
||||
Score int
|
||||
AverageRating float64
|
||||
}
|
||||
|
||||
// Collect players by side
|
||||
type Player struct {
|
||||
u *User
|
||||
side string
|
||||
dElo float64
|
||||
}
|
||||
|
||||
func NewTeam(players []Player, score int) *Team {
|
||||
var t1s float64 = 0
|
||||
for _, player := range players {
|
||||
t1s += float64(player.u.Elo)
|
||||
|
||||
}
|
||||
t1a := t1s / float64(len(players))
|
||||
|
||||
return &Team{Players: players, Score: score, AverageRating: t1a}
|
||||
}
|
||||
|
||||
func GetNewElo(teams []*Team) error {
|
||||
// Update the teams
|
||||
t1 := teams[0]
|
||||
t2 := teams[1]
|
||||
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)
|
||||
for i, t := range teams {
|
||||
otherTeam := teams[1-i]
|
||||
for i, p := range t.Players {
|
||||
newRating, err := CalculateRating(p.u.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.u.GameCount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Players[i].u.Elo = 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)
|
||||
}
|
||||
38
src/main.go
38
src/main.go
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -50,13 +52,20 @@ func main() {
|
||||
}
|
||||
lg.Println("using SQLite qrank.db")
|
||||
|
||||
if err := db.AutoMigrate(&User{}, &Session{}, &LoginToken{}, &Table{}, &Game{}, &GamePlayer{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Session{}, &LoginToken{}, &Table{}, &Game{}, &GameUser{}); err != nil {
|
||||
lg.Fatal("migrate:", err)
|
||||
}
|
||||
if err := db.SetupJoinTable(&User{}, "Games", &GameUser{}); err != nil {
|
||||
lg.Fatal("setup jointable:", err)
|
||||
}
|
||||
|
||||
// Create engine
|
||||
r := gin.Default()
|
||||
|
||||
store := cookie.NewStore([]byte("secret"))
|
||||
r.Use(sessions.Sessions("mysession", store))
|
||||
r.Use(SessionHandlerMiddleware())
|
||||
|
||||
// Serve static files from the current directory
|
||||
r.Static("/assets", "./assets")
|
||||
|
||||
@@ -66,20 +75,25 @@ func main() {
|
||||
r.POST("/login", postLogin)
|
||||
r.GET("/magic", getMagic)
|
||||
|
||||
// Authenticated routes
|
||||
r.GET("/enter", requireAuth(), getEnter)
|
||||
r.POST("/enter", requireAuth(), postEnter)
|
||||
authorized := r.Group("/")
|
||||
authorized.Use(RequireAuthMiddleware())
|
||||
{
|
||||
|
||||
// QR-prepped table routes
|
||||
r.GET("/t/:tslug/enter", requireAuth(), getEnter)
|
||||
r.POST("/t/:tslug/enter", requireAuth(), postEnter)
|
||||
// Authenticated routes
|
||||
authorized.GET("/enter", getEnter)
|
||||
authorized.POST("/enter", postEnter)
|
||||
|
||||
r.GET("/history", requireAuth(), getHistory)
|
||||
r.GET("/leaderboard", requireAuth(), getLeaderboard)
|
||||
// QR-prepped table routes
|
||||
authorized.GET("/table/:tableSlug", getEnter)
|
||||
authorized.POST("/table/:tableSlug", postEnter)
|
||||
|
||||
r.GET("/u/:slug", requireAuth(), getUserView)
|
||||
r.GET("/me", requireAuth(), getMe)
|
||||
r.POST("/me", requireAuth(), postMe)
|
||||
authorized.GET("/history", getHistory)
|
||||
authorized.GET("/leaderboard", getLeaderboard)
|
||||
|
||||
authorized.GET("/user/:userSlug", getUserView)
|
||||
authorized.GET("/me", getMe)
|
||||
authorized.POST("/me", postMe)
|
||||
}
|
||||
|
||||
// Start application with port
|
||||
bind := ":" + port
|
||||
|
||||
@@ -2,65 +2,70 @@ package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// One knows that the user is active when there exists a session for the user
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
gorm.Model
|
||||
|
||||
Email string `gorm:"uniqueIndex;size:320"`
|
||||
Username string `gorm:"uniqueIndex;size:64"`
|
||||
Slug string `gorm:"uniqueIndex;size:80"`
|
||||
Slug string `gorm:"uniqueIndex;size:128"`
|
||||
|
||||
Elo float64 `gorm:"default:1500"` // Current Elo rating
|
||||
GameCount int `gorm:"default:0"`
|
||||
WinCount int `gorm:"default:0"`
|
||||
LossCount int `gorm:"default:0"`
|
||||
|
||||
Games []Game `gorm:"many2many:game_users;"`
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
gorm.Model
|
||||
TableID *uint `gorm:"index"`
|
||||
Table *Table
|
||||
|
||||
// Test if this is needed
|
||||
Users []User `gorm:"many2many:game_users;"`
|
||||
|
||||
ScoreA int
|
||||
ScoreB int
|
||||
}
|
||||
|
||||
// Join table between Game and User with extra fields
|
||||
type GameUser struct {
|
||||
gorm.Model
|
||||
GameID uint `gorm:"primaryKey"`
|
||||
UserID uint `gorm:"primaryKey"`
|
||||
|
||||
Side string `gorm:"size:1"`
|
||||
DeltaElo float64
|
||||
|
||||
// Eager loading
|
||||
Game Game
|
||||
User User
|
||||
}
|
||||
|
||||
// Currently no expiry
|
||||
type Session struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
Token string `gorm:"uniqueIndex;size:128"`
|
||||
UserID uint `gorm:"index"`
|
||||
User User
|
||||
gorm.Model
|
||||
UserID uint `gorm:"index"`
|
||||
User User
|
||||
|
||||
Token string `gorm:"uniqueIndex;size:128"`
|
||||
}
|
||||
|
||||
type LoginToken struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
gorm.Model
|
||||
Token string `gorm:"uniqueIndex;size:128"`
|
||||
Email string `gorm:"index;size:320"`
|
||||
ExpiresAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
type Table struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Name string
|
||||
Slug string `gorm:"uniqueIndex;size:80"`
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
TableID *uint `gorm:"index"`
|
||||
Table *Table
|
||||
ScoreA int
|
||||
ScoreB int
|
||||
WinnerIsA bool
|
||||
}
|
||||
|
||||
type GamePlayer struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
GameID uint `gorm:"index"`
|
||||
UserID uint `gorm:"index"`
|
||||
Side string `gorm:"size:1;index"` // "A" or "B"
|
||||
User User
|
||||
}
|
||||
|
||||
// Also house the common models that are not in the database here
|
||||
type stats struct {
|
||||
Games,
|
||||
Wins,
|
||||
Losses int
|
||||
gorm.Model
|
||||
Name string
|
||||
Slug string
|
||||
}
|
||||
|
||||
219
src/routes.go
219
src/routes.go
@@ -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) {
|
||||
|
||||
85
src/session.go
Normal file
85
src/session.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Middleware injects form data from session into context for templates
|
||||
func SessionHandlerMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
raw := session.Get("form_data")
|
||||
|
||||
form := map[string]string{}
|
||||
if raw != nil {
|
||||
if data, ok := raw.(string); ok {
|
||||
_ = json.Unmarshal([]byte(data), &form)
|
||||
}
|
||||
// clear after first use
|
||||
session.Delete("form_data")
|
||||
_ = session.Save()
|
||||
}
|
||||
|
||||
// make available in context
|
||||
c.Set("form", form)
|
||||
|
||||
// Set message in context when there is one available in the session
|
||||
message := session.Get("message")
|
||||
c.Set("mes", message)
|
||||
session.Delete("message")
|
||||
|
||||
_ = session.Save()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// SaveForm stores submitted POST form data into the session
|
||||
func SaveForm(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
form := map[string]string{}
|
||||
for key, values := range c.Request.PostForm {
|
||||
if len(values) > 0 {
|
||||
form[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
if b, err := json.Marshal(form); err == nil {
|
||||
session.Set("form_data", string(b))
|
||||
_ = session.Save()
|
||||
}
|
||||
}
|
||||
|
||||
// SaveForm stores submitted POST form data into the session
|
||||
func SetMessage(c *gin.Context, message string) {
|
||||
session := sessions.Default(c)
|
||||
session.Set("message", message)
|
||||
_ = session.Save()
|
||||
}
|
||||
|
||||
func setSessionCookie(c *gin.Context, token string) {
|
||||
// Very long-lived cookie for one year
|
||||
maxAge := 365 * 24 * 60 * 60
|
||||
httpOnly := true
|
||||
secure := strings.HasPrefix(baseURL, "https://")
|
||||
sameSite := http.SameSiteLaxMode
|
||||
c.SetCookie("session", token, maxAge, "/", cookieDomain, secure, httpOnly)
|
||||
// Workaround to set SameSite explicitly via header
|
||||
c.Header("Set-Cookie", (&http.Cookie{Name: "session", Value: token, Path: "/", Domain: cookieDomain, MaxAge: maxAge, Secure: secure, HttpOnly: httpOnly, SameSite: sameSite}).String())
|
||||
}
|
||||
|
||||
func getSessionUser(c *gin.Context) *User {
|
||||
cookie, err := c.Cookie("session")
|
||||
if err != nil || cookie == "" {
|
||||
return nil
|
||||
}
|
||||
var s Session
|
||||
if err := db.Preload("User").Where("token = ?", cookie).First(&s).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &s.User
|
||||
}
|
||||
@@ -82,7 +82,12 @@ func (tm *TemplateManager) LoadTemplates() {
|
||||
func (tm *TemplateManager) Render(c *gin.Context, name string, data gin.H) error {
|
||||
print("\nRendering template:", name, "\n")
|
||||
u := getSessionUser(c)
|
||||
|
||||
// Prefil the data for the render
|
||||
data["CurrentUser"] = u
|
||||
// Try to get information from current context that is filled from the session middleware
|
||||
data["Form"], _ = c.Get("form")
|
||||
data["Message"], _ = c.Get("mes")
|
||||
|
||||
tpl, ok := tm.templates[name]
|
||||
if !ok {
|
||||
@@ -91,7 +96,7 @@ func (tm *TemplateManager) Render(c *gin.Context, name string, data gin.H) error
|
||||
fmt.Print(tm.templates[name])
|
||||
|
||||
if err := tpl.ExecuteTemplate(c.Writer, tm.base, data); err != nil {
|
||||
log.Println("tpl error:", err)
|
||||
log.Println("template error:", err)
|
||||
c.Status(500)
|
||||
return err
|
||||
}
|
||||
|
||||
33
src/utils.go
33
src/utils.go
@@ -134,36 +134,3 @@ func stripAfterDot(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func getStatsFromUser(u *User) stats {
|
||||
var gps []GamePlayer
|
||||
var games, wins, losses int
|
||||
db.Where("user_id = ?", u.ID).Find(&gps)
|
||||
gameMap := map[uint]string{}
|
||||
for _, gp := range gps {
|
||||
gameMap[gp.GameID] = gp.Side
|
||||
}
|
||||
|
||||
if len(gameMap) > 0 {
|
||||
var gamesL []Game
|
||||
ids := make([]uint, 0, len(gameMap))
|
||||
for gid := range gameMap {
|
||||
ids = append(ids, gid)
|
||||
}
|
||||
db.Find(&gamesL, ids)
|
||||
for _, g := range gamesL {
|
||||
games++
|
||||
if g.ScoreA == g.ScoreB {
|
||||
continue
|
||||
}
|
||||
if g.ScoreA > g.ScoreB && gameMap[g.ID] == "A" {
|
||||
wins++
|
||||
} else if g.ScoreB > g.ScoreA && gameMap[g.ID] == "B" {
|
||||
wins++
|
||||
} else {
|
||||
losses++
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats{Games: games, Wins: wins, Losses: losses}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user