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

Configure Feed

Select the types of activity you want to include in your feed.

feat: add sse and use round robin style tournaments

+236 -353
+12 -22
database.go
··· 101 101 player1_id INTEGER, 102 102 player2_id INTEGER, 103 103 winner_id INTEGER, 104 + player1_wins INTEGER DEFAULT 0, 105 + player2_wins INTEGER DEFAULT 0, 104 106 player1_moves INTEGER, 105 107 player2_moves INTEGER, 106 108 timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, ··· 127 129 query := ` 128 130 SELECT 129 131 s.username, 130 - COUNT(CASE WHEN bm.winner_id = s.id THEN 1 END) as wins, 131 - 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, 132 - AVG(CASE WHEN bm.player1_id = s.id THEN bm.player1_moves ELSE bm.player2_moves END) as avg_moves, 133 - MAX(bm.timestamp) as last_played 132 + 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, 133 + 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, 134 + AVG(CASE WHEN m.player1_id = s.id THEN m.player1_moves ELSE m.player2_moves END) as avg_moves, 135 + MAX(m.timestamp) as last_played 134 136 FROM submissions s 135 - LEFT JOIN bracket_matches bm ON (bm.player1_id = s.id OR bm.player2_id = s.id) AND bm.status = 'completed' 137 + LEFT JOIN matches m ON (m.player1_id = s.id OR m.player2_id = s.id) 136 138 WHERE s.is_active = 1 137 139 GROUP BY s.username 138 - HAVING COUNT(bm.id) > 0 139 - ORDER BY wins DESC, losses ASC, avg_moves ASC 140 + HAVING COUNT(m.id) > 0 141 + ORDER BY total_wins DESC, total_losses ASC, avg_moves ASC 140 142 LIMIT ? 141 143 ` 142 144 ··· 158 160 // Parse the timestamp string 159 161 e.LastPlayed, _ = time.Parse("2006-01-02 15:04:05", lastPlayed) 160 162 161 - // Determine stage based on average moves 162 - // Based on random AI benchmark: avg=95.459, p25=94, p75=99 163 - if e.AvgMoves >= 99 { 164 - e.Stage = "Beginner" 165 - } else if e.AvgMoves >= 95 { 166 - e.Stage = "Intermediate" 167 - } else if e.AvgMoves >= 85 { 168 - e.Stage = "Advanced" 169 - } else { 170 - e.Stage = "Expert" 171 - } 172 - 173 163 entries = append(entries, e) 174 164 } 175 165 ··· 197 187 return result.LastInsertId() 198 188 } 199 189 200 - func addMatch(player1ID, player2ID, winnerID, player1Moves, player2Moves int) error { 190 + func addMatch(player1ID, player2ID, winnerID, player1Wins, player2Wins, player1Moves, player2Moves int) error { 201 191 _, err := globalDB.Exec( 202 - "INSERT INTO matches (player1_id, player2_id, winner_id, player1_moves, player2_moves) VALUES (?, ?, ?, ?, ?)", 203 - player1ID, player2ID, winnerID, player1Moves, player2Moves, 192 + "INSERT INTO matches (player1_id, player2_id, winner_id, player1_wins, player2_wins, player1_moves, player2_moves) VALUES (?, ?, ?, ?, ?, ?, ?)", 193 + player1ID, player2ID, winnerID, player1Wins, player2Wins, player1Moves, player2Moves, 204 194 ) 205 195 return err 206 196 }
+5 -1
go.mod
··· 8 8 github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 9 9 github.com/charmbracelet/wish v1.4.7 10 10 github.com/mattn/go-sqlite3 v1.14.32 11 + github.com/pkg/sftp v1.13.10 12 + github.com/r3labs/sse/v2 v2.10.0 11 13 ) 12 14 13 15 require ( ··· 35 37 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 36 38 github.com/muesli/cancelreader v0.2.2 // indirect 37 39 github.com/muesli/termenv v0.16.0 // indirect 38 - github.com/pkg/sftp v1.13.10 // indirect 39 40 github.com/rivo/uniseg v0.4.7 // indirect 41 + github.com/tmaxmax/go-sse v0.11.0 // indirect 40 42 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 41 43 golang.org/x/crypto v0.41.0 // indirect 42 44 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect 45 + golang.org/x/net v0.42.0 // indirect 43 46 golang.org/x/sys v0.36.0 // indirect 44 47 golang.org/x/text v0.28.0 // indirect 48 + gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect 45 49 )
+20 -6
go.sum
··· 34 34 github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= 35 35 github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= 36 36 github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 37 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 37 38 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 38 39 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 39 40 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= ··· 66 67 github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= 67 68 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 68 69 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 70 + github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0= 71 + github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= 69 72 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 70 73 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 71 74 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 75 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 76 + github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 72 77 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 73 78 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 79 + github.com/tmaxmax/go-sse v0.11.0 h1:nogmJM6rJUoOLoAwEKeQe5XlVpt9l7N82SS1jI7lWFg= 80 + github.com/tmaxmax/go-sse v0.11.0/go.mod h1:u/2kZQR1tyngo1lKaNCj1mJmhXGZWS1Zs5yiSOD+Eg8= 74 81 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 75 82 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 76 - golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 77 - golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 83 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 78 84 golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 79 85 golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 80 86 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 81 87 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 88 + golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 89 + golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 90 + golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 91 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 82 92 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 83 93 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 84 94 golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 85 95 golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 86 - golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 87 - golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 88 - golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 89 - golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 96 + golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= 97 + golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 98 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 99 + golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 90 100 golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 101 + gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= 102 + gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= 103 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 104 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 91 105 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 92 106 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+37 -2
main.go
··· 23 23 host = "0.0.0.0" 24 24 sshPort = "2222" 25 25 webPort = "8080" 26 + ssePort = "8081" 26 27 uploadDir = "./submissions" 27 28 resultsDB = "./results.db" 28 29 ) ··· 32 33 if err := initStorage(); err != nil { 33 34 log.Fatal(err) 34 35 } 36 + 37 + // Initialize SSE server 38 + initSSE() 39 + 40 + // Start SSE server on separate port 41 + go startSSEServer() 35 42 36 43 // Start background worker 37 44 workerCtx, workerCancel := context.WithCancel(context.Background()) ··· 112 119 mux := http.NewServeMux() 113 120 mux.HandleFunc("/", handleLeaderboard) 114 121 mux.HandleFunc("/api/leaderboard", handleAPILeaderboard) 115 - mux.HandleFunc("/api/bracket", handleBracketData) 116 122 117 123 // Serve static files 118 124 fs := http.FileServer(http.Dir("./static")) 119 125 mux.Handle("/static/", http.StripPrefix("/static/", fs)) 120 126 127 + server := &http.Server{ 128 + Addr: ":" + webPort, 129 + Handler: mux, 130 + ReadTimeout: 0, // No timeout for SSE 131 + WriteTimeout: 0, // No timeout for SSE 132 + MaxHeaderBytes: 1 << 20, 133 + } 134 + 121 135 log.Printf("Web server starting on :%s", webPort) 122 - if err := http.ListenAndServe(":"+webPort, mux); err != nil { 136 + if err := server.ListenAndServe(); err != nil { 137 + log.Fatal(err) 138 + } 139 + } 140 + 141 + func startSSEServer() { 142 + // Wrap SSE server with CORS middleware 143 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 144 + w.Header().Set("Access-Control-Allow-Origin", "*") 145 + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") 146 + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") 147 + 148 + if r.Method == "OPTIONS" { 149 + w.WriteHeader(http.StatusOK) 150 + return 151 + } 152 + 153 + sseServer.ServeHTTP(w, r) 154 + }) 155 + 156 + log.Printf("SSE server starting on :%s", ssePort) 157 + if err := http.ListenAndServe(":"+ssePort, handler); err != nil { 123 158 log.Fatal(err) 124 159 } 125 160 }
+4 -18
model.go
··· 189 189 b.WriteString(lipgloss.NewStyle().Bold(true).Render("🏆 Leaderboard") + "\n\n") 190 190 191 191 // Header without styling on the whole line 192 - b.WriteString(fmt.Sprintf("%-4s %-20s %-12s %8s %8s %10s %10s\n", 193 - "Rank", "User", "Stage", "Wins", "Losses", "Win Rate", "Avg Moves")) 192 + b.WriteString(fmt.Sprintf("%-4s %-20s %8s %8s %10s %10s\n", 193 + "Rank", "User", "Wins", "Losses", "Win Rate", "Avg Moves")) 194 194 195 195 for i, entry := range entries { 196 196 winRate := 0.0 ··· 213 213 coloredRank = rank 214 214 } 215 215 216 - // Color stage badge 217 - var stageColor string 218 - switch entry.Stage { 219 - case "Expert": 220 - stageColor = "green" 221 - case "Advanced": 222 - stageColor = "blue" 223 - case "Intermediate": 224 - stageColor = "yellow" 225 - default: 226 - stageColor = "240" 227 - } 228 - coloredStage := lipgloss.NewStyle().Foreground(lipgloss.Color(stageColor)).Render(entry.Stage) 229 - 230 216 // Format line with proper spacing 231 - b.WriteString(fmt.Sprintf("%-4s %-20s %-12s %8d %8d %9.2f%% %9.1f\n", 232 - coloredRank, entry.Username, coloredStage, entry.Wins, entry.Losses, winRate, entry.AvgMoves)) 217 + b.WriteString(fmt.Sprintf("%-4s %-20s %8d %8d %9.2f%% %9.1f\n", 218 + coloredRank, entry.Username, entry.Wins, entry.Losses, winRate, entry.AvgMoves)) 233 219 } 234 220 235 221 return b.String()
+26 -98
runner.go
··· 31 31 log.Printf("Submission %d compiled successfully: %s by %s", sub.ID, sub.Filename, sub.Username) 32 32 updateSubmissionStatus(sub.ID, "completed") 33 33 34 - // Tournament will be created/updated by processBracketMatches 35 - log.Printf("Submission %d ready for tournament", sub.ID) 34 + // Run round-robin matches 35 + log.Printf("Starting round-robin matches for submission %d", sub.ID) 36 + runRoundRobinMatches(sub) 36 37 } 37 38 38 39 return nil ··· 128 129 return nil 129 130 } 130 131 131 - func processBracketMatches() error { 132 - // Ensure tournament exists 133 - tournament, err := ensureTournamentExists() 134 - if err != nil { 135 - return fmt.Errorf("failed to ensure tournament: %v", err) 136 - } 137 - 138 - if tournament.Status != "active" { 139 - log.Println("Tournament is complete, skipping match processing") 140 - return nil 141 - } 142 - 143 - // Get pending matches for current tournament 144 - matches, err := getPendingBracketMatches(tournament.ID) 145 - if err != nil { 146 - return fmt.Errorf("failed to get pending matches: %v", err) 147 - } 148 - 149 - if len(matches) == 0 { 150 - log.Println("No pending bracket matches") 151 - return nil 152 - } 153 - 154 - // Process each match 155 - for _, match := range matches { 156 - log.Printf("Running bracket match: %s vs %s (Round %d, Position %d)", 157 - match.Player1Name, match.Player2Name, match.Round, match.Position) 158 - 159 - // Get submission details 160 - player1, err := getSubmissionByID(match.Player1ID) 161 - if err != nil { 162 - log.Printf("Failed to get player1 submission: %v", err) 163 - continue 164 - } 165 - 166 - player2, err := getSubmissionByID(match.Player2ID) 167 - if err != nil { 168 - log.Printf("Failed to get player2 submission: %v", err) 169 - continue 170 - } 171 - 172 - // Run head-to-head match (1000 games) 173 - player1Wins, player2Wins, totalMoves := runHeadToHead(player1, player2, 1000) 174 - 175 - avgMovesP1 := totalMoves / 2000 // Each player plays ~500 games 176 - avgMovesP2 := avgMovesP1 177 - 178 - // Determine winner 179 - var winnerID int 180 - if player1Wins > player2Wins { 181 - winnerID = match.Player1ID 182 - log.Printf("Match result: %s wins (%d-%d)", match.Player1Name, player1Wins, player2Wins) 183 - } else if player2Wins > player1Wins { 184 - winnerID = match.Player2ID 185 - log.Printf("Match result: %s wins (%d-%d)", match.Player2Name, player2Wins, player1Wins) 186 - } else { 187 - // Tie - better average moves wins 188 - if avgMovesP1 < avgMovesP2 { 189 - winnerID = match.Player1ID 190 - } else { 191 - winnerID = match.Player2ID 192 - } 193 - log.Printf("Match result: Tie %d-%d, winner by avg moves: ID %d", player1Wins, player2Wins, winnerID) 194 - } 195 - 196 - // Update match result 197 - err = updateBracketMatchResult(match.ID, winnerID, player1Wins, player2Wins, avgMovesP1, avgMovesP2) 198 - if err != nil { 199 - log.Printf("Failed to update bracket match: %v", err) 200 - continue 201 - } 202 - } 203 - 204 - // Check if current round is complete 205 - complete, err := isRoundComplete(tournament.ID, tournament.CurrentRound) 206 - if err != nil { 207 - return fmt.Errorf("failed to check round completion: %v", err) 208 - } 209 - 210 - if complete { 211 - log.Printf("Round %d complete, advancing winners", tournament.CurrentRound) 212 - err = advanceWinners(tournament.ID, tournament.CurrentRound) 213 - if err != nil { 214 - return fmt.Errorf("failed to advance winners: %v", err) 215 - } 216 - } 217 - 218 - return nil 219 - } 132 + 220 133 221 134 func getSubmissionByID(id int) (Submission, error) { 222 135 var sub Submission ··· 227 140 return sub, err 228 141 } 229 142 230 - // Deprecated: replaced by bracket tournament 231 - func runTournamentMatches(newSub Submission) { 143 + func runRoundRobinMatches(newSub Submission) { 232 144 // Get all active submissions 233 145 activeSubmissions, err := getActiveSubmissions() 234 146 if err != nil { ··· 236 148 return 237 149 } 238 150 151 + totalMatches := len(activeSubmissions) - 1 // Exclude self 152 + if totalMatches <= 0 { 153 + log.Printf("No opponents for %s, skipping matches", newSub.Username) 154 + return 155 + } 156 + 157 + log.Printf("Starting round-robin for %s against %d opponents", newSub.Username, totalMatches) 158 + matchNum := 0 159 + 239 160 // Run matches against all other submissions 240 161 for _, opponent := range activeSubmissions { 241 162 if opponent.ID == newSub.ID { 242 163 continue 243 164 } 244 165 245 - log.Printf("Running match: %s vs %s (1000 games)", newSub.Username, opponent.Username) 166 + matchNum++ 167 + log.Printf("[%d/%d] Running match: %s vs %s (1000 games)", matchNum, totalMatches, newSub.Username, opponent.Username) 246 168 247 169 // Run match (1000 games total) 248 170 player1Wins, player2Wins, totalMoves := runHeadToHead(newSub, opponent, 1000) ··· 253 175 254 176 if player1Wins > player2Wins { 255 177 winnerID = newSub.ID 256 - log.Printf("Match result: %s wins (%d-%d, avg %d moves)", newSub.Username, player1Wins, player2Wins, avgMoves) 178 + log.Printf("[%d/%d] Match result: %s wins (%d-%d, avg %d moves)", matchNum, totalMatches, newSub.Username, player1Wins, player2Wins, avgMoves) 257 179 } else if player2Wins > player1Wins { 258 180 winnerID = opponent.ID 259 - log.Printf("Match result: %s wins (%d-%d, avg %d moves)", opponent.Username, player2Wins, player1Wins, avgMoves) 181 + log.Printf("[%d/%d] Match result: %s wins (%d-%d, avg %d moves)", matchNum, totalMatches, opponent.Username, player2Wins, player1Wins, avgMoves) 260 182 } else { 261 183 // Tie - coin flip 262 184 if totalMoves%2 == 0 { ··· 264 186 } else { 265 187 winnerID = opponent.ID 266 188 } 267 - log.Printf("Match result: Tie %d-%d, winner by coin flip: %d", player1Wins, player2Wins, winnerID) 189 + log.Printf("[%d/%d] Match result: Tie %d-%d, winner by coin flip: %d", matchNum, totalMatches, player1Wins, player2Wins, winnerID) 268 190 } 269 191 270 192 // Store match result 271 - if err := addMatch(newSub.ID, opponent.ID, winnerID, avgMoves, avgMoves); err != nil { 193 + if err := addMatch(newSub.ID, opponent.ID, winnerID, player1Wins, player2Wins, avgMoves, avgMoves); err != nil { 272 194 log.Printf("Failed to store match result: %v", err) 195 + } else { 196 + // Notify SSE clients of update after each match 197 + log.Printf("Broadcasting leaderboard update after match %d/%d", matchNum, totalMatches) 198 + NotifyLeaderboardUpdate() 273 199 } 274 200 } 201 + 202 + log.Printf("Round-robin complete for %s (%d matches)", newSub.Username, totalMatches) 275 203 } 276 204 277 205 func runHeadToHead(player1, player2 Submission, numGames int) (int, int, int) {
+48
sse.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + 8 + "github.com/tmaxmax/go-sse" 9 + ) 10 + 11 + var sseServer *sse.Server 12 + 13 + func initSSE() { 14 + sseServer = &sse.Server{} 15 + log.Printf("SSE server initialized (tmaxmax/go-sse)") 16 + } 17 + 18 + func handleSSE(w http.ResponseWriter, r *http.Request) { 19 + log.Printf("SSE client connected from %s", r.RemoteAddr) 20 + sseServer.ServeHTTP(w, r) 21 + } 22 + 23 + // NotifyLeaderboardUpdate sends updated leaderboard to all connected clients 24 + func NotifyLeaderboardUpdate() { 25 + entries, err := getLeaderboard(50) 26 + if err != nil { 27 + log.Printf("Failed to get leaderboard for SSE: %v", err) 28 + return 29 + } 30 + 31 + data, err := json.Marshal(entries) 32 + if err != nil { 33 + log.Printf("Failed to marshal leaderboard for SSE: %v", err) 34 + return 35 + } 36 + 37 + msg := &sse.Message{} 38 + msg.AppendData(string(data)) 39 + 40 + // Publish to default topic 41 + log.Printf("Publishing to SSE clients (%d bytes)", len(data)) 42 + if err := sseServer.Publish(msg); err != nil { 43 + log.Printf("Failed to publish SSE message: %v", err) 44 + return 45 + } 46 + 47 + log.Printf("Broadcast leaderboard update to SSE clients (%d bytes)", len(data)) 48 + }
+84 -200
web.go
··· 14 14 <title>Battleship Arena - Leaderboard</title> 15 15 <meta charset="UTF-8"> 16 16 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 17 - <link rel="stylesheet" href="/static/brackets-viewer.min.css" /> 18 17 <style> 19 18 body { 20 19 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; ··· 138 137 font-size: 0.9em; 139 138 margin-top: 20px; 140 139 } 141 - .bracket-section { 142 - margin: 40px 0; 143 - background: white; 144 - padding: 20px; 145 - border-radius: 12px; 140 + .live-indicator { 141 + display: inline-block; 142 + width: 10px; 143 + height: 10px; 144 + background: #10b981; 145 + border-radius: 50%; 146 + animation: pulse 2s infinite; 147 + margin-right: 8px; 146 148 } 147 - .bracket-section h2 { 149 + @keyframes pulse { 150 + 0%, 100% { opacity: 1; } 151 + 50% { opacity: 0.5; } 152 + } 153 + .status-bar { 148 154 text-align: center; 149 - color: #333; 150 - margin-bottom: 30px; 155 + color: #10b981; 156 + margin-bottom: 20px; 157 + font-size: 0.9em; 151 158 } 152 159 </style> 153 - <script type="text/javascript" src="/static/brackets-viewer.min.js"></script> 154 160 <script> 155 - // Auto-refresh every 30 seconds 156 - setTimeout(() => location.reload(), 30000); 161 + // Server-Sent Events for live updates 162 + let eventSource; 157 163 158 - // Load and render bracket data 159 - window.addEventListener('DOMContentLoaded', async () => { 160 - try { 161 - const response = await fetch('/api/bracket'); 162 - const data = await response.json(); 163 - 164 - if (data.matches && data.matches.length > 0) { 165 - window.bracketsViewer.render({ 166 - stages: data.stages, 167 - matches: data.matches, 168 - matchGames: data.matchGames, 169 - participants: data.participants, 170 - }); 164 + function connectSSE() { 165 + console.log('Connecting to SSE...'); 166 + eventSource = new EventSource('http://localhost:8081'); 167 + 168 + eventSource.onopen = function() { 169 + console.log('SSE connection established'); 170 + }; 171 + 172 + eventSource.onmessage = function(event) { 173 + console.log('SSE message received:', event.data.substring(0, 100) + '...'); 174 + try { 175 + const entries = JSON.parse(event.data); 176 + console.log('Updating leaderboard with', entries.length, 'entries'); 177 + updateLeaderboard(entries); 178 + } catch (error) { 179 + console.error('Failed to parse SSE data:', error); 171 180 } 172 - } catch (error) { 173 - console.error('Failed to load bracket data:', error); 181 + }; 182 + 183 + eventSource.onerror = function(error) { 184 + console.error('SSE error, reconnecting...', error); 185 + eventSource.close(); 186 + setTimeout(connectSSE, 5000); 187 + }; 188 + } 189 + 190 + function updateLeaderboard(entries) { 191 + const tbody = document.querySelector('tbody'); 192 + if (!tbody) return; 193 + 194 + if (entries.length === 0) { 195 + tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; padding: 40px; color: #999;">No submissions yet. Be the first to compete!</td></tr>'; 196 + return; 174 197 } 198 + 199 + tbody.innerHTML = entries.map((e, i) => { 200 + const rank = i + 1; 201 + const total = e.Wins + e.Losses; 202 + const winRate = total === 0 ? 0 : ((e.Wins / total) * 100).toFixed(1); 203 + const winRateClass = winRate >= 80 ? 'win-rate-high' : winRate >= 50 ? 'win-rate-med' : 'win-rate-low'; 204 + const medals = ['🥇', '🥈', '🥉']; 205 + const medal = medals[i] || '#' + rank; 206 + const lastPlayed = new Date(e.LastPlayed).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); 207 + 208 + return '<tr>' + 209 + '<td class="rank rank-' + rank + '">' + medal + '</td>' + 210 + '<td><strong>' + e.Username + '</strong></td>' + 211 + '<td>' + e.Wins + '</td>' + 212 + '<td>' + e.Losses + '</td>' + 213 + '<td class="win-rate ' + winRateClass + '">' + winRate + '%</td>' + 214 + '<td>' + e.AvgMoves.toFixed(1) + '</td>' + 215 + '<td>' + lastPlayed + '</td>' + 216 + '</tr>'; 217 + }).join(''); 218 + 219 + // Update stats 220 + const statValues = document.querySelectorAll('.stat-value'); 221 + statValues[0].textContent = entries.length; 222 + const totalGames = entries.reduce((sum, e) => sum + e.Wins + e.Losses, 0) / 2; 223 + statValues[1].textContent = Math.floor(totalGames); 224 + } 225 + 226 + window.addEventListener('DOMContentLoaded', () => { 227 + connectSSE(); 175 228 }); 176 229 </script> 177 230 </head> ··· 180 233 <h1>🚢 Battleship Arena</h1> 181 234 <p class="subtitle">Smart AI Competition</p> 182 235 183 - <div class="bracket-section"> 184 - <h2>⚔️ Tournament Bracket</h2> 185 - <div class="brackets-viewer"></div> 236 + <div class="status-bar"> 237 + <span class="live-indicator"></span>Live Updates Active 186 238 </div> 187 239 188 - <h2 style="text-align: center; color: #333; margin-top: 60px;">📊 Rankings</h2> 240 + <h2 style="text-align: center; color: #333;">📊 Rankings</h2> 189 241 <table> 190 242 <thead> 191 243 <tr> 192 244 <th>Rank</th> 193 245 <th>Player</th> 194 - <th>Stage</th> 195 246 <th>Wins</th> 196 247 <th>Losses</th> 197 248 <th>Win Rate</th> ··· 205 256 <tr> 206 257 <td class="rank rank-{{add $i 1}}">{{if lt $i 3}}{{medal $i}}{{else}}#{{add $i 1}}{{end}}</td> 207 258 <td><strong>{{$e.Username}}</strong></td> 208 - <td><span class="stage stage-{{$e.Stage}}">{{$e.Stage}}</span></td> 209 259 <td>{{$e.Wins}}</td> 210 260 <td>{{$e.Losses}}</td> 211 261 <td class="win-rate {{winRateClass $e}}">{{winRate $e}}%</td> ··· 241 291 <p style="margin-top: 10px;">Then navigate to upload your <code>memory_functions_*.cpp</code> file.</p> 242 292 </div> 243 293 244 - <p class="refresh-note">Page auto-refreshes every 30 seconds</p> 294 + <p class="refresh-note">Updates in real-time via Server-Sent Events</p> 245 295 </div> 246 296 </body> 247 297 </html> ··· 336 386 json.NewEncoder(w).Encode(entries) 337 387 } 338 388 339 - func handleBracketData(w http.ResponseWriter, r *http.Request) { 340 - // Get latest tournament (active or completed) 341 - tournament, err := getLatestTournament() 342 - if err != nil { 343 - http.Error(w, fmt.Sprintf("Failed to load tournament: %v", err), http.StatusInternalServerError) 344 - return 345 - } 346 - 347 - if tournament == nil { 348 - // No tournament yet 349 - w.Header().Set("Content-Type", "application/json") 350 - json.NewEncoder(w).Encode(map[string]interface{}{ 351 - "stages": []map[string]interface{}{}, 352 - "matches": []map[string]interface{}{}, 353 - "participants": []map[string]interface{}{}, 354 - }) 355 - return 356 - } 357 - 358 - // Get all bracket matches 359 - matches, err := getAllBracketMatches(tournament.ID) 360 - if err != nil { 361 - http.Error(w, fmt.Sprintf("Failed to load matches: %v", err), http.StatusInternalServerError) 362 - return 363 - } 364 389 365 - if matches == nil { 366 - matches = []BracketMatch{} 367 - } 368 - 369 - // Get unique participants (skip byes where ID = 0) 370 - participantMap := make(map[int]int) // submissionID -> participantID 371 - participants := []map[string]interface{}{} 372 - participantID := 1 373 - 374 - for _, match := range matches { 375 - if match.Player1ID > 0 && match.Player1Name != "" { 376 - if _, exists := participantMap[match.Player1ID]; !exists { 377 - participantMap[match.Player1ID] = participantID 378 - participants = append(participants, map[string]interface{}{ 379 - "id": participantID, 380 - "name": match.Player1Name, 381 - }) 382 - participantID++ 383 - } 384 - } 385 - if match.Player2ID > 0 && match.Player2Name != "" { 386 - if _, exists := participantMap[match.Player2ID]; !exists { 387 - participantMap[match.Player2ID] = participantID 388 - participants = append(participants, map[string]interface{}{ 389 - "id": participantID, 390 - "name": match.Player2Name, 391 - }) 392 - participantID++ 393 - } 394 - } 395 - } 396 - 397 - // Group matches by round for bracket format 398 - roundMatches := make(map[int][]BracketMatch) 399 - maxRound := 0 400 - for _, match := range matches { 401 - roundMatches[match.Round] = append(roundMatches[match.Round], match) 402 - if match.Round > maxRound { 403 - maxRound = match.Round 404 - } 405 - } 406 - 407 - // Create match data in brackets-viewer format (single elimination) 408 - bracketMatches := []map[string]interface{}{} 409 - matchNumber := 1 410 - 411 - for round := 1; round <= maxRound; round++ { 412 - for _, match := range roundMatches[round] { 413 - var opponent1, opponent2 map[string]interface{} 414 - 415 - // Player 1 416 - if match.Player1ID > 0 { 417 - result := "loss" 418 - if match.WinnerID == match.Player1ID { 419 - result = "win" 420 - } 421 - opponent1 = map[string]interface{}{ 422 - "id": participantMap[match.Player1ID], 423 - "result": result, 424 - "score": match.Player1Wins, 425 - } 426 - } else { 427 - opponent1 = nil // Bye 428 - } 429 - 430 - // Player 2 431 - if match.Player2ID > 0 { 432 - result := "loss" 433 - if match.WinnerID == match.Player2ID { 434 - result = "win" 435 - } 436 - opponent2 = map[string]interface{}{ 437 - "id": participantMap[match.Player2ID], 438 - "result": result, 439 - "score": match.Player2Wins, 440 - } 441 - } else { 442 - opponent2 = nil // Bye 443 - } 444 - 445 - status := "pending" 446 - if match.Status == "completed" { 447 - status = "completed" 448 - } 449 - 450 - bracketMatches = append(bracketMatches, map[string]interface{}{ 451 - "id": matchNumber, 452 - "stage_id": 1, 453 - "group_id": 1, 454 - "round_id": round, 455 - "number": match.Position + 1, 456 - "opponent1": opponent1, 457 - "opponent2": opponent2, 458 - "status": status, 459 - }) 460 - matchNumber++ 461 - } 462 - } 463 - 464 - // Create stage data for single elimination 465 - // Calculate bracket size (next power of 2) 466 - bracketSize := 1 467 - for bracketSize < len(participants) { 468 - bracketSize *= 2 469 - } 470 - 471 - stages := []map[string]interface{}{ 472 - { 473 - "id": 1, 474 - "name": "Tournament", 475 - "type": "single_elimination", 476 - "number": 1, 477 - "settings": map[string]interface{}{ 478 - "size": bracketSize, 479 - "seedOrdering": []string{"natural"}, 480 - "grandFinal": "none", 481 - "skipFirstRound": false, 482 - }, 483 - }, 484 - } 485 - 486 - // Create groups array (required for brackets-viewer) 487 - groups := []map[string]interface{}{ 488 - { 489 - "id": 1, 490 - "stage_id": 1, 491 - "number": 1, 492 - }, 493 - } 494 - 495 - data := map[string]interface{}{ 496 - "stages": stages, 497 - "groups": groups, 498 - "matches": bracketMatches, 499 - "matchGames": []map[string]interface{}{}, 500 - "participants": participants, 501 - } 502 - 503 - w.Header().Set("Content-Type", "application/json") 504 - json.NewEncoder(w).Encode(data) 505 - } 506 390 507 391 func calculateTotalGames(entries []LeaderboardEntry) int { 508 392 total := 0
-6
worker.go
··· 15 15 if err := processSubmissions(); err != nil { 16 16 log.Printf("Worker error (submissions): %v", err) 17 17 } 18 - if err := processBracketMatches(); err != nil { 19 - log.Printf("Worker error (bracket): %v", err) 20 - } 21 18 22 19 for { 23 20 select { ··· 26 23 case <-ticker.C: 27 24 if err := processSubmissions(); err != nil { 28 25 log.Printf("Worker error (submissions): %v", err) 29 - } 30 - if err := processBracketMatches(); err != nil { 31 - log.Printf("Worker error (bracket): %v", err) 32 26 } 33 27 } 34 28 }