a geicko-2 based round robin ranking system designed to test c++ battleship submissions
battleship.dunkirk.sh
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`