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

feat: add pending submissions

dunkirk.sh 8dc2bbe2 cb3204e8

verified
+75 -23
battleship-arena

This is a binary file and will not be displayed.

+49 -20
internal/server/web.go
··· 168 transition: background 0.2s; 169 } 170 171 tbody tr:hover { 172 background: rgba(59, 130, 246, 0.05); 173 } ··· 436 437 tbody.innerHTML = entries.map((e, i) => { 438 const rank = i + 1; 439 const winRate = e.WinPct.toFixed(1); 440 const winRateClass = e.WinPct >= 60 ? 'win-rate-high' : e.WinPct >= 40 ? 'win-rate-med' : 'win-rate-low'; 441 - const medals = ['🥇', '🥈', '🥉']; 442 - const medal = medals[i] || rank; 443 - const lastPlayed = new Date(e.LastPlayed).toLocaleString('en-US', { 444 month: 'short', 445 day: 'numeric', 446 hour: 'numeric', 447 minute: '2-digit' 448 }); 449 450 - return '<tr>' + 451 - '<td class="rank rank-' + rank + '">' + medal + '</td>' + 452 - '<td class="player-name"><a href="/user/' + e.Username + '" style="color: inherit; text-decoration: none;">' + e.Username + '</a></td>' + 453 - '<td><strong>' + e.Rating + '</strong> <span style="color: #94a3b8; font-size: 0.85em;">±' + e.RD + '</span></td>' + 454 - '<td>' + e.Wins.toLocaleString() + '</td>' + 455 - '<td>' + e.Losses.toLocaleString() + '</td>' + 456 - '<td><span class="win-rate ' + winRateClass + '">' + winRate + '%</span></td>' + 457 - '<td>' + e.AvgMoves.toFixed(1) + '</td>' + 458 '<td style="color: #64748b;">' + lastPlayed + '</td>' + 459 '</tr>'; 460 }).join(''); ··· 554 <tbody> 555 {{if .Entries}} 556 {{range $i, $e := .Entries}} 557 - <tr> 558 - <td class="rank rank-{{add $i 1}}">{{if lt $i 3}}{{medal $i}}{{else}}{{add $i 1}}{{end}}</td> 559 - <td class="player-name"><a href="/user/{{$e.Username}}" style="color: inherit; text-decoration: none;">{{$e.Username}}</a></td> 560 - <td><strong>{{$e.Rating}}</strong> <span style="color: #94a3b8; font-size: 0.85em;">±{{$e.RD}}</span></td> 561 - <td>{{$e.Wins}}</td> 562 - <td>{{$e.Losses}}</td> 563 - <td><span class="win-rate {{winRateClass $e}}">{{winRate $e}}%</span></td> 564 - <td>{{printf "%.1f" $e.AvgMoves}}</td> 565 - <td style="color: #64748b;">{{$e.LastPlayed.Format "Jan 2, 3:04 PM"}}</td> 566 </tr> 567 {{end}} 568 {{else}}
··· 168 transition: background 0.2s; 169 } 170 171 + tbody tr.pending { 172 + opacity: 0.5; 173 + color: #64748b; 174 + } 175 + 176 + tbody tr.pending .player-name { 177 + color: #64748b; 178 + } 179 + 180 + tbody tr.pending .rank { 181 + color: #64748b !important; 182 + } 183 + 184 tbody tr:hover { 185 background: rgba(59, 130, 246, 0.05); 186 } ··· 449 450 tbody.innerHTML = entries.map((e, i) => { 451 const rank = i + 1; 452 + const isPending = e.IsPending || false; 453 + const rowClass = isPending ? ' class="pending"' : ''; 454 + 455 + let rankDisplay; 456 + if (isPending) { 457 + rankDisplay = '⏳'; 458 + } else { 459 + const medals = ['🥇', '🥈', '🥉']; 460 + rankDisplay = medals[i] || rank; 461 + } 462 + 463 const winRate = e.WinPct.toFixed(1); 464 const winRateClass = e.WinPct >= 60 ? 'win-rate-high' : e.WinPct >= 40 ? 'win-rate-med' : 'win-rate-low'; 465 + const lastPlayed = isPending ? 'Waiting...' : new Date(e.LastPlayed).toLocaleString('en-US', { 466 month: 'short', 467 day: 'numeric', 468 hour: 'numeric', 469 minute: '2-digit' 470 }); 471 472 + const nameDisplay = e.Username + (isPending ? ' <span style="font-size: 0.8em;">(pending)</span>' : ''); 473 + const ratingDisplay = isPending ? '-' : '<strong>' + e.Rating + '</strong> <span style="color: #94a3b8; font-size: 0.85em;">±' + e.RD + '</span>'; 474 + const winsDisplay = isPending ? '-' : e.Wins.toLocaleString(); 475 + const lossesDisplay = isPending ? '-' : e.Losses.toLocaleString(); 476 + const winRateDisplay = isPending ? '-' : '<span class="win-rate ' + winRateClass + '">' + winRate + '%</span>'; 477 + const avgMovesDisplay = isPending ? '-' : e.AvgMoves.toFixed(1); 478 + 479 + return '<tr' + rowClass + '>' + 480 + '<td class="rank rank-' + rank + '">' + rankDisplay + '</td>' + 481 + '<td class="player-name"><a href="/user/' + e.Username + '" style="color: inherit; text-decoration: none;">' + nameDisplay + '</a></td>' + 482 + '<td>' + ratingDisplay + '</td>' + 483 + '<td>' + winsDisplay + '</td>' + 484 + '<td>' + lossesDisplay + '</td>' + 485 + '<td>' + winRateDisplay + '</td>' + 486 + '<td>' + avgMovesDisplay + '</td>' + 487 '<td style="color: #64748b;">' + lastPlayed + '</td>' + 488 '</tr>'; 489 }).join(''); ··· 583 <tbody> 584 {{if .Entries}} 585 {{range $i, $e := .Entries}} 586 + <tr{{if $e.IsPending}} class="pending"{{end}}> 587 + <td class="rank rank-{{add $i 1}}">{{if $e.IsPending}}⏳{{else if lt $i 3}}{{medal $i}}{{else}}{{add $i 1}}{{end}}</td> 588 + <td class="player-name"><a href="/user/{{$e.Username}}" style="color: inherit; text-decoration: none;">{{$e.Username}}{{if $e.IsPending}} <span style="font-size: 0.8em;">(pending)</span>{{end}}</a></td> 589 + <td>{{if $e.IsPending}}-{{else}}<strong>{{$e.Rating}}</strong> <span style="color: #94a3b8; font-size: 0.85em;">±{{$e.RD}}</span>{{end}}</td> 590 + <td>{{if $e.IsPending}}-{{else}}{{$e.Wins}}{{end}}</td> 591 + <td>{{if $e.IsPending}}-{{else}}{{$e.Losses}}{{end}}</td> 592 + <td>{{if $e.IsPending}}-{{else}}<span class="win-rate {{winRateClass $e}}">{{winRate $e}}%</span>{{end}}</td> 593 + <td>{{if $e.IsPending}}-{{else}}{{printf "%.1f" $e.AvgMoves}}{{end}}</td> 594 + <td style="color: #64748b;">{{if $e.IsPending}}Waiting...{{else}}{{$e.LastPlayed.Format "Jan 2, 3:04 PM"}}{{end}}</td> 595 </tr> 596 {{end}} 597 {{else}}
+26 -3
internal/storage/database.go
··· 20 AvgMoves float64 21 Stage string 22 LastPlayed time.Time 23 } 24 25 type Submission struct { ··· 175 } 176 177 func GetLeaderboard(limit int) ([]LeaderboardEntry, error) { 178 query := ` 179 SELECT 180 s.username, ··· 183 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, 184 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, 185 AVG(CASE WHEN m.player1_id = s.id THEN m.player1_moves ELSE m.player2_moves END) as avg_moves, 186 - MAX(m.timestamp) as last_played 187 FROM submissions s 188 LEFT JOIN matches m ON (m.player1_id = s.id OR m.player2_id = s.id) AND m.is_valid = 1 189 WHERE s.is_active = 1 190 GROUP BY s.username, s.glicko_rating, s.glicko_rd 191 HAVING COUNT(m.id) > 0 192 - ORDER BY rating DESC, total_wins DESC 193 LIMIT ? 194 ` 195 ··· 204 var e LeaderboardEntry 205 var lastPlayed string 206 var rating, rd float64 207 - err := rows.Scan(&e.Username, &rating, &rd, &e.Wins, &e.Losses, &e.AvgMoves, &lastPlayed) 208 if err != nil { 209 return nil, err 210 } 211 212 e.Rating = int(rating) 213 e.RD = int(rd) 214 215 totalGames := e.Wins + e.Losses 216 if totalGames > 0 {
··· 20 AvgMoves float64 21 Stage string 22 LastPlayed time.Time 23 + IsPending bool 24 } 25 26 type Submission struct { ··· 176 } 177 178 func GetLeaderboard(limit int) ([]LeaderboardEntry, error) { 179 + // Get submissions with matches 180 query := ` 181 SELECT 182 s.username, ··· 185 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, 186 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, 187 AVG(CASE WHEN m.player1_id = s.id THEN m.player1_moves ELSE m.player2_moves END) as avg_moves, 188 + MAX(m.timestamp) as last_played, 189 + 0 as is_pending 190 FROM submissions s 191 LEFT JOIN matches m ON (m.player1_id = s.id OR m.player2_id = s.id) AND m.is_valid = 1 192 WHERE s.is_active = 1 193 GROUP BY s.username, s.glicko_rating, s.glicko_rd 194 HAVING COUNT(m.id) > 0 195 + 196 + UNION ALL 197 + 198 + SELECT 199 + s.username, 200 + 1500.0 as rating, 201 + 350.0 as rd, 202 + 0 as total_wins, 203 + 0 as total_losses, 204 + 0.0 as avg_moves, 205 + s.upload_time as last_played, 206 + 1 as is_pending 207 + FROM submissions s 208 + LEFT JOIN matches m ON (m.player1_id = s.id OR m.player2_id = s.id) AND m.is_valid = 1 209 + WHERE s.is_active = 1 AND s.status IN ('pending', 'testing') 210 + GROUP BY s.username, s.upload_time 211 + HAVING COUNT(m.id) = 0 212 + 213 + ORDER BY is_pending ASC, rating DESC, total_wins DESC 214 LIMIT ? 215 ` 216 ··· 225 var e LeaderboardEntry 226 var lastPlayed string 227 var rating, rd float64 228 + var isPending int 229 + err := rows.Scan(&e.Username, &rating, &rd, &e.Wins, &e.Losses, &e.AvgMoves, &lastPlayed, &isPending) 230 if err != nil { 231 return nil, err 232 } 233 234 e.Rating = int(rating) 235 e.RD = int(rd) 236 + e.IsPending = isPending == 1 237 238 totalGames := e.Wins + e.Losses 239 if totalGames > 0 {