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: use elo rating system

+104 -36
+73 -4
database.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "math" 5 6 "time" 6 7 7 8 _ "github.com/mattn/go-sqlite3" ··· 14 15 Wins int 15 16 Losses int 16 17 WinPct float64 18 + Elo int 17 19 AvgMoves float64 18 20 Stage string 19 21 LastPlayed time.Time ··· 65 67 filename TEXT NOT NULL, 66 68 upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 67 69 status TEXT DEFAULT 'pending', 68 - is_active BOOLEAN DEFAULT 1 70 + is_active BOOLEAN DEFAULT 1, 71 + elo_rating INTEGER DEFAULT 1500 69 72 ); 70 73 71 74 CREATE TABLE IF NOT EXISTS tournaments ( ··· 133 136 query := ` 134 137 SELECT 135 138 s.username, 139 + s.elo_rating, 136 140 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, 137 141 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, 138 142 AVG(CASE WHEN m.player1_id = s.id THEN m.player1_moves ELSE m.player2_moves END) as avg_moves, ··· 140 144 FROM submissions s 141 145 LEFT JOIN matches m ON (m.player1_id = s.id OR m.player2_id = s.id) AND m.is_valid = 1 142 146 WHERE s.is_active = 1 143 - GROUP BY s.username 147 + GROUP BY s.username, s.elo_rating 144 148 HAVING COUNT(m.id) > 0 145 - ORDER BY (CAST(total_wins AS REAL) / (total_wins + total_losses)) DESC, avg_moves ASC 149 + ORDER BY s.elo_rating DESC, total_wins DESC 146 150 LIMIT ? 147 151 ` 148 152 ··· 156 160 for rows.Next() { 157 161 var e LeaderboardEntry 158 162 var lastPlayed string 159 - err := rows.Scan(&e.Username, &e.Wins, &e.Losses, &e.AvgMoves, &lastPlayed) 163 + err := rows.Scan(&e.Username, &e.Elo, &e.Wins, &e.Losses, &e.AvgMoves, &lastPlayed) 160 164 if err != nil { 161 165 return nil, err 162 166 } ··· 286 290 } 287 291 288 292 return submissions, rows.Err() 293 + } 294 + 295 + func calculateEloChange(player1Rating, player2Rating, player1TotalGames, player2TotalGames int, player1Score float64) (int, int) { 296 + // K-factor: higher for fewer games (more volatile), lower for experienced players 297 + kPlayer1 := 32 298 + kPlayer2 := 32 299 + 300 + if player1TotalGames > 500 { 301 + kPlayer1 = 16 302 + } 303 + if player2TotalGames > 500 { 304 + kPlayer2 = 16 305 + } 306 + 307 + // Expected scores 308 + expectedPlayer1 := 1.0 / (1.0 + math.Pow(10, float64(player2Rating-player1Rating)/400.0)) 309 + expectedPlayer2 := 1.0 / (1.0 + math.Pow(10, float64(player1Rating-player2Rating)/400.0)) 310 + 311 + // Actual scores (player1Score is win percentage, player2Score is 1-player1Score) 312 + player2Score := 1.0 - player1Score 313 + 314 + // Rating changes based on difference between actual and expected 315 + player1Change := int(float64(kPlayer1) * (player1Score - expectedPlayer1)) 316 + player2Change := int(float64(kPlayer2) * (player2Score - expectedPlayer2)) 317 + 318 + return player1Change, player2Change 319 + } 320 + 321 + func updateEloRatings(player1ID, player2ID, player1Wins, player2Wins int) error { 322 + // Get current ratings and match counts 323 + var player1Rating, player2Rating, player1Games, player2Games int 324 + 325 + err := globalDB.QueryRow(` 326 + SELECT s.elo_rating, 327 + (SELECT COUNT(*) FROM matches m WHERE (m.player1_id = s.id OR m.player2_id = s.id) AND m.is_valid = 1) 328 + FROM submissions s WHERE s.id = ? 329 + `, player1ID).Scan(&player1Rating, &player1Games) 330 + if err != nil { 331 + return err 332 + } 333 + 334 + err = globalDB.QueryRow(` 335 + SELECT s.elo_rating, 336 + (SELECT COUNT(*) FROM matches m WHERE (m.player1_id = s.id OR m.player2_id = s.id) AND m.is_valid = 1) 337 + FROM submissions s WHERE s.id = ? 338 + `, player2ID).Scan(&player2Rating, &player2Games) 339 + if err != nil { 340 + return err 341 + } 342 + 343 + // Calculate player1's actual score (win percentage) 344 + totalGames := player1Wins + player2Wins 345 + player1Score := float64(player1Wins) / float64(totalGames) 346 + 347 + // Calculate rating changes based on actual performance 348 + player1Change, player2Change := calculateEloChange(player1Rating, player2Rating, player1Games, player2Games, player1Score) 349 + 350 + // Update ratings 351 + _, err = globalDB.Exec("UPDATE submissions SET elo_rating = ? WHERE id = ?", player1Rating+player1Change, player1ID) 352 + if err != nil { 353 + return err 354 + } 355 + 356 + _, err = globalDB.Exec("UPDATE submissions SET elo_rating = ? WHERE id = ?", player2Rating+player2Change, player2ID) 357 + return err 289 358 } 290 359 291 360 func hasMatchBetween(player1ID, player2ID int) (bool, error) {
+4 -4
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 %8s %8s %10s %10s\n", 193 - "Rank", "User", "Wins", "Losses", "Win Rate", "Avg Moves")) 192 + b.WriteString(fmt.Sprintf("%-4s %-20s %6s %8s %8s %10s %10s\n", 193 + "Rank", "User", "ELO", "Wins", "Losses", "Win Rate", "Avg Moves")) 194 194 195 195 for i, entry := range entries { 196 196 rank := fmt.Sprintf("#%d", i+1) ··· 208 208 } 209 209 210 210 // Format line with proper spacing 211 - b.WriteString(fmt.Sprintf("%-4s %-20s %8d %8d %9.2f%% %9.1f\n", 212 - coloredRank, entry.Username, entry.Wins, entry.Losses, entry.WinPct, entry.AvgMoves)) 211 + b.WriteString(fmt.Sprintf("%-4s %-20s %6d %8d %8d %9.2f%% %9.1f\n", 212 + coloredRank, entry.Username, entry.Elo, entry.Wins, entry.Losses, entry.WinPct, entry.AvgMoves)) 213 213 } 214 214 215 215 return b.String()
+14 -12
runner.go
··· 20 20 } 21 21 22 22 for _, sub := range submissions { 23 - log.Printf("Starting compilation for submission %d: %s by %s", sub.ID, sub.Filename, sub.Username) 23 + log.Printf("⚙️ Compiling %s (%s)", sub.Username, sub.Filename) 24 24 25 25 if err := compileSubmission(sub); err != nil { 26 - log.Printf("Submission %d failed compilation: %v", sub.ID, err) 26 + log.Printf("❌ Compilation failed for %s: %v", sub.Username, err) 27 27 updateSubmissionStatus(sub.ID, "failed") 28 28 continue 29 29 } 30 30 31 - log.Printf("Submission %d compiled successfully: %s by %s", sub.ID, sub.Filename, sub.Username) 31 + log.Printf("✓ Compiled %s", sub.Username) 32 32 updateSubmissionStatus(sub.ID, "completed") 33 33 34 34 // Run round-robin matches 35 - log.Printf("Starting round-robin matches for submission %d", sub.ID) 36 35 runRoundRobinMatches(sub) 37 36 } 38 37 ··· 173 172 return 174 173 } 175 174 176 - log.Printf("Starting round-robin for %s against %d new opponents", newSub.Username, totalMatches) 175 + log.Printf("Starting round-robin for %s (%d opponents)", newSub.Username, totalMatches) 177 176 matchNum := 0 178 177 179 178 // Run matches against unplayed opponents only 180 179 for _, opponent := range unplayedOpponents { 181 180 matchNum++ 182 - log.Printf("[%d/%d] Running match: %s vs %s (1000 games)", matchNum, totalMatches, newSub.Username, opponent.Username) 183 181 184 182 // Run match (1000 games total) 185 183 player1Wins, player2Wins, totalMoves := runHeadToHead(newSub, opponent, 1000) ··· 190 188 191 189 if player1Wins > player2Wins { 192 190 winnerID = newSub.ID 193 - log.Printf("[%d/%d] Match result: %s wins (%d-%d, avg %d moves)", matchNum, totalMatches, newSub.Username, player1Wins, player2Wins, avgMoves) 191 + log.Printf("[%d/%d] %s defeats %s (%d-%d, %d moves avg)", matchNum, totalMatches, newSub.Username, opponent.Username, player1Wins, player2Wins, avgMoves) 194 192 } else if player2Wins > player1Wins { 195 193 winnerID = opponent.ID 196 - log.Printf("[%d/%d] Match result: %s wins (%d-%d, avg %d moves)", matchNum, totalMatches, opponent.Username, player2Wins, player1Wins, avgMoves) 194 + log.Printf("[%d/%d] %s defeats %s (%d-%d, %d moves avg)", matchNum, totalMatches, opponent.Username, newSub.Username, player2Wins, player1Wins, avgMoves) 197 195 } else { 198 196 // Tie - coin flip 199 197 if totalMoves%2 == 0 { ··· 201 199 } else { 202 200 winnerID = opponent.ID 203 201 } 204 - log.Printf("[%d/%d] Match result: Tie %d-%d, winner by coin flip: %d", matchNum, totalMatches, player1Wins, player2Wins, winnerID) 202 + log.Printf("[%d/%d] Tie %d-%d, coin flip winner: %s", matchNum, totalMatches, player1Wins, player2Wins, 203 + map[int]string{newSub.ID: newSub.Username, opponent.ID: opponent.Username}[winnerID]) 205 204 } 206 205 207 206 // Store match result 208 207 if err := addMatch(newSub.ID, opponent.ID, winnerID, player1Wins, player2Wins, avgMoves, avgMoves); err != nil { 209 208 log.Printf("Failed to store match result: %v", err) 210 209 } else { 211 - // Notify SSE clients of update after each match 212 - log.Printf("Broadcasting leaderboard update after match %d/%d", matchNum, totalMatches) 210 + // Update ELO ratings based on actual win percentages 211 + if err := updateEloRatings(newSub.ID, opponent.ID, player1Wins, player2Wins); err != nil { 212 + log.Printf("ELO update failed: %v", err) 213 + } 214 + 213 215 NotifyLeaderboardUpdate() 214 216 } 215 217 } 216 218 217 - log.Printf("Round-robin complete for %s (%d new matches)", newSub.Username, totalMatches) 219 + log.Printf("✓ Round-robin complete for %s (%d matches)", newSub.Username, totalMatches) 218 220 } 219 221 220 222 func runHeadToHead(player1, player2 Submission, numGames int) (int, int, int) {
+3 -10
sse.go
··· 12 12 13 13 func initSSE() { 14 14 sseServer = &sse.Server{} 15 - log.Printf("SSE server initialized (tmaxmax/go-sse)") 16 15 } 17 16 18 17 func handleSSE(w http.ResponseWriter, r *http.Request) { 19 - log.Printf("SSE client connected from %s", r.RemoteAddr) 20 18 sseServer.ServeHTTP(w, r) 21 19 } 22 20 ··· 24 22 func NotifyLeaderboardUpdate() { 25 23 entries, err := getLeaderboard(50) 26 24 if err != nil { 27 - log.Printf("Failed to get leaderboard for SSE: %v", err) 25 + log.Printf("SSE: failed to get leaderboard: %v", err) 28 26 return 29 27 } 30 28 31 29 data, err := json.Marshal(entries) 32 30 if err != nil { 33 - log.Printf("Failed to marshal leaderboard for SSE: %v", err) 31 + log.Printf("SSE: failed to marshal leaderboard: %v", err) 34 32 return 35 33 } 36 34 37 35 msg := &sse.Message{} 38 36 msg.AppendData(string(data)) 39 37 40 - // Publish to default topic 41 - log.Printf("Publishing to SSE clients (%d bytes)", len(data)) 42 38 if err := sseServer.Publish(msg); err != nil { 43 - log.Printf("Failed to publish SSE message: %v", err) 44 - return 39 + log.Printf("SSE: publish failed: %v", err) 45 40 } 46 - 47 - log.Printf("Broadcast leaderboard update to SSE clients (%d bytes)", len(data)) 48 41 }
+10 -6
web.go
··· 152 152 } 153 153 154 154 th:first-child { width: 80px; } 155 - th:nth-child(3), th:nth-child(4) { width: 100px; } 156 - th:nth-child(5) { width: 120px; } 157 - th:nth-child(6) { width: 120px; } 158 - th:last-child { width: 150px; } 155 + th:nth-child(3) { width: 90px; } /* ELO */ 156 + th:nth-child(4), th:nth-child(5) { width: 100px; } /* Wins, Losses */ 157 + th:nth-child(6) { width: 120px; } /* Win Rate */ 158 + th:nth-child(7) { width: 120px; } /* Avg Moves */ 159 + th:last-child { width: 150px; } /* Last Active */ 159 160 160 161 tbody tr { 161 162 border-bottom: 1px solid #334155; ··· 292 293 if (!tbody) return; 293 294 294 295 if (entries.length === 0) { 295 - tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="empty-state-icon">🎯</div><div>No submissions yet. Be the first to compete!</div></div></td></tr>'; 296 + tbody.innerHTML = '<tr><td colspan="8"><div class="empty-state"><div class="empty-state-icon">🎯</div><div>No submissions yet. Be the first to compete!</div></div></td></tr>'; 296 297 return; 297 298 } 298 299 ··· 312 313 return '<tr>' + 313 314 '<td class="rank rank-' + rank + '">' + medal + '</td>' + 314 315 '<td class="player-name">' + e.Username + '</td>' + 316 + '<td><strong>' + e.Elo + '</strong></td>' + 315 317 '<td>' + e.Wins.toLocaleString() + '</td>' + 316 318 '<td>' + e.Losses.toLocaleString() + '</td>' + 317 319 '<td><span class="win-rate ' + winRateClass + '">' + winRate + '%</span></td>' + ··· 364 366 <tr> 365 367 <th>Rank</th> 366 368 <th>Player</th> 369 + <th>ELO</th> 367 370 <th>Wins</th> 368 371 <th>Losses</th> 369 372 <th>Win Rate</th> ··· 377 380 <tr> 378 381 <td class="rank rank-{{add $i 1}}">{{if lt $i 3}}{{medal $i}}{{else}}{{add $i 1}}{{end}}</td> 379 382 <td class="player-name">{{$e.Username}}</td> 383 + <td><strong>{{$e.Elo}}</strong></td> 380 384 <td>{{$e.Wins}}</td> 381 385 <td>{{$e.Losses}}</td> 382 386 <td><span class="win-rate {{winRateClass $e}}">{{winRate $e}}%</span></td> ··· 386 390 {{end}} 387 391 {{else}} 388 392 <tr> 389 - <td colspan="7"> 393 + <td colspan="8"> 390 394 <div class="empty-state"> 391 395 <div class="empty-state-icon">🎯</div> 392 396 <div>No submissions yet. Be the first to compete!</div>