a geicko-2 based round robin ranking system designed to test c++ battleship submissions battleship.dunkirk.sh
at main 46 kB view raw
1package server 2 3import ( 4 "encoding/json" 5 "fmt" 6 "html/template" 7 "net/http" 8 9 "github.com/go-chi/chi/v5" 10 11 "battleship-arena/internal/storage" 12) 13 14const leaderboardHTML = ` 15<!DOCTYPE html> 16<html lang="en"> 17<head> 18 <title>Battleship Arena</title> 19 <meta charset="UTF-8"> 20 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 21 <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚓</text></svg>"> 22 <style> 23 * { 24 margin: 0; 25 padding: 0; 26 box-sizing: border-box; 27 } 28 29 body { 30 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 31 background: #0f172a; 32 color: #e2e8f0; 33 min-height: 100vh; 34 padding: 2rem 1rem; 35 } 36 37 .container { 38 max-width: 1400px; 39 margin: 0 auto; 40 } 41 42 header { 43 text-align: center; 44 margin-bottom: 3rem; 45 } 46 47 h1 { 48 font-size: 3rem; 49 font-weight: 800; 50 background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100%); 51 -webkit-background-clip: text; 52 -webkit-text-fill-color: transparent; 53 background-clip: text; 54 margin-bottom: 0.5rem; 55 } 56 57 .subtitle { 58 font-size: 1.125rem; 59 color: #94a3b8; 60 } 61 62 .status-bar { 63 display: flex; 64 align-items: center; 65 justify-content: center; 66 gap: 0.5rem; 67 margin: 1.5rem 0; 68 padding: 0.75rem; 69 background: rgba(16, 185, 129, 0.1); 70 border: 1px solid rgba(16, 185, 129, 0.2); 71 border-radius: 0.5rem; 72 font-size: 0.875rem; 73 color: #10b981; 74 } 75 76 .live-dot { 77 width: 8px; 78 height: 8px; 79 background: #10b981; 80 border-radius: 50%; 81 animation: pulse 2s ease-in-out infinite; 82 } 83 84 @keyframes pulse { 85 0%, 100% { opacity: 1; transform: scale(1); } 86 50% { opacity: 0.5; transform: scale(1.1); } 87 } 88 89 .stats-grid { 90 display: grid; 91 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 92 gap: 1.5rem; 93 margin-bottom: 2rem; 94 } 95 96 .stat-card { 97 background: #1e293b; 98 border: 1px solid #334155; 99 border-radius: 0.75rem; 100 padding: 1.5rem; 101 text-align: center; 102 } 103 104 .stat-value { 105 font-size: 2.5rem; 106 font-weight: 700; 107 background: linear-gradient(135deg, #3b82f6, #8b5cf6); 108 -webkit-background-clip: text; 109 -webkit-text-fill-color: transparent; 110 background-clip: text; 111 } 112 113 .stat-label { 114 font-size: 0.875rem; 115 color: #94a3b8; 116 margin-top: 0.5rem; 117 text-transform: uppercase; 118 letter-spacing: 0.05em; 119 } 120 121 .leaderboard { 122 background: #1e293b; 123 border: 1px solid #334155; 124 border-radius: 0.75rem; 125 overflow: hidden; 126 margin-bottom: 2rem; 127 } 128 129 .leaderboard-header { 130 padding: 1.5rem; 131 background: linear-gradient(135deg, #1e293b 0%, #334155 100%); 132 border-bottom: 1px solid #334155; 133 } 134 135 .leaderboard-header h2 { 136 font-size: 1.5rem; 137 font-weight: 700; 138 } 139 140 table { 141 width: 100%; 142 border-collapse: collapse; 143 } 144 145 thead { 146 background: #0f172a; 147 } 148 149 th { 150 padding: 1rem 1.5rem; 151 text-align: left; 152 font-size: 0.75rem; 153 font-weight: 600; 154 color: #94a3b8; 155 text-transform: uppercase; 156 letter-spacing: 0.05em; 157 } 158 159 th:first-child { width: 80px; } 160 th:nth-child(3) { width: 90px; } /* ELO */ 161 th:nth-child(4), th:nth-child(5) { width: 100px; } /* Wins, Losses */ 162 th:nth-child(6) { width: 120px; } /* Win Rate */ 163 th:nth-child(7) { width: 120px; } /* Avg Moves */ 164 th:last-child { width: 150px; } /* Last Active */ 165 166 tbody tr { 167 border-bottom: 1px solid #334155; 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.broken { 185 opacity: 0.6; 186 color: #ef4444; 187 } 188 189 tbody tr.broken .player-name { 190 color: #f87171; 191 } 192 193 tbody tr.broken .rank { 194 color: #ef4444 !important; 195 } 196 197 tbody tr:hover { 198 background: rgba(59, 130, 246, 0.05); 199 } 200 201 tbody tr:last-child { 202 border-bottom: none; 203 } 204 205 td { 206 padding: 1.25rem 1.5rem; 207 font-size: 0.9375rem; 208 } 209 210 .rank { 211 font-size: 1.25rem; 212 font-weight: 700; 213 } 214 215 .rank-1 { color: #fbbf24; } 216 .rank-2 { color: #d1d5db; } 217 .rank-3 { color: #f59e0b; } 218 219 .player-name { 220 font-weight: 600; 221 color: #e2e8f0; 222 } 223 224 .player-name a:hover { 225 color: #60a5fa !important; 226 text-decoration: underline !important; 227 } 228 229 .win-rate { 230 font-weight: 600; 231 padding: 0.25rem 0.75rem; 232 border-radius: 0.375rem; 233 display: inline-block; 234 } 235 236 .win-rate-high { 237 background: rgba(16, 185, 129, 0.1); 238 color: #10b981; 239 } 240 241 .win-rate-med { 242 background: rgba(245, 158, 11, 0.1); 243 color: #f59e0b; 244 } 245 246 .win-rate-low { 247 background: rgba(239, 68, 68, 0.1); 248 color: #ef4444; 249 } 250 251 .info-card { 252 background: #1e293b; 253 border: 1px solid #334155; 254 border-radius: 0.75rem; 255 padding: 2rem; 256 } 257 258 .info-card h3 { 259 font-size: 1.25rem; 260 margin-bottom: 1rem; 261 color: #e2e8f0; 262 } 263 264 .info-card p { 265 color: #94a3b8; 266 line-height: 1.6; 267 margin-bottom: 0.75rem; 268 } 269 270 code { 271 background: #0f172a; 272 padding: 0.5rem 0.875rem; 273 border-radius: 0.5rem; 274 font-family: 'Monaco', 'Courier New', monospace; 275 font-size: 0.875rem; 276 color: #60a5fa; 277 border: 1px solid #1e3a8a; 278 display: inline-block; 279 line-height: 1.5; 280 } 281 282 .code-block { 283 position: relative; 284 background: #0f172a; 285 border: 1px solid #1e3a8a; 286 border-radius: 0.5rem; 287 margin: 1rem 0; 288 overflow: hidden; 289 } 290 291 .code-block-header { 292 background: #1e3a8a; 293 padding: 0.5rem 1rem; 294 display: flex; 295 justify-content: space-between; 296 align-items: center; 297 border-bottom: 1px solid #1e3a8a; 298 } 299 300 .code-block-lang { 301 color: #94a3b8; 302 font-size: 0.75rem; 303 font-weight: 600; 304 text-transform: uppercase; 305 letter-spacing: 0.05em; 306 } 307 308 .code-block-copy { 309 background: #3b82f6; 310 color: white; 311 border: none; 312 padding: 0.25rem 0.75rem; 313 border-radius: 0.25rem; 314 font-size: 0.75rem; 315 cursor: pointer; 316 transition: background 0.2s; 317 } 318 319 .code-block-copy:hover { 320 background: #2563eb; 321 } 322 323 .code-block-copy.copied { 324 background: #10b981; 325 } 326 327 .code-block pre { 328 margin: 0; 329 padding: 1rem; 330 overflow-x: auto; 331 } 332 333 .code-block code { 334 background: transparent; 335 border: none; 336 padding: 0; 337 display: block; 338 color: #e2e8f0; 339 } 340 341 .code-block .token-command { 342 color: #60a5fa; 343 } 344 345 .code-block .token-flag { 346 color: #a78bfa; 347 } 348 349 .code-block .token-string { 350 color: #34d399; 351 } 352 353 .code-block .token-comment { 354 color: #64748b; 355 font-style: italic; 356 } 357 358 .empty-state { 359 text-align: center; 360 padding: 4rem 2rem; 361 color: #64748b; 362 } 363 364 .empty-state-icon { 365 font-size: 3rem; 366 margin-bottom: 1rem; 367 } 368 369 .progress-indicator { 370 position: fixed; 371 bottom: 2rem; 372 right: 2rem; 373 background: #1e293b; 374 border: 2px solid #3b82f6; 375 border-radius: 12px; 376 padding: 1.5rem; 377 box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); 378 min-width: 300px; 379 z-index: 1000; 380 animation: slideIn 0.3s ease-out; 381 } 382 383 @keyframes slideIn { 384 from { 385 transform: translateX(400px); 386 opacity: 0; 387 } 388 to { 389 transform: translateX(0); 390 opacity: 1; 391 } 392 } 393 394 .progress-indicator.hidden { 395 display: none; 396 } 397 398 .progress-header { 399 display: flex; 400 align-items: center; 401 margin-bottom: 1rem; 402 } 403 404 .progress-spinner { 405 width: 20px; 406 height: 20px; 407 border: 3px solid #334155; 408 border-top-color: #3b82f6; 409 border-radius: 50%; 410 animation: spin 0.8s linear infinite; 411 margin-right: 0.75rem; 412 } 413 414 @keyframes spin { 415 to { transform: rotate(360deg); } 416 } 417 418 .progress-title { 419 font-weight: 600; 420 color: #e2e8f0; 421 font-size: 1rem; 422 } 423 424 .progress-player { 425 color: #3b82f6; 426 font-weight: 700; 427 margin-bottom: 0.5rem; 428 } 429 430 .progress-stats { 431 font-size: 0.875rem; 432 color: #94a3b8; 433 margin-bottom: 0.75rem; 434 } 435 436 .progress-bar-container { 437 background: #0f172a; 438 border-radius: 4px; 439 height: 8px; 440 overflow: hidden; 441 margin-bottom: 0.5rem; 442 } 443 444 .progress-bar { 445 background: linear-gradient(90deg, #3b82f6, #8b5cf6); 446 height: 100%; 447 transition: width 0.5s ease; 448 border-radius: 4px; 449 } 450 451 .progress-time { 452 font-size: 0.75rem; 453 color: #64748b; 454 text-align: right; 455 } 456 457 .progress-queue { 458 margin-top: 1rem; 459 padding-top: 1rem; 460 border-top: 1px solid #334155; 461 } 462 463 .progress-queue-title { 464 font-size: 0.75rem; 465 color: #64748b; 466 margin-bottom: 0.5rem; 467 } 468 469 .progress-queue-list { 470 font-size: 0.875rem; 471 color: #94a3b8; 472 max-height: 100px; 473 overflow-y: auto; 474 } 475 476 .progress-queue-item { 477 padding: 0.25rem 0; 478 } 479 480 .hidden { 481 display: none; 482 } 483 484 .tooltip { 485 position: relative; 486 display: inline-block; 487 cursor: help; 488 } 489 490 .tooltip:hover::after { 491 content: attr(data-tooltip); 492 position: absolute; 493 bottom: 100%; 494 left: 50%; 495 transform: translateX(-50%); 496 background: #1e293b; 497 border: 1px solid #3b82f6; 498 color: #e2e8f0; 499 padding: 0.5rem 0.75rem; 500 border-radius: 0.375rem; 501 font-size: 0.75rem; 502 white-space: nowrap; 503 z-index: 1000; 504 margin-bottom: 0.5rem; 505 box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); 506 } 507 508 .info-card ul { 509 list-style: none; 510 margin: 1rem 0; 511 } 512 513 .info-card li { 514 padding: 0.5rem 0; 515 color: #94a3b8; 516 display: flex; 517 align-items: start; 518 gap: 0.5rem; 519 } 520 521 .info-card li::before { 522 content: "→"; 523 color: #3b82f6; 524 font-weight: bold; 525 flex-shrink: 0; 526 } 527 528 .collapsible-section { 529 margin-top: 2rem; 530 margin-bottom: 2rem; 531 background: #1e293b; 532 border: 1px solid #334155; 533 border-radius: 0.75rem; 534 overflow: hidden; 535 } 536 537 .collapsible-header { 538 padding: 1rem 1.5rem; 539 cursor: pointer; 540 display: flex; 541 justify-content: space-between; 542 align-items: center; 543 background: #1e293b; 544 border-bottom: 1px solid #334155; 545 transition: background 0.2s; 546 } 547 548 .collapsible-header:hover { 549 background: #334155; 550 } 551 552 .collapsible-title { 553 font-size: 1rem; 554 font-weight: 600; 555 color: #e2e8f0; 556 display: flex; 557 align-items: center; 558 gap: 0.5rem; 559 } 560 561 .collapsible-count { 562 background: rgba(239, 68, 68, 0.2); 563 color: #ef4444; 564 padding: 0.25rem 0.5rem; 565 border-radius: 0.375rem; 566 font-size: 0.875rem; 567 } 568 569 .collapsible-arrow { 570 transition: transform 0.2s; 571 color: #94a3b8; 572 } 573 574 .collapsible-content { 575 max-height: 0; 576 overflow: hidden; 577 transition: max-height 0.3s ease-out; 578 } 579 580 .collapsible-content.open { 581 max-height: 1000px; 582 } 583 584 @media (max-width: 768px) { 585 h1 { font-size: 2rem; } 586 .subtitle { font-size: 1rem; } 587 th, td { padding: 0.75rem 1rem; font-size: 0.875rem; } 588 .stat-value { font-size: 2rem; } 589 .progress-indicator { 590 bottom: 1rem; 591 right: 1rem; 592 left: 1rem; 593 min-width: unset; 594 } 595 } 596 </style> 597 <script> 598 let eventSource; 599 600 function connectSSE() { 601 console.log('Connecting to SSE...'); 602 eventSource = new EventSource('/events/updates'); 603 604 eventSource.onopen = () => { 605 console.log('SSE connection established'); 606 document.querySelector('.status-bar').style.borderColor = 'rgba(16, 185, 129, 0.4)'; 607 }; 608 609 eventSource.onmessage = (event) => { 610 console.log('SSE raw event:', event); 611 console.log('SSE event.data:', event.data); 612 try { 613 const data = JSON.parse(event.data); 614 console.log('SSE message received:', data); 615 616 // Check message type 617 if (data.type === 'progress') { 618 console.log('Progress update:', data); 619 updateProgress(data); 620 } else if (data.type === 'complete') { 621 console.log('Progress complete'); 622 hideProgress(); 623 } else if (data.type === 'status') { 624 console.log('Status update:', data); 625 updatePlayerStatus(data); 626 } else if (Array.isArray(data)) { 627 // Leaderboard update 628 console.log('Updating leaderboard with', data.length, 'entries'); 629 updateLeaderboard(data); 630 } else { 631 console.log('Unknown message type:', data); 632 } 633 } catch (error) { 634 console.error('Failed to parse SSE data:', error, 'Raw data:', event.data); 635 } 636 }; 637 638 eventSource.onerror = (error) => { 639 console.error('SSE error, reconnecting...', error); 640 document.querySelector('.status-bar').style.borderColor = 'rgba(239, 68, 68, 0.4)'; 641 eventSource.close(); 642 setTimeout(connectSSE, 5000); 643 }; 644 } 645 646 function updateLeaderboard(entries) { 647 const tbody = document.querySelector('tbody'); 648 if (!tbody) return; 649 650 if (entries.length === 0) { 651 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>'; 652 return; 653 } 654 655 tbody.innerHTML = entries.map((e, i) => { 656 const rank = i + 1; 657 const isPending = e.IsPending || false; 658 const rowClass = isPending ? ' class="pending"' : ''; 659 660 let rankDisplay; 661 if (isPending) { 662 rankDisplay = '⏳'; 663 } else { 664 const medals = ['🥇', '🥈', '🥉']; 665 rankDisplay = medals[i] || rank; 666 } 667 668 const winRate = e.WinPct.toFixed(1); 669 const winRateClass = e.WinPct >= 60 ? 'win-rate-high' : e.WinPct >= 40 ? 'win-rate-med' : 'win-rate-low'; 670 const lastPlayed = isPending ? 'Waiting...' : new Date(e.LastPlayed).toLocaleString('en-US', { 671 month: 'short', 672 day: 'numeric', 673 hour: 'numeric', 674 minute: '2-digit' 675 }); 676 677 const nameDisplay = e.Username + (isPending ? ' <span style="font-size: 0.8em;">(pending)</span>' : ''); 678 const ratingDisplay = isPending ? '-' : '<strong>' + e.Rating + '</strong> <span style="color: #94a3b8; font-size: 0.85em;">±' + e.RD + '</span>'; 679 const winsDisplay = isPending ? '-' : e.Wins.toLocaleString(); 680 const lossesDisplay = isPending ? '-' : e.Losses.toLocaleString(); 681 const winRateDisplay = isPending ? '-' : '<span class="win-rate ' + winRateClass + '">' + winRate + '%</span>'; 682 const avgMovesDisplay = isPending ? '-' : e.AvgMoves.toFixed(1); 683 684 return '<tr' + rowClass + '>' + 685 '<td class="rank rank-' + rank + '">' + rankDisplay + '</td>' + 686 '<td class="player-name"><a href="/user/' + e.Username + '" style="color: inherit; text-decoration: none;">' + nameDisplay + '</a></td>' + 687 '<td>' + ratingDisplay + '</td>' + 688 '<td>' + winsDisplay + '</td>' + 689 '<td>' + lossesDisplay + '</td>' + 690 '<td>' + winRateDisplay + '</td>' + 691 '<td>' + avgMovesDisplay + '</td>' + 692 '<td style="color: #64748b;">' + lastPlayed + '</td>' + 693 '</tr>'; 694 }).join(''); 695 696 // Update stats 697 const statValues = document.querySelectorAll('.stat-value'); 698 statValues[0].textContent = entries.length; 699 const totalGames = entries.reduce((sum, e) => sum + e.Wins + e.Losses, 0); 700 statValues[1].textContent = totalGames.toLocaleString(); 701 } 702 703 function updateProgress(data) { 704 const indicator = document.getElementById('progress-indicator'); 705 706 if (!indicator) { 707 console.error('Progress indicator element not found!'); 708 return; 709 } 710 711 console.log('Updating progress indicator:', data); 712 713 // Show indicator 714 indicator.classList.remove('hidden'); 715 716 // Update content 717 document.getElementById('progress-player').textContent = data.player; 718 document.getElementById('progress-current').textContent = data.current_match; 719 document.getElementById('progress-total').textContent = data.total_matches; 720 document.getElementById('progress-time').textContent = data.estimated_time_left; 721 document.getElementById('progress-bar').style.width = data.percent_complete + '%'; 722 723 // Update queue 724 const queueContainer = document.getElementById('progress-queue-container'); 725 if (data.queued_players && data.queued_players.length > 0) { 726 queueContainer.style.display = 'block'; 727 const queueList = document.getElementById('progress-queue-list'); 728 queueList.innerHTML = data.queued_players.map(p => 729 '<div class="progress-queue-item">⏳ ' + p + '</div>' 730 ).join(''); 731 } else { 732 queueContainer.style.display = 'none'; 733 } 734 } 735 736 function hideProgress() { 737 const indicator = document.getElementById('progress-indicator'); 738 if (indicator) { 739 indicator.classList.add('hidden'); 740 } 741 } 742 743 function updatePlayerStatus(data) { 744 // Find the player's row in the leaderboard 745 const rows = document.querySelectorAll('tbody tr'); 746 for (const row of rows) { 747 const playerLink = row.querySelector('.player-name a'); 748 if (!playerLink) continue; 749 750 const username = playerLink.getAttribute('href').split('/').pop(); 751 if (username === data.player) { 752 const lastPlayedCell = row.cells[7]; // Last cell (Last Active) 753 754 if (data.status === 'compiling') { 755 lastPlayedCell.innerHTML = '<span style="color: #3b82f6;">⚙️ Compiling...</span>'; 756 row.classList.add('pending'); 757 } else if (data.status === 'compilation_failed') { 758 lastPlayedCell.innerHTML = '<span style="color: #ef4444;" title="' + 759 (data.failure_message || 'Compilation failed') + '">❌ Failed</span>'; 760 row.classList.remove('pending'); 761 } else if (data.status === 'running_matches') { 762 lastPlayedCell.innerHTML = '<span style="color: #10b981;">▶️ Running matches...</span>'; 763 row.classList.add('pending'); 764 } else if (data.status === 'completed') { 765 // Will be updated by leaderboard refresh 766 row.classList.remove('pending'); 767 } 768 break; 769 } 770 } 771 } 772 773 window.addEventListener('DOMContentLoaded', () => { 774 connectSSE(); 775 }); 776 777 function copyCode(button, text) { 778 // Decode HTML entities in template variables 779 const tempDiv = document.createElement('div'); 780 tempDiv.innerHTML = text; 781 const decodedText = tempDiv.textContent || tempDiv.innerText; 782 783 navigator.clipboard.writeText(decodedText).then(() => { 784 const originalText = button.textContent; 785 button.textContent = 'Copied!'; 786 button.classList.add('copied'); 787 setTimeout(() => { 788 button.textContent = originalText; 789 button.classList.remove('copied'); 790 }, 2000); 791 }).catch(err => { 792 console.error('Failed to copy:', err); 793 }); 794 } 795 796 function toggleCollapsible() { 797 const content = document.getElementById('collapsible-content'); 798 const arrow = document.getElementById('collapsible-arrow'); 799 800 if (content.classList.contains('open')) { 801 content.classList.remove('open'); 802 arrow.textContent = '▼'; 803 } else { 804 content.classList.add('open'); 805 arrow.textContent = '▲'; 806 } 807 } 808 </script> 809</head> 810<body> 811 <div class="container"> 812 <header> 813 <h1>⚓ BATTLESHIP ARENA</h1> 814 <p class="subtitle">AI Strategy Competition</p> 815 </header> 816 817 <div class="status-bar"> 818 <div class="live-dot"></div> 819 <span>Live Updates</span> 820 </div> 821 822 <div class="stats-grid"> 823 <div class="stat-card"> 824 <div class="stat-value">{{.TotalPlayers}}</div> 825 <div class="stat-label">Active Players</div> 826 </div> 827 <div class="stat-card"> 828 <div class="stat-value">{{.TotalGames}}</div> 829 <div class="stat-label">Games Played</div> 830 </div> 831 </div> 832 833 <div class="leaderboard"> 834 <div class="leaderboard-header"> 835 <h2>🏆 Leaderboard</h2> 836 </div> 837 <table> 838 <thead> 839 <tr> 840 <th>Rank</th> 841 <th>Player</th> 842 <th><span class="tooltip" data-tooltip="Glicko-2 rating: higher is better">Rating</span></th> 843 <th>Wins</th> 844 <th>Losses</th> 845 <th>Win Rate</th> 846 <th><span class="tooltip" data-tooltip="Average moves to win (lower is better)">Avg Moves</span></th> 847 <th>Last Active</th> 848 </tr> 849 </thead> 850 <tbody> 851 {{if .Entries}} 852 {{range $i, $e := .Entries}} 853 <tr{{if $e.IsPending}} class="pending"{{end}}> 854 <td class="rank rank-{{add $i 1}}">{{if $e.IsPending}}{{else if lt $i 3}}{{medal $i}}{{else}}{{add $i 1}}{{end}}</td> 855 <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> 856 <td>{{if $e.IsPending}}-{{else}}<strong>{{$e.Rating}}</strong> <span style="color: #94a3b8; font-size: 0.85em;">±{{$e.RD}}</span>{{end}}</td> 857 <td>{{if $e.IsPending}}-{{else}}{{$e.Wins}}{{end}}</td> 858 <td>{{if $e.IsPending}}-{{else}}{{$e.Losses}}{{end}}</td> 859 <td>{{if $e.IsPending}}-{{else}}<span class="win-rate {{winRateClass $e}}">{{winRate $e}}%</span>{{end}}</td> 860 <td>{{if $e.IsPending}}-{{else}}{{printf "%.1f" $e.AvgMoves}}{{end}}</td> 861 <td style="color: #64748b;">{{if $e.IsPending}}Waiting...{{else}}{{$e.LastPlayed.Format "Jan 2, 3:04 PM"}}{{end}}</td> 862 </tr> 863 {{end}} 864 {{else}} 865 <tr> 866 <td colspan="8"> 867 <div class="empty-state"> 868 <div class="empty-state-icon">🎯</div> 869 <div>No submissions yet. Be the first to compete!</div> 870 </div> 871 </td> 872 </tr> 873 {{end}} 874 </tbody> 875 </table> 876 </div> 877 878 {{if .BrokenEntries}} 879 <div class="collapsible-section"> 880 <div class="collapsible-header" onclick="toggleCollapsible()"> 881 <div class="collapsible-title"> 882 💥 Failed Submissions 883 <span class="collapsible-count">{{len .BrokenEntries}}</span> 884 </div> 885 <span class="collapsible-arrow" id="collapsible-arrow">▼</span> 886 </div> 887 <div class="collapsible-content" id="collapsible-content"> 888 <table> 889 <tbody> 890 {{range $e := .BrokenEntries}} 891 <tr class="broken"> 892 <td class="rank">💥</td> 893 <td class="player-name"><a href="/user/{{$e.Username}}" style="color: inherit; text-decoration: none;">{{$e.Username}}</a></td> 894 <td>-</td> 895 <td>-</td> 896 <td>-</td> 897 <td>-</td> 898 <td>-</td> 899 <td style="color: #64748b;">{{$e.FailureMessage}}</td> 900 </tr> 901 {{end}} 902 </tbody> 903 </table> 904 </div> 905 </div> 906 {{end}} 907 908 <div class="info-card"> 909 <h3>📤 How to Submit</h3> 910 <p><strong>First time?</strong> Connect via SSH to create your account:</p> 911 912 <div class="code-block"> 913 <div class="code-block-header"> 914 <span class="code-block-lang">bash</span> 915 <button class="code-block-copy" onclick="copyCode(this, 'ssh -p 2222 {{.ServerURL}}')">Copy</button> 916 </div> 917 <pre><code><span class="token-command">ssh</span> <span class="token-flag">-p</span> <span class="token-string">2222</span> <span class="token-string">{{.ServerURL}}</span></code></pre> 918 </div> 919 920 <p style="margin-top: 0.5rem; color: #94a3b8;">You'll be prompted for your name, bio, and link. Your SSH key will be registered.</p> 921 922 <p style="margin-top: 1rem;"><strong>Upload your AI:</strong></p> 923 924 <div class="code-block"> 925 <div class="code-block-header"> 926 <span class="code-block-lang">bash</span> 927 <button class="code-block-copy" onclick="copyCode(this, 'scp -P 2222 memory_functions_yourname.cpp {{.ServerURL}}:~/')">Copy</button> 928 </div> 929 <pre><code><span class="token-command">scp</span> <span class="token-flag">-P</span> <span class="token-string">2222</span> <span class="token-string">memory_functions_yourname.cpp</span> <span class="token-string">{{.ServerURL}}:~/</span></code></pre> 930 </div> 931 932 <p style="margin-top: 1.5rem;"><strong>How it works:</strong></p> 933 <ul> 934 <li>Your AI plays 1000 games against each opponent</li> 935 <li>Rankings use Glicko-2 rating system (like chess)</li> 936 <li>Lower average moves = more efficient strategy</li> 937 <li>Live updates as matches complete</li> 938 </ul> 939 940 <p style="margin-top: 1rem; color: #94a3b8;"> 941 <a href="/users" style="color: #60a5fa; text-decoration: none;">View all players →</a> 942 </p> 943 </div> 944 </div> 945 946 <!-- Progress Indicator --> 947 <div id="progress-indicator" class="progress-indicator hidden"> 948 <div class="progress-header"> 949 <div class="progress-spinner"></div> 950 <div class="progress-title">Computing Ratings</div> 951 </div> 952 <div class="progress-player" id="progress-player">-</div> 953 <div class="progress-stats"> 954 Match <span id="progress-current">0</span> of <span id="progress-total">0</span> 955 </div> 956 <div class="progress-bar-container"> 957 <div class="progress-bar" id="progress-bar" style="width: 0%"></div> 958 </div> 959 <div class="progress-time"> 960 Est. <span id="progress-time">-</span> remaining 961 </div> 962 <div id="progress-queue-container" class="progress-queue" style="display: none;"> 963 <div class="progress-queue-title">Queued Players:</div> 964 <div id="progress-queue-list" class="progress-queue-list"></div> 965 </div> 966 </div> 967</body> 968</html> 969` 970 971var tmpl = template.Must(template.New("leaderboard").Funcs(template.FuncMap{ 972 "add": func(a, b int) int { 973 return a + b 974 }, 975 "medal": func(i int) string { 976 medals := []string{"🥇", "🥈", "🥉"} 977 if i < len(medals) { 978 return medals[i] 979 } 980 return "" 981 }, 982 "winRate": func(e storage.LeaderboardEntry) string { 983 return formatFloat(e.WinPct, 1) 984 }, 985 "winRateClass": func(e storage.LeaderboardEntry) string { 986 if e.WinPct >= 60 { 987 return "win-rate-high" 988 } else if e.WinPct >= 40 { 989 return "win-rate-med" 990 } 991 return "win-rate-low" 992 }, 993}).Parse(leaderboardHTML)) 994 995func formatFloat(f float64, decimals int) string { 996 return fmt.Sprintf("%.1f", f) 997} 998 999func HandleLeaderboard(w http.ResponseWriter, r *http.Request) { 1000 entries, err := storage.GetLeaderboard(50) 1001 if err != nil { 1002 http.Error(w, fmt.Sprintf("Failed to load leaderboard: %v", err), http.StatusInternalServerError) 1003 return 1004 } 1005 1006 // Empty leaderboard is fine 1007 if entries == nil { 1008 entries = []storage.LeaderboardEntry{} 1009 } 1010 1011 // Split entries into working and broken 1012 var workingEntries []storage.LeaderboardEntry 1013 var brokenEntries []storage.LeaderboardEntry 1014 for _, e := range entries { 1015 if e.IsBroken { 1016 brokenEntries = append(brokenEntries, e) 1017 } else { 1018 workingEntries = append(workingEntries, e) 1019 } 1020 } 1021 1022 // Get matches for bracket 1023 matches, err := storage.GetAllMatches() 1024 if err != nil { 1025 matches = []storage.MatchResult{} 1026 } 1027 1028 data := struct { 1029 Entries []storage.LeaderboardEntry 1030 BrokenEntries []storage.LeaderboardEntry 1031 Matches []storage.MatchResult 1032 TotalPlayers int 1033 TotalGames int 1034 ServerURL string 1035 }{ 1036 Entries: workingEntries, 1037 BrokenEntries: brokenEntries, 1038 Matches: matches, 1039 TotalPlayers: len(workingEntries), 1040 TotalGames: calculateTotalGames(workingEntries), 1041 ServerURL: GetServerURL(), 1042 } 1043 1044 if err := tmpl.Execute(w, data); err != nil { 1045 http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError) 1046 } 1047} 1048 1049func HandleAPILeaderboard(w http.ResponseWriter, r *http.Request) { 1050 entries, err := storage.GetLeaderboard(50) 1051 if err != nil { 1052 http.Error(w, fmt.Sprintf("Failed to load leaderboard: %v", err), http.StatusInternalServerError) 1053 return 1054 } 1055 1056 // Empty leaderboard is fine 1057 if entries == nil { 1058 entries = []storage.LeaderboardEntry{} 1059 } 1060 1061 w.Header().Set("Content-Type", "application/json") 1062 json.NewEncoder(w).Encode(entries) 1063} 1064 1065func calculateTotalGames(entries []storage.LeaderboardEntry) int { 1066 total := 0 1067 for _, e := range entries { 1068 total += e.Wins + e.Losses 1069 } 1070 return total / 2 // Each game counted twice (win+loss) 1071} 1072 1073func HandleRatingHistory(w http.ResponseWriter, r *http.Request) { 1074 username := chi.URLParam(r, "player") 1075 if username == "" { 1076 http.Error(w, "Username required", http.StatusBadRequest) 1077 return 1078 } 1079 1080 // Get submission ID for this username 1081 var submissionID int 1082 err := storage.DB.QueryRow( 1083 "SELECT id FROM submissions WHERE username = ? AND is_active = 1", 1084 username, 1085 ).Scan(&submissionID) 1086 1087 if err != nil { 1088 http.Error(w, "Player not found", http.StatusNotFound) 1089 return 1090 } 1091 1092 // Get rating history 1093 history, err := storage.GetRatingHistory(submissionID) 1094 if err != nil { 1095 http.Error(w, fmt.Sprintf("Failed to get rating history: %v", err), http.StatusInternalServerError) 1096 return 1097 } 1098 1099 w.Header().Set("Content-Type", "application/json") 1100 json.NewEncoder(w).Encode(history) 1101} 1102 1103func HandlePlayerPage(w http.ResponseWriter, r *http.Request) { 1104 username := chi.URLParam(r, "player") 1105 if username == "" { 1106 http.Redirect(w, r, "/", http.StatusSeeOther) 1107 return 1108 } 1109 1110 tmpl := template.Must(template.New("player").Parse(playerPageHTML)) 1111 tmpl.Execute(w, map[string]string{"Username": username}) 1112} 1113 1114const playerPageHTML = ` 1115<!DOCTYPE html> 1116<html lang="en"> 1117<head> 1118 <title>{{.Username}} - Battleship Arena</title> 1119 <meta charset="UTF-8"> 1120 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 1121 <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚓</text></svg>"> 1122 <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> 1123 <style> 1124 * { 1125 margin: 0; 1126 padding: 0; 1127 box-sizing: border-box; 1128 } 1129 1130 body { 1131 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 1132 background: #0f172a; 1133 color: #e2e8f0; 1134 min-height: 100vh; 1135 padding: 2rem 1rem; 1136 } 1137 1138 .container { 1139 max-width: 1200px; 1140 margin: 0 auto; 1141 } 1142 1143 h1 { 1144 font-size: 2.5rem; 1145 font-weight: 700; 1146 margin-bottom: 0.5rem; 1147 background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%); 1148 -webkit-background-clip: text; 1149 -webkit-text-fill-color: transparent; 1150 } 1151 1152 .back-link { 1153 display: inline-block; 1154 margin-bottom: 2rem; 1155 color: #60a5fa; 1156 text-decoration: none; 1157 font-size: 0.9rem; 1158 } 1159 1160 .back-link:hover { 1161 text-decoration: underline; 1162 } 1163 1164 .stats-grid { 1165 display: grid; 1166 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 1167 gap: 1rem; 1168 margin-bottom: 2rem; 1169 } 1170 1171 .stat-card { 1172 background: #1e293b; 1173 border: 1px solid #334155; 1174 border-radius: 12px; 1175 padding: 1.5rem; 1176 } 1177 1178 .stat-label { 1179 font-size: 0.875rem; 1180 color: #94a3b8; 1181 margin-bottom: 0.5rem; 1182 } 1183 1184 .stat-value { 1185 font-size: 2rem; 1186 font-weight: 700; 1187 color: #60a5fa; 1188 } 1189 1190 .chart-container { 1191 background: #1e293b; 1192 border: 1px solid #334155; 1193 border-radius: 12px; 1194 padding: 2rem; 1195 margin-bottom: 2rem; 1196 } 1197 1198 .chart-title { 1199 font-size: 1.25rem; 1200 font-weight: 600; 1201 margin-bottom: 1.5rem; 1202 color: #e2e8f0; 1203 } 1204 1205 canvas { 1206 max-height: 400px; 1207 } 1208 </style> 1209</head> 1210<body> 1211 <div class="container"> 1212 <a href="/" class="back-link">← Back to Leaderboard</a> 1213 <h1>{{.Username}}</h1> 1214 <p style="color: #94a3b8; margin-bottom: 2rem;">Player Statistics</p> 1215 1216 <div class="stats-grid" id="stats-grid"> 1217 <div class="stat-card"> 1218 <div class="stat-label">Current Rating</div> 1219 <div class="stat-value" id="current-rating">-</div> 1220 </div> 1221 <div class="stat-card"> 1222 <div class="stat-label">Rating Deviation</div> 1223 <div class="stat-value" id="current-rd">-</div> 1224 </div> 1225 <div class="stat-card"> 1226 <div class="stat-label">Win Rate</div> 1227 <div class="stat-value" id="win-rate">-</div> 1228 </div> 1229 <div class="stat-card"> 1230 <div class="stat-label">Total Matches</div> 1231 <div class="stat-value" id="total-matches">-</div> 1232 </div> 1233 </div> 1234 1235 <div class="chart-container"> 1236 <h2 class="chart-title">Rating History</h2> 1237 <canvas id="rating-chart"></canvas> 1238 </div> 1239 1240 <div class="chart-container"> 1241 <h2 class="chart-title">Rating Deviation Over Time</h2> 1242 <canvas id="rd-chart"></canvas> 1243 </div> 1244 </div> 1245 1246 <script> 1247 const username = "{{.Username}}"; 1248 1249 async function loadData() { 1250 try { 1251 // Load rating history 1252 const historyRes = await fetch('/api/rating-history/' + username); 1253 const history = await historyRes.json(); 1254 1255 // Load current stats from leaderboard 1256 const leaderboardRes = await fetch('/api/leaderboard'); 1257 const leaderboard = await leaderboardRes.json(); 1258 const player = leaderboard.find(p => p.Username === username); 1259 1260 if (player) { 1261 document.getElementById('current-rating').textContent = player.Rating + ' ±' + player.RD; 1262 document.getElementById('current-rd').textContent = player.RD; 1263 document.getElementById('win-rate').textContent = player.WinPct.toFixed(1) + '%'; 1264 const total = player.Wins + player.Losses; 1265 document.getElementById('total-matches').textContent = Math.floor(total / 1000); 1266 } 1267 1268 // Create rating chart 1269 const ratingCtx = document.getElementById('rating-chart').getContext('2d'); 1270 new Chart(ratingCtx, { 1271 type: 'line', 1272 data: { 1273 labels: history.map((h, i) => 'Match ' + (i + 1)), 1274 datasets: [{ 1275 label: 'Rating', 1276 data: history.map(h => h.Rating), 1277 borderColor: '#60a5fa', 1278 backgroundColor: 'rgba(96, 165, 250, 0.1)', 1279 tension: 0.1, 1280 fill: true 1281 }] 1282 }, 1283 options: { 1284 responsive: true, 1285 maintainAspectRatio: true, 1286 plugins: { 1287 legend: { 1288 display: false 1289 } 1290 }, 1291 scales: { 1292 y: { 1293 beginAtZero: false, 1294 grid: { 1295 color: '#334155' 1296 }, 1297 ticks: { 1298 color: '#94a3b8' 1299 } 1300 }, 1301 x: { 1302 grid: { 1303 color: '#334155' 1304 }, 1305 ticks: { 1306 color: '#94a3b8', 1307 maxTicksLimit: 10 1308 } 1309 } 1310 } 1311 } 1312 }); 1313 1314 // Create RD chart 1315 const rdCtx = document.getElementById('rd-chart').getContext('2d'); 1316 new Chart(rdCtx, { 1317 type: 'line', 1318 data: { 1319 labels: history.map((h, i) => 'Match ' + (i + 1)), 1320 datasets: [{ 1321 label: 'Rating Deviation', 1322 data: history.map(h => h.RD), 1323 borderColor: '#a78bfa', 1324 backgroundColor: 'rgba(167, 139, 250, 0.1)', 1325 tension: 0.1, 1326 fill: true 1327 }] 1328 }, 1329 options: { 1330 responsive: true, 1331 maintainAspectRatio: true, 1332 plugins: { 1333 legend: { 1334 display: false 1335 } 1336 }, 1337 scales: { 1338 y: { 1339 beginAtZero: false, 1340 grid: { 1341 color: '#334155' 1342 }, 1343 ticks: { 1344 color: '#94a3b8' 1345 } 1346 }, 1347 x: { 1348 grid: { 1349 color: '#334155' 1350 }, 1351 ticks: { 1352 color: '#94a3b8', 1353 maxTicksLimit: 10 1354 } 1355 } 1356 } 1357 } 1358 }); 1359 1360 } catch (err) { 1361 console.error('Failed to load data:', err); 1362 } 1363 } 1364 1365 loadData(); 1366 </script> 1367</body> 1368</html> 1369`