a geicko-2 based round robin ranking system designed to test c++ battleship submissions battleship.dunkirk.sh

feat: add sse and use round robin style tournaments

dunkirk.sh 0edeb889 3ec306e4

verified
+12 -22
database.go
···
player1_id INTEGER,
player2_id INTEGER,
winner_id INTEGER,
+
player1_wins INTEGER DEFAULT 0,
+
player2_wins INTEGER DEFAULT 0,
player1_moves INTEGER,
player2_moves INTEGER,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
···
query := `
SELECT
s.username,
-
COUNT(CASE WHEN bm.winner_id = s.id THEN 1 END) as wins,
-
COUNT(CASE WHEN (bm.player1_id = s.id OR bm.player2_id = s.id) AND bm.winner_id != s.id AND bm.winner_id IS NOT NULL THEN 1 END) as losses,
-
AVG(CASE WHEN bm.player1_id = s.id THEN bm.player1_moves ELSE bm.player2_moves END) as avg_moves,
-
MAX(bm.timestamp) as last_played
+
SUM(CASE WHEN m.player1_id = s.id THEN m.player1_wins WHEN m.player2_id = s.id THEN m.player2_wins ELSE 0 END) as total_wins,
+
SUM(CASE WHEN m.player1_id = s.id THEN m.player2_wins WHEN m.player2_id = s.id THEN m.player1_wins ELSE 0 END) as total_losses,
+
AVG(CASE WHEN m.player1_id = s.id THEN m.player1_moves ELSE m.player2_moves END) as avg_moves,
+
MAX(m.timestamp) as last_played
FROM submissions s
-
LEFT JOIN bracket_matches bm ON (bm.player1_id = s.id OR bm.player2_id = s.id) AND bm.status = 'completed'
+
LEFT JOIN matches m ON (m.player1_id = s.id OR m.player2_id = s.id)
WHERE s.is_active = 1
GROUP BY s.username
-
HAVING COUNT(bm.id) > 0
-
ORDER BY wins DESC, losses ASC, avg_moves ASC
+
HAVING COUNT(m.id) > 0
+
ORDER BY total_wins DESC, total_losses ASC, avg_moves ASC
LIMIT ?
`
···
// Parse the timestamp string
e.LastPlayed, _ = time.Parse("2006-01-02 15:04:05", lastPlayed)
-
// Determine stage based on average moves
-
// Based on random AI benchmark: avg=95.459, p25=94, p75=99
-
if e.AvgMoves >= 99 {
-
e.Stage = "Beginner"
-
} else if e.AvgMoves >= 95 {
-
e.Stage = "Intermediate"
-
} else if e.AvgMoves >= 85 {
-
e.Stage = "Advanced"
-
} else {
-
e.Stage = "Expert"
-
}
-
entries = append(entries, e)
}
···
return result.LastInsertId()
}
-
func addMatch(player1ID, player2ID, winnerID, player1Moves, player2Moves int) error {
+
func addMatch(player1ID, player2ID, winnerID, player1Wins, player2Wins, player1Moves, player2Moves int) error {
_, err := globalDB.Exec(
-
"INSERT INTO matches (player1_id, player2_id, winner_id, player1_moves, player2_moves) VALUES (?, ?, ?, ?, ?)",
-
player1ID, player2ID, winnerID, player1Moves, player2Moves,
+
"INSERT INTO matches (player1_id, player2_id, winner_id, player1_wins, player2_wins, player1_moves, player2_moves) VALUES (?, ?, ?, ?, ?, ?, ?)",
+
player1ID, player2ID, winnerID, player1Wins, player2Wins, player1Moves, player2Moves,
)
return err
}
+5 -1
go.mod
···
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
github.com/charmbracelet/wish v1.4.7
github.com/mattn/go-sqlite3 v1.14.32
+
github.com/pkg/sftp v1.13.10
+
github.com/r3labs/sse/v2 v2.10.0
)
require (
···
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
-
github.com/pkg/sftp v1.13.10 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+
github.com/tmaxmax/go-sse v0.11.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+
golang.org/x/net v0.42.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.28.0 // indirect
+
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
)
+20 -6
go.sum
···
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
···
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+
github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0=
+
github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+
github.com/tmaxmax/go-sse v0.11.0 h1:nogmJM6rJUoOLoAwEKeQe5XlVpt9l7N82SS1jI7lWFg=
+
github.com/tmaxmax/go-sse v0.11.0/go.mod h1:u/2kZQR1tyngo1lKaNCj1mJmhXGZWS1Zs5yiSOD+Eg8=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
-
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
-
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+
golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
-
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
-
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
-
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
+
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
+
gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+37 -2
main.go
···
host = "0.0.0.0"
sshPort = "2222"
webPort = "8080"
+
ssePort = "8081"
uploadDir = "./submissions"
resultsDB = "./results.db"
)
···
if err := initStorage(); err != nil {
log.Fatal(err)
}
+
+
// Initialize SSE server
+
initSSE()
+
+
// Start SSE server on separate port
+
go startSSEServer()
// Start background worker
workerCtx, workerCancel := context.WithCancel(context.Background())
···
mux := http.NewServeMux()
mux.HandleFunc("/", handleLeaderboard)
mux.HandleFunc("/api/leaderboard", handleAPILeaderboard)
-
mux.HandleFunc("/api/bracket", handleBracketData)
// Serve static files
fs := http.FileServer(http.Dir("./static"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
+
server := &http.Server{
+
Addr: ":" + webPort,
+
Handler: mux,
+
ReadTimeout: 0, // No timeout for SSE
+
WriteTimeout: 0, // No timeout for SSE
+
MaxHeaderBytes: 1 << 20,
+
}
+
log.Printf("Web server starting on :%s", webPort)
-
if err := http.ListenAndServe(":"+webPort, mux); err != nil {
+
if err := server.ListenAndServe(); err != nil {
+
log.Fatal(err)
+
}
+
}
+
+
func startSSEServer() {
+
// Wrap SSE server with CORS middleware
+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.Header().Set("Access-Control-Allow-Origin", "*")
+
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
+
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+
+
if r.Method == "OPTIONS" {
+
w.WriteHeader(http.StatusOK)
+
return
+
}
+
+
sseServer.ServeHTTP(w, r)
+
})
+
+
log.Printf("SSE server starting on :%s", ssePort)
+
if err := http.ListenAndServe(":"+ssePort, handler); err != nil {
log.Fatal(err)
}
}
+4 -18
model.go
···
b.WriteString(lipgloss.NewStyle().Bold(true).Render("🏆 Leaderboard") + "\n\n")
// Header without styling on the whole line
-
b.WriteString(fmt.Sprintf("%-4s %-20s %-12s %8s %8s %10s %10s\n",
-
"Rank", "User", "Stage", "Wins", "Losses", "Win Rate", "Avg Moves"))
+
b.WriteString(fmt.Sprintf("%-4s %-20s %8s %8s %10s %10s\n",
+
"Rank", "User", "Wins", "Losses", "Win Rate", "Avg Moves"))
for i, entry := range entries {
winRate := 0.0
···
coloredRank = rank
}
-
// Color stage badge
-
var stageColor string
-
switch entry.Stage {
-
case "Expert":
-
stageColor = "green"
-
case "Advanced":
-
stageColor = "blue"
-
case "Intermediate":
-
stageColor = "yellow"
-
default:
-
stageColor = "240"
-
}
-
coloredStage := lipgloss.NewStyle().Foreground(lipgloss.Color(stageColor)).Render(entry.Stage)
-
// Format line with proper spacing
-
b.WriteString(fmt.Sprintf("%-4s %-20s %-12s %8d %8d %9.2f%% %9.1f\n",
-
coloredRank, entry.Username, coloredStage, entry.Wins, entry.Losses, winRate, entry.AvgMoves))
+
b.WriteString(fmt.Sprintf("%-4s %-20s %8d %8d %9.2f%% %9.1f\n",
+
coloredRank, entry.Username, entry.Wins, entry.Losses, winRate, entry.AvgMoves))
}
return b.String()
+26 -98
runner.go
···
log.Printf("Submission %d compiled successfully: %s by %s", sub.ID, sub.Filename, sub.Username)
updateSubmissionStatus(sub.ID, "completed")
-
// Tournament will be created/updated by processBracketMatches
-
log.Printf("Submission %d ready for tournament", sub.ID)
+
// Run round-robin matches
+
log.Printf("Starting round-robin matches for submission %d", sub.ID)
+
runRoundRobinMatches(sub)
}
return nil
···
return nil
}
-
func processBracketMatches() error {
-
// Ensure tournament exists
-
tournament, err := ensureTournamentExists()
-
if err != nil {
-
return fmt.Errorf("failed to ensure tournament: %v", err)
-
}
-
-
if tournament.Status != "active" {
-
log.Println("Tournament is complete, skipping match processing")
-
return nil
-
}
-
-
// Get pending matches for current tournament
-
matches, err := getPendingBracketMatches(tournament.ID)
-
if err != nil {
-
return fmt.Errorf("failed to get pending matches: %v", err)
-
}
-
-
if len(matches) == 0 {
-
log.Println("No pending bracket matches")
-
return nil
-
}
-
-
// Process each match
-
for _, match := range matches {
-
log.Printf("Running bracket match: %s vs %s (Round %d, Position %d)",
-
match.Player1Name, match.Player2Name, match.Round, match.Position)
-
-
// Get submission details
-
player1, err := getSubmissionByID(match.Player1ID)
-
if err != nil {
-
log.Printf("Failed to get player1 submission: %v", err)
-
continue
-
}
-
-
player2, err := getSubmissionByID(match.Player2ID)
-
if err != nil {
-
log.Printf("Failed to get player2 submission: %v", err)
-
continue
-
}
-
-
// Run head-to-head match (1000 games)
-
player1Wins, player2Wins, totalMoves := runHeadToHead(player1, player2, 1000)
-
-
avgMovesP1 := totalMoves / 2000 // Each player plays ~500 games
-
avgMovesP2 := avgMovesP1
-
-
// Determine winner
-
var winnerID int
-
if player1Wins > player2Wins {
-
winnerID = match.Player1ID
-
log.Printf("Match result: %s wins (%d-%d)", match.Player1Name, player1Wins, player2Wins)
-
} else if player2Wins > player1Wins {
-
winnerID = match.Player2ID
-
log.Printf("Match result: %s wins (%d-%d)", match.Player2Name, player2Wins, player1Wins)
-
} else {
-
// Tie - better average moves wins
-
if avgMovesP1 < avgMovesP2 {
-
winnerID = match.Player1ID
-
} else {
-
winnerID = match.Player2ID
-
}
-
log.Printf("Match result: Tie %d-%d, winner by avg moves: ID %d", player1Wins, player2Wins, winnerID)
-
}
-
-
// Update match result
-
err = updateBracketMatchResult(match.ID, winnerID, player1Wins, player2Wins, avgMovesP1, avgMovesP2)
-
if err != nil {
-
log.Printf("Failed to update bracket match: %v", err)
-
continue
-
}
-
}
-
-
// Check if current round is complete
-
complete, err := isRoundComplete(tournament.ID, tournament.CurrentRound)
-
if err != nil {
-
return fmt.Errorf("failed to check round completion: %v", err)
-
}
-
-
if complete {
-
log.Printf("Round %d complete, advancing winners", tournament.CurrentRound)
-
err = advanceWinners(tournament.ID, tournament.CurrentRound)
-
if err != nil {
-
return fmt.Errorf("failed to advance winners: %v", err)
-
}
-
}
-
-
return nil
-
}
+
func getSubmissionByID(id int) (Submission, error) {
var sub Submission
···
return sub, err
}
-
// Deprecated: replaced by bracket tournament
-
func runTournamentMatches(newSub Submission) {
+
func runRoundRobinMatches(newSub Submission) {
// Get all active submissions
activeSubmissions, err := getActiveSubmissions()
if err != nil {
···
return
}
+
totalMatches := len(activeSubmissions) - 1 // Exclude self
+
if totalMatches <= 0 {
+
log.Printf("No opponents for %s, skipping matches", newSub.Username)
+
return
+
}
+
+
log.Printf("Starting round-robin for %s against %d opponents", newSub.Username, totalMatches)
+
matchNum := 0
+
// Run matches against all other submissions
for _, opponent := range activeSubmissions {
if opponent.ID == newSub.ID {
continue
}
-
log.Printf("Running match: %s vs %s (1000 games)", newSub.Username, opponent.Username)
+
matchNum++
+
log.Printf("[%d/%d] Running match: %s vs %s (1000 games)", matchNum, totalMatches, newSub.Username, opponent.Username)
// Run match (1000 games total)
player1Wins, player2Wins, totalMoves := runHeadToHead(newSub, opponent, 1000)
···
if player1Wins > player2Wins {
winnerID = newSub.ID
-
log.Printf("Match result: %s wins (%d-%d, avg %d moves)", newSub.Username, player1Wins, player2Wins, avgMoves)
+
log.Printf("[%d/%d] Match result: %s wins (%d-%d, avg %d moves)", matchNum, totalMatches, newSub.Username, player1Wins, player2Wins, avgMoves)
} else if player2Wins > player1Wins {
winnerID = opponent.ID
-
log.Printf("Match result: %s wins (%d-%d, avg %d moves)", opponent.Username, player2Wins, player1Wins, avgMoves)
+
log.Printf("[%d/%d] Match result: %s wins (%d-%d, avg %d moves)", matchNum, totalMatches, opponent.Username, player2Wins, player1Wins, avgMoves)
} else {
// Tie - coin flip
if totalMoves%2 == 0 {
···
} else {
winnerID = opponent.ID
}
-
log.Printf("Match result: Tie %d-%d, winner by coin flip: %d", player1Wins, player2Wins, winnerID)
+
log.Printf("[%d/%d] Match result: Tie %d-%d, winner by coin flip: %d", matchNum, totalMatches, player1Wins, player2Wins, winnerID)
}
// Store match result
-
if err := addMatch(newSub.ID, opponent.ID, winnerID, avgMoves, avgMoves); err != nil {
+
if err := addMatch(newSub.ID, opponent.ID, winnerID, player1Wins, player2Wins, avgMoves, avgMoves); err != nil {
log.Printf("Failed to store match result: %v", err)
+
} else {
+
// Notify SSE clients of update after each match
+
log.Printf("Broadcasting leaderboard update after match %d/%d", matchNum, totalMatches)
+
NotifyLeaderboardUpdate()
}
}
+
+
log.Printf("Round-robin complete for %s (%d matches)", newSub.Username, totalMatches)
}
func runHeadToHead(player1, player2 Submission, numGames int) (int, int, int) {
+48
sse.go
···
+
package main
+
+
import (
+
"encoding/json"
+
"log"
+
"net/http"
+
+
"github.com/tmaxmax/go-sse"
+
)
+
+
var sseServer *sse.Server
+
+
func initSSE() {
+
sseServer = &sse.Server{}
+
log.Printf("SSE server initialized (tmaxmax/go-sse)")
+
}
+
+
func handleSSE(w http.ResponseWriter, r *http.Request) {
+
log.Printf("SSE client connected from %s", r.RemoteAddr)
+
sseServer.ServeHTTP(w, r)
+
}
+
+
// NotifyLeaderboardUpdate sends updated leaderboard to all connected clients
+
func NotifyLeaderboardUpdate() {
+
entries, err := getLeaderboard(50)
+
if err != nil {
+
log.Printf("Failed to get leaderboard for SSE: %v", err)
+
return
+
}
+
+
data, err := json.Marshal(entries)
+
if err != nil {
+
log.Printf("Failed to marshal leaderboard for SSE: %v", err)
+
return
+
}
+
+
msg := &sse.Message{}
+
msg.AppendData(string(data))
+
+
// Publish to default topic
+
log.Printf("Publishing to SSE clients (%d bytes)", len(data))
+
if err := sseServer.Publish(msg); err != nil {
+
log.Printf("Failed to publish SSE message: %v", err)
+
return
+
}
+
+
log.Printf("Broadcast leaderboard update to SSE clients (%d bytes)", len(data))
+
}
+84 -200
web.go
···
<title>Battleship Arena - Leaderboard</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<link rel="stylesheet" href="/static/brackets-viewer.min.css" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
···
font-size: 0.9em;
margin-top: 20px;
}
-
.bracket-section {
-
margin: 40px 0;
-
background: white;
-
padding: 20px;
-
border-radius: 12px;
+
.live-indicator {
+
display: inline-block;
+
width: 10px;
+
height: 10px;
+
background: #10b981;
+
border-radius: 50%;
+
animation: pulse 2s infinite;
+
margin-right: 8px;
}
-
.bracket-section h2 {
+
@keyframes pulse {
+
0%, 100% { opacity: 1; }
+
50% { opacity: 0.5; }
+
}
+
.status-bar {
text-align: center;
-
color: #333;
-
margin-bottom: 30px;
+
color: #10b981;
+
margin-bottom: 20px;
+
font-size: 0.9em;
}
</style>
-
<script type="text/javascript" src="/static/brackets-viewer.min.js"></script>
<script>
-
// Auto-refresh every 30 seconds
-
setTimeout(() => location.reload(), 30000);
+
// Server-Sent Events for live updates
+
let eventSource;
-
// Load and render bracket data
-
window.addEventListener('DOMContentLoaded', async () => {
-
try {
-
const response = await fetch('/api/bracket');
-
const data = await response.json();
-
-
if (data.matches && data.matches.length > 0) {
-
window.bracketsViewer.render({
-
stages: data.stages,
-
matches: data.matches,
-
matchGames: data.matchGames,
-
participants: data.participants,
-
});
+
function connectSSE() {
+
console.log('Connecting to SSE...');
+
eventSource = new EventSource('http://localhost:8081');
+
+
eventSource.onopen = function() {
+
console.log('SSE connection established');
+
};
+
+
eventSource.onmessage = function(event) {
+
console.log('SSE message received:', event.data.substring(0, 100) + '...');
+
try {
+
const entries = JSON.parse(event.data);
+
console.log('Updating leaderboard with', entries.length, 'entries');
+
updateLeaderboard(entries);
+
} catch (error) {
+
console.error('Failed to parse SSE data:', error);
}
-
} catch (error) {
-
console.error('Failed to load bracket data:', error);
+
};
+
+
eventSource.onerror = function(error) {
+
console.error('SSE error, reconnecting...', error);
+
eventSource.close();
+
setTimeout(connectSSE, 5000);
+
};
+
}
+
+
function updateLeaderboard(entries) {
+
const tbody = document.querySelector('tbody');
+
if (!tbody) return;
+
+
if (entries.length === 0) {
+
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; padding: 40px; color: #999;">No submissions yet. Be the first to compete!</td></tr>';
+
return;
}
+
+
tbody.innerHTML = entries.map((e, i) => {
+
const rank = i + 1;
+
const total = e.Wins + e.Losses;
+
const winRate = total === 0 ? 0 : ((e.Wins / total) * 100).toFixed(1);
+
const winRateClass = winRate >= 80 ? 'win-rate-high' : winRate >= 50 ? 'win-rate-med' : 'win-rate-low';
+
const medals = ['🥇', '🥈', '🥉'];
+
const medal = medals[i] || '#' + rank;
+
const lastPlayed = new Date(e.LastPlayed).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+
+
return '<tr>' +
+
'<td class="rank rank-' + rank + '">' + medal + '</td>' +
+
'<td><strong>' + e.Username + '</strong></td>' +
+
'<td>' + e.Wins + '</td>' +
+
'<td>' + e.Losses + '</td>' +
+
'<td class="win-rate ' + winRateClass + '">' + winRate + '%</td>' +
+
'<td>' + e.AvgMoves.toFixed(1) + '</td>' +
+
'<td>' + lastPlayed + '</td>' +
+
'</tr>';
+
}).join('');
+
+
// Update stats
+
const statValues = document.querySelectorAll('.stat-value');
+
statValues[0].textContent = entries.length;
+
const totalGames = entries.reduce((sum, e) => sum + e.Wins + e.Losses, 0) / 2;
+
statValues[1].textContent = Math.floor(totalGames);
+
}
+
+
window.addEventListener('DOMContentLoaded', () => {
+
connectSSE();
});
</script>
</head>
···
<h1>🚢 Battleship Arena</h1>
<p class="subtitle">Smart AI Competition</p>
-
<div class="bracket-section">
-
<h2>⚔️ Tournament Bracket</h2>
-
<div class="brackets-viewer"></div>
+
<div class="status-bar">
+
<span class="live-indicator"></span>Live Updates Active
</div>
-
<h2 style="text-align: center; color: #333; margin-top: 60px;">📊 Rankings</h2>
+
<h2 style="text-align: center; color: #333;">📊 Rankings</h2>
<table>
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
-
<th>Stage</th>
<th>Wins</th>
<th>Losses</th>
<th>Win Rate</th>
···
<tr>
<td class="rank rank-{{add $i 1}}">{{if lt $i 3}}{{medal $i}}{{else}}#{{add $i 1}}{{end}}</td>
<td><strong>{{$e.Username}}</strong></td>
-
<td><span class="stage stage-{{$e.Stage}}">{{$e.Stage}}</span></td>
<td>{{$e.Wins}}</td>
<td>{{$e.Losses}}</td>
<td class="win-rate {{winRateClass $e}}">{{winRate $e}}%</td>
···
<p style="margin-top: 10px;">Then navigate to upload your <code>memory_functions_*.cpp</code> file.</p>
</div>
-
<p class="refresh-note">Page auto-refreshes every 30 seconds</p>
+
<p class="refresh-note">Updates in real-time via Server-Sent Events</p>
</div>
</body>
</html>
···
json.NewEncoder(w).Encode(entries)
}
-
func handleBracketData(w http.ResponseWriter, r *http.Request) {
-
// Get latest tournament (active or completed)
-
tournament, err := getLatestTournament()
-
if err != nil {
-
http.Error(w, fmt.Sprintf("Failed to load tournament: %v", err), http.StatusInternalServerError)
-
return
-
}
-
-
if tournament == nil {
-
// No tournament yet
-
w.Header().Set("Content-Type", "application/json")
-
json.NewEncoder(w).Encode(map[string]interface{}{
-
"stages": []map[string]interface{}{},
-
"matches": []map[string]interface{}{},
-
"participants": []map[string]interface{}{},
-
})
-
return
-
}
-
-
// Get all bracket matches
-
matches, err := getAllBracketMatches(tournament.ID)
-
if err != nil {
-
http.Error(w, fmt.Sprintf("Failed to load matches: %v", err), http.StatusInternalServerError)
-
return
-
}
-
if matches == nil {
-
matches = []BracketMatch{}
-
}
-
-
// Get unique participants (skip byes where ID = 0)
-
participantMap := make(map[int]int) // submissionID -> participantID
-
participants := []map[string]interface{}{}
-
participantID := 1
-
-
for _, match := range matches {
-
if match.Player1ID > 0 && match.Player1Name != "" {
-
if _, exists := participantMap[match.Player1ID]; !exists {
-
participantMap[match.Player1ID] = participantID
-
participants = append(participants, map[string]interface{}{
-
"id": participantID,
-
"name": match.Player1Name,
-
})
-
participantID++
-
}
-
}
-
if match.Player2ID > 0 && match.Player2Name != "" {
-
if _, exists := participantMap[match.Player2ID]; !exists {
-
participantMap[match.Player2ID] = participantID
-
participants = append(participants, map[string]interface{}{
-
"id": participantID,
-
"name": match.Player2Name,
-
})
-
participantID++
-
}
-
}
-
}
-
-
// Group matches by round for bracket format
-
roundMatches := make(map[int][]BracketMatch)
-
maxRound := 0
-
for _, match := range matches {
-
roundMatches[match.Round] = append(roundMatches[match.Round], match)
-
if match.Round > maxRound {
-
maxRound = match.Round
-
}
-
}
-
-
// Create match data in brackets-viewer format (single elimination)
-
bracketMatches := []map[string]interface{}{}
-
matchNumber := 1
-
-
for round := 1; round <= maxRound; round++ {
-
for _, match := range roundMatches[round] {
-
var opponent1, opponent2 map[string]interface{}
-
-
// Player 1
-
if match.Player1ID > 0 {
-
result := "loss"
-
if match.WinnerID == match.Player1ID {
-
result = "win"
-
}
-
opponent1 = map[string]interface{}{
-
"id": participantMap[match.Player1ID],
-
"result": result,
-
"score": match.Player1Wins,
-
}
-
} else {
-
opponent1 = nil // Bye
-
}
-
-
// Player 2
-
if match.Player2ID > 0 {
-
result := "loss"
-
if match.WinnerID == match.Player2ID {
-
result = "win"
-
}
-
opponent2 = map[string]interface{}{
-
"id": participantMap[match.Player2ID],
-
"result": result,
-
"score": match.Player2Wins,
-
}
-
} else {
-
opponent2 = nil // Bye
-
}
-
-
status := "pending"
-
if match.Status == "completed" {
-
status = "completed"
-
}
-
-
bracketMatches = append(bracketMatches, map[string]interface{}{
-
"id": matchNumber,
-
"stage_id": 1,
-
"group_id": 1,
-
"round_id": round,
-
"number": match.Position + 1,
-
"opponent1": opponent1,
-
"opponent2": opponent2,
-
"status": status,
-
})
-
matchNumber++
-
}
-
}
-
-
// Create stage data for single elimination
-
// Calculate bracket size (next power of 2)
-
bracketSize := 1
-
for bracketSize < len(participants) {
-
bracketSize *= 2
-
}
-
-
stages := []map[string]interface{}{
-
{
-
"id": 1,
-
"name": "Tournament",
-
"type": "single_elimination",
-
"number": 1,
-
"settings": map[string]interface{}{
-
"size": bracketSize,
-
"seedOrdering": []string{"natural"},
-
"grandFinal": "none",
-
"skipFirstRound": false,
-
},
-
},
-
}
-
-
// Create groups array (required for brackets-viewer)
-
groups := []map[string]interface{}{
-
{
-
"id": 1,
-
"stage_id": 1,
-
"number": 1,
-
},
-
}
-
-
data := map[string]interface{}{
-
"stages": stages,
-
"groups": groups,
-
"matches": bracketMatches,
-
"matchGames": []map[string]interface{}{},
-
"participants": participants,
-
}
-
-
w.Header().Set("Content-Type", "application/json")
-
json.NewEncoder(w).Encode(data)
-
}
func calculateTotalGames(entries []LeaderboardEntry) int {
total := 0
-6
worker.go
···
if err := processSubmissions(); err != nil {
log.Printf("Worker error (submissions): %v", err)
}
-
if err := processBracketMatches(); err != nil {
-
log.Printf("Worker error (bracket): %v", err)
-
}
for {
select {
···
case <-ticker.C:
if err := processSubmissions(); err != nil {
log.Printf("Worker error (submissions): %v", err)
-
}
-
if err := processBracketMatches(); err != nil {
-
log.Printf("Worker error (bracket): %v", err)
}
}
}