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: impove queue and add status modal

+349 -17
+8 -8
database.go
··· 152 152 query := ` 153 153 SELECT 154 154 s.username, 155 - s.glicko_rating, 156 - s.glicko_rd, 155 + COALESCE(s.glicko_rating, 1500.0) as rating, 156 + COALESCE(s.glicko_rd, 350.0) as rd, 157 157 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, 158 158 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, 159 159 AVG(CASE WHEN m.player1_id = s.id THEN m.player1_moves ELSE m.player2_moves END) as avg_moves, ··· 163 163 WHERE s.is_active = 1 164 164 GROUP BY s.username, s.glicko_rating, s.glicko_rd 165 165 HAVING COUNT(m.id) > 0 166 - ORDER BY s.glicko_rating DESC, total_wins DESC 166 + ORDER BY rating DESC, total_wins DESC 167 167 LIMIT ? 168 168 ` 169 169 ··· 222 222 return 0, err 223 223 } 224 224 225 - // Insert new submission 225 + // Insert new submission with default Glicko-2 values 226 226 result, err := globalDB.Exec( 227 - "INSERT INTO submissions (username, filename, is_active) VALUES (?, ?, 1)", 227 + "INSERT INTO submissions (username, filename, is_active, glicko_rating, glicko_rd, glicko_volatility) VALUES (?, ?, 1, 1500.0, 350.0, 0.06)", 228 228 username, filename, 229 229 ) 230 230 if err != nil { ··· 454 454 } 455 455 456 456 func updateGlicko2Ratings(player1ID, player2ID, player1Wins, player2Wins int) error { 457 - // Get current Glicko-2 values for both players 457 + // Get current Glicko-2 values for both players, with defaults for NULL 458 458 var p1Rating, p1RD, p1Vol, p2Rating, p2RD, p2Vol float64 459 459 460 460 err := globalDB.QueryRow( 461 - "SELECT glicko_rating, glicko_rd, glicko_volatility FROM submissions WHERE id = ?", 461 + "SELECT COALESCE(glicko_rating, 1500.0), COALESCE(glicko_rd, 350.0), COALESCE(glicko_volatility, 0.06) FROM submissions WHERE id = ?", 462 462 player1ID, 463 463 ).Scan(&p1Rating, &p1RD, &p1Vol) 464 464 if err != nil { ··· 466 466 } 467 467 468 468 err = globalDB.QueryRow( 469 - "SELECT glicko_rating, glicko_rd, glicko_volatility FROM submissions WHERE id = ?", 469 + "SELECT COALESCE(glicko_rating, 1500.0), COALESCE(glicko_rd, 350.0), COALESCE(glicko_volatility, 0.06) FROM submissions WHERE id = ?", 470 470 player2ID, 471 471 ).Scan(&p2Rating, &p2RD, &p2Vol) 472 472 if err != nil {
+35
runner.go
··· 9 9 "regexp" 10 10 "strconv" 11 11 "strings" 12 + "time" 12 13 ) 13 14 14 15 const enginePath = "./battleship-engine" 16 + 17 + func getQueuedPlayerNames() []string { 18 + // Get submissions that are either pending OR currently being tested 19 + // This gives a complete view of the processing queue 20 + rows, err := globalDB.Query( 21 + "SELECT username FROM submissions WHERE (status = 'pending' OR status = 'testing') AND is_active = 1 ORDER BY upload_time", 22 + ) 23 + if err != nil { 24 + return []string{} 25 + } 26 + defer rows.Close() 27 + 28 + var names []string 29 + for rows.Next() { 30 + var username string 31 + if err := rows.Scan(&username); err == nil { 32 + names = append(names, username) 33 + } 34 + } 35 + return names 36 + } 15 37 16 38 func recordRatingSnapshot(submissionID, matchID int) { 17 39 var rating, rd, volatility float64 ··· 186 208 187 209 log.Printf("Starting round-robin for %s (%d opponents)", newSub.Username, totalMatches) 188 210 matchNum := 0 211 + startTime := time.Now() 189 212 190 213 // Run matches against unplayed opponents only 191 214 for _, opponent := range unplayedOpponents { 192 215 matchNum++ 216 + 217 + // Get fresh queue data before each match 218 + queuedPlayers := getQueuedPlayerNames() 219 + 220 + // Broadcast progress update 221 + broadcastProgress(newSub.Username, matchNum, totalMatches, startTime, queuedPlayers) 193 222 194 223 // Run match (1000 games total) 195 224 player1Wins, player2Wins, totalMoves := runHeadToHead(newSub, opponent, 1000) ··· 231 260 232 261 NotifyLeaderboardUpdate() 233 262 } 263 + } 264 + 265 + // Check if queue is empty before hiding 266 + queuedPlayers := getQueuedPlayerNames() 267 + if len(queuedPlayers) == 0 { 268 + broadcastProgressComplete() 234 269 } 235 270 236 271 log.Printf("✓ Round-robin complete for %s (%d matches)", newSub.Username, totalMatches)
+94
sse.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 + "fmt" 5 6 "log" 6 7 "net/http" 8 + "time" 7 9 8 10 "github.com/tmaxmax/go-sse" 9 11 ) 10 12 11 13 var sseServer *sse.Server 12 14 15 + type ProgressUpdate struct { 16 + Type string `json:"type"` // "progress" or "complete" 17 + Player string `json:"player,omitempty"` 18 + Opponent string `json:"opponent,omitempty"` 19 + CurrentMatch int `json:"current_match,omitempty"` 20 + TotalMatches int `json:"total_matches,omitempty"` 21 + EstimatedTimeLeft string `json:"estimated_time_left,omitempty"` 22 + PercentComplete float64 `json:"percent_complete,omitempty"` 23 + QueuedPlayers []string `json:"queued_players,omitempty"` 24 + } 25 + 13 26 func initSSE() { 14 27 sseServer = &sse.Server{} 15 28 } ··· 39 52 log.Printf("SSE: publish failed: %v", err) 40 53 } 41 54 } 55 + 56 + func broadcastProgress(player string, currentMatch, totalMatches int, startTime time.Time, queuedPlayers []string) { 57 + elapsed := time.Since(startTime) 58 + avgTimePerMatch := elapsed / time.Duration(currentMatch) 59 + remainingMatches := totalMatches - currentMatch 60 + estimatedTimeLeft := avgTimePerMatch * time.Duration(remainingMatches) 61 + 62 + percentComplete := float64(currentMatch) / float64(totalMatches) * 100.0 63 + 64 + // Format time left 65 + timeLeftStr := formatDuration(estimatedTimeLeft) 66 + 67 + // Filter out current player from queue (they're being shown in progress, not queue) 68 + filteredQueue := make([]string, 0) 69 + for _, p := range queuedPlayers { 70 + if p != player { 71 + filteredQueue = append(filteredQueue, p) 72 + } 73 + } 74 + 75 + progress := ProgressUpdate{ 76 + Type: "progress", 77 + Player: player, 78 + CurrentMatch: currentMatch, 79 + TotalMatches: totalMatches, 80 + EstimatedTimeLeft: timeLeftStr, 81 + PercentComplete: percentComplete, 82 + QueuedPlayers: filteredQueue, 83 + } 84 + 85 + data, err := json.Marshal(progress) 86 + if err != nil { 87 + log.Printf("Failed to marshal progress: %v", err) 88 + return 89 + } 90 + 91 + log.Printf("Broadcasting progress: %s [%d/%d] %.1f%% (queue: %d)", player, currentMatch, totalMatches, percentComplete, len(filteredQueue)) 92 + 93 + msg := &sse.Message{} 94 + msg.AppendData(string(data)) 95 + // Don't set Type - just send as regular message 96 + 97 + if err := sseServer.Publish(msg); err != nil { 98 + log.Printf("SSE: progress publish failed: %v", err) 99 + } 100 + } 101 + 102 + func formatDuration(d time.Duration) string { 103 + if d < time.Minute { 104 + return "< 1 min" 105 + } 106 + minutes := int(d.Minutes()) 107 + if minutes < 60 { 108 + return fmt.Sprintf("%d min", minutes) 109 + } 110 + hours := minutes / 60 111 + mins := minutes % 60 112 + if mins > 0 { 113 + return fmt.Sprintf("%dh %dm", hours, mins) 114 + } 115 + return fmt.Sprintf("%dh", hours) 116 + } 117 + 118 + func broadcastProgressComplete() { 119 + complete := ProgressUpdate{ 120 + Type: "complete", 121 + } 122 + 123 + data, err := json.Marshal(complete) 124 + if err != nil { 125 + return 126 + } 127 + 128 + log.Printf("Broadcasting progress complete") 129 + 130 + msg := &sse.Message{} 131 + msg.AppendData(string(data)) 132 + // Don't set Type - just send as regular message 133 + 134 + sseServer.Publish(msg) 135 + }
+188 -3
web.go
··· 256 256 margin-bottom: 1rem; 257 257 } 258 258 259 + .progress-indicator { 260 + position: fixed; 261 + bottom: 2rem; 262 + right: 2rem; 263 + background: #1e293b; 264 + border: 2px solid #3b82f6; 265 + border-radius: 12px; 266 + padding: 1.5rem; 267 + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); 268 + min-width: 300px; 269 + z-index: 1000; 270 + animation: slideIn 0.3s ease-out; 271 + } 272 + 273 + @keyframes slideIn { 274 + from { 275 + transform: translateX(400px); 276 + opacity: 0; 277 + } 278 + to { 279 + transform: translateX(0); 280 + opacity: 1; 281 + } 282 + } 283 + 284 + .progress-indicator.hidden { 285 + display: none; 286 + } 287 + 288 + .progress-header { 289 + display: flex; 290 + align-items: center; 291 + margin-bottom: 1rem; 292 + } 293 + 294 + .progress-spinner { 295 + width: 20px; 296 + height: 20px; 297 + border: 3px solid #334155; 298 + border-top-color: #3b82f6; 299 + border-radius: 50%; 300 + animation: spin 0.8s linear infinite; 301 + margin-right: 0.75rem; 302 + } 303 + 304 + @keyframes spin { 305 + to { transform: rotate(360deg); } 306 + } 307 + 308 + .progress-title { 309 + font-weight: 600; 310 + color: #e2e8f0; 311 + font-size: 1rem; 312 + } 313 + 314 + .progress-player { 315 + color: #3b82f6; 316 + font-weight: 700; 317 + margin-bottom: 0.5rem; 318 + } 319 + 320 + .progress-stats { 321 + font-size: 0.875rem; 322 + color: #94a3b8; 323 + margin-bottom: 0.75rem; 324 + } 325 + 326 + .progress-bar-container { 327 + background: #0f172a; 328 + border-radius: 4px; 329 + height: 8px; 330 + overflow: hidden; 331 + margin-bottom: 0.5rem; 332 + } 333 + 334 + .progress-bar { 335 + background: linear-gradient(90deg, #3b82f6, #8b5cf6); 336 + height: 100%; 337 + transition: width 0.5s ease; 338 + border-radius: 4px; 339 + } 340 + 341 + .progress-time { 342 + font-size: 0.75rem; 343 + color: #64748b; 344 + text-align: right; 345 + } 346 + 347 + .progress-queue { 348 + margin-top: 1rem; 349 + padding-top: 1rem; 350 + border-top: 1px solid #334155; 351 + } 352 + 353 + .progress-queue-title { 354 + font-size: 0.75rem; 355 + color: #64748b; 356 + margin-bottom: 0.5rem; 357 + } 358 + 359 + .progress-queue-list { 360 + font-size: 0.875rem; 361 + color: #94a3b8; 362 + max-height: 100px; 363 + overflow-y: auto; 364 + } 365 + 366 + .progress-queue-item { 367 + padding: 0.25rem 0; 368 + } 369 + 259 370 @media (max-width: 768px) { 260 371 h1 { font-size: 2rem; } 261 372 .subtitle { font-size: 1rem; } ··· 277 388 278 389 eventSource.onmessage = (event) => { 279 390 try { 280 - const entries = JSON.parse(event.data); 281 - console.log('Updating leaderboard with', entries.length, 'entries'); 282 - updateLeaderboard(entries); 391 + const data = JSON.parse(event.data); 392 + console.log('SSE message received:', data); 393 + 394 + // Check if it's a progress update or leaderboard update 395 + if (data.type === 'progress') { 396 + console.log('Progress update:', data); 397 + updateProgress(data); 398 + } else if (data.type === 'complete') { 399 + console.log('Progress complete'); 400 + hideProgress(); 401 + } else if (Array.isArray(data)) { 402 + // Leaderboard update 403 + console.log('Updating leaderboard with', data.length, 'entries'); 404 + updateLeaderboard(data); 405 + } 283 406 } catch (error) { 284 407 console.error('Failed to parse SSE data:', error); 285 408 } ··· 334 457 statValues[1].textContent = totalGames.toLocaleString(); 335 458 } 336 459 460 + function updateProgress(data) { 461 + const indicator = document.getElementById('progress-indicator'); 462 + 463 + if (!indicator) { 464 + console.error('Progress indicator element not found!'); 465 + return; 466 + } 467 + 468 + console.log('Updating progress indicator:', data); 469 + 470 + // Show indicator 471 + indicator.classList.remove('hidden'); 472 + 473 + // Update content 474 + document.getElementById('progress-player').textContent = data.player; 475 + document.getElementById('progress-current').textContent = data.current_match; 476 + document.getElementById('progress-total').textContent = data.total_matches; 477 + document.getElementById('progress-time').textContent = data.estimated_time_left; 478 + document.getElementById('progress-bar').style.width = data.percent_complete + '%'; 479 + 480 + // Update queue 481 + const queueContainer = document.getElementById('progress-queue-container'); 482 + if (data.queued_players && data.queued_players.length > 0) { 483 + queueContainer.style.display = 'block'; 484 + const queueList = document.getElementById('progress-queue-list'); 485 + queueList.innerHTML = data.queued_players.map(p => 486 + '<div class="progress-queue-item">⏳ ' + p + '</div>' 487 + ).join(''); 488 + } else { 489 + queueContainer.style.display = 'none'; 490 + } 491 + } 492 + 493 + function hideProgress() { 494 + const indicator = document.getElementById('progress-indicator'); 495 + if (indicator) { 496 + indicator.classList.add('hidden'); 497 + } 498 + } 499 + 337 500 window.addEventListener('DOMContentLoaded', () => { 338 501 connectSSE(); 339 502 }); ··· 412 575 <p>Connect via SSH to submit your battleship AI:</p> 413 576 <p><code>ssh -p 2222 username@localhost</code></p> 414 577 <p style="margin-top: 1rem;">Upload your <code>memory_functions_*.cpp</code> file and compete in the arena!</p> 578 + </div> 579 + </div> 580 + 581 + <!-- Progress Indicator --> 582 + <div id="progress-indicator" class="progress-indicator hidden"> 583 + <div class="progress-header"> 584 + <div class="progress-spinner"></div> 585 + <div class="progress-title">Computing Ratings</div> 586 + </div> 587 + <div class="progress-player" id="progress-player">-</div> 588 + <div class="progress-stats"> 589 + Match <span id="progress-current">0</span> of <span id="progress-total">0</span> 590 + </div> 591 + <div class="progress-bar-container"> 592 + <div class="progress-bar" id="progress-bar" style="width: 0%"></div> 593 + </div> 594 + <div class="progress-time"> 595 + Est. <span id="progress-time">-</span> remaining 596 + </div> 597 + <div id="progress-queue-container" class="progress-queue" style="display: none;"> 598 + <div class="progress-queue-title">Queued Players:</div> 599 + <div id="progress-queue-list" class="progress-queue-list"></div> 415 600 </div> 416 601 </div> 417 602 </body>
+24 -6
worker.go
··· 3 3 import ( 4 4 "context" 5 5 "log" 6 + "sync" 6 7 "time" 7 8 ) 9 + 10 + var workerMutex sync.Mutex 8 11 9 12 // Background worker that processes pending submissions and bracket matches 10 13 func startWorker(ctx context.Context) { ··· 12 15 defer ticker.Stop() 13 16 14 17 // Process immediately on start 15 - if err := processSubmissions(); err != nil { 16 - log.Printf("Worker error (submissions): %v", err) 17 - } 18 + go func() { 19 + if err := processSubmissionsWithLock(); err != nil { 20 + log.Printf("Worker error (submissions): %v", err) 21 + } 22 + }() 18 23 19 24 for { 20 25 select { 21 26 case <-ctx.Done(): 22 27 return 23 28 case <-ticker.C: 24 - if err := processSubmissions(); err != nil { 25 - log.Printf("Worker error (submissions): %v", err) 26 - } 29 + go func() { 30 + if err := processSubmissionsWithLock(); err != nil { 31 + log.Printf("Worker error (submissions): %v", err) 32 + } 33 + }() 27 34 } 28 35 } 29 36 } 37 + 38 + func processSubmissionsWithLock() error { 39 + // Try to acquire lock, return immediately if already processing 40 + if !workerMutex.TryLock() { 41 + log.Printf("Worker already running, skipping this cycle") 42 + return nil 43 + } 44 + defer workerMutex.Unlock() 45 + 46 + return processSubmissions() 47 + }