a geicko-2 based round robin ranking system designed to test c++ battleship submissions
battleship.dunkirk.sh
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "html/template"
7 "net/http"
8)
9
10const leaderboardHTML = `
11<!DOCTYPE html>
12<html lang="en">
13<head>
14 <title>Battleship Arena</title>
15 <meta charset="UTF-8">
16 <meta name="viewport" content="width=device-width, initial-scale=1.0">
17 <style>
18 * {
19 margin: 0;
20 padding: 0;
21 box-sizing: border-box;
22 }
23
24 body {
25 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
26 background: #0f172a;
27 color: #e2e8f0;
28 min-height: 100vh;
29 padding: 2rem 1rem;
30 }
31
32 .container {
33 max-width: 1400px;
34 margin: 0 auto;
35 }
36
37 header {
38 text-align: center;
39 margin-bottom: 3rem;
40 }
41
42 h1 {
43 font-size: 3rem;
44 font-weight: 800;
45 background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100%);
46 -webkit-background-clip: text;
47 -webkit-text-fill-color: transparent;
48 background-clip: text;
49 margin-bottom: 0.5rem;
50 }
51
52 .subtitle {
53 font-size: 1.125rem;
54 color: #94a3b8;
55 }
56
57 .status-bar {
58 display: flex;
59 align-items: center;
60 justify-content: center;
61 gap: 0.5rem;
62 margin: 1.5rem 0;
63 padding: 0.75rem;
64 background: rgba(16, 185, 129, 0.1);
65 border: 1px solid rgba(16, 185, 129, 0.2);
66 border-radius: 0.5rem;
67 font-size: 0.875rem;
68 color: #10b981;
69 }
70
71 .live-dot {
72 width: 8px;
73 height: 8px;
74 background: #10b981;
75 border-radius: 50%;
76 animation: pulse 2s ease-in-out infinite;
77 }
78
79 @keyframes pulse {
80 0%, 100% { opacity: 1; transform: scale(1); }
81 50% { opacity: 0.5; transform: scale(1.1); }
82 }
83
84 .stats-grid {
85 display: grid;
86 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
87 gap: 1.5rem;
88 margin-bottom: 2rem;
89 }
90
91 .stat-card {
92 background: #1e293b;
93 border: 1px solid #334155;
94 border-radius: 0.75rem;
95 padding: 1.5rem;
96 text-align: center;
97 }
98
99 .stat-value {
100 font-size: 2.5rem;
101 font-weight: 700;
102 background: linear-gradient(135deg, #3b82f6, #8b5cf6);
103 -webkit-background-clip: text;
104 -webkit-text-fill-color: transparent;
105 background-clip: text;
106 }
107
108 .stat-label {
109 font-size: 0.875rem;
110 color: #94a3b8;
111 margin-top: 0.5rem;
112 text-transform: uppercase;
113 letter-spacing: 0.05em;
114 }
115
116 .leaderboard {
117 background: #1e293b;
118 border: 1px solid #334155;
119 border-radius: 0.75rem;
120 overflow: hidden;
121 margin-bottom: 2rem;
122 }
123
124 .leaderboard-header {
125 padding: 1.5rem;
126 background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
127 border-bottom: 1px solid #334155;
128 }
129
130 .leaderboard-header h2 {
131 font-size: 1.5rem;
132 font-weight: 700;
133 }
134
135 table {
136 width: 100%;
137 border-collapse: collapse;
138 }
139
140 thead {
141 background: #0f172a;
142 }
143
144 th {
145 padding: 1rem 1.5rem;
146 text-align: left;
147 font-size: 0.75rem;
148 font-weight: 600;
149 color: #94a3b8;
150 text-transform: uppercase;
151 letter-spacing: 0.05em;
152 }
153
154 th:first-child { width: 80px; }
155 th:nth-child(3) { width: 90px; } /* ELO */
156 th:nth-child(4), th:nth-child(5) { width: 100px; } /* Wins, Losses */
157 th:nth-child(6) { width: 120px; } /* Win Rate */
158 th:nth-child(7) { width: 120px; } /* Avg Moves */
159 th:last-child { width: 150px; } /* Last Active */
160
161 tbody tr {
162 border-bottom: 1px solid #334155;
163 transition: background 0.2s;
164 }
165
166 tbody tr:hover {
167 background: rgba(59, 130, 246, 0.05);
168 }
169
170 tbody tr:last-child {
171 border-bottom: none;
172 }
173
174 td {
175 padding: 1.25rem 1.5rem;
176 font-size: 0.9375rem;
177 }
178
179 .rank {
180 font-size: 1.25rem;
181 font-weight: 700;
182 }
183
184 .rank-1 { color: #fbbf24; }
185 .rank-2 { color: #d1d5db; }
186 .rank-3 { color: #f59e0b; }
187
188 .player-name {
189 font-weight: 600;
190 color: #e2e8f0;
191 }
192
193 .win-rate {
194 font-weight: 600;
195 padding: 0.25rem 0.75rem;
196 border-radius: 0.375rem;
197 display: inline-block;
198 }
199
200 .win-rate-high {
201 background: rgba(16, 185, 129, 0.1);
202 color: #10b981;
203 }
204
205 .win-rate-med {
206 background: rgba(245, 158, 11, 0.1);
207 color: #f59e0b;
208 }
209
210 .win-rate-low {
211 background: rgba(239, 68, 68, 0.1);
212 color: #ef4444;
213 }
214
215 .info-card {
216 background: #1e293b;
217 border: 1px solid #334155;
218 border-radius: 0.75rem;
219 padding: 2rem;
220 }
221
222 .info-card h3 {
223 font-size: 1.25rem;
224 margin-bottom: 1rem;
225 color: #e2e8f0;
226 }
227
228 .info-card p {
229 color: #94a3b8;
230 line-height: 1.6;
231 margin-bottom: 0.75rem;
232 }
233
234 code {
235 background: #0f172a;
236 padding: 0.375rem 0.75rem;
237 border-radius: 0.375rem;
238 font-family: 'Monaco', 'Courier New', monospace;
239 font-size: 0.875rem;
240 color: #3b82f6;
241 }
242
243 .empty-state {
244 text-align: center;
245 padding: 4rem 2rem;
246 color: #64748b;
247 }
248
249 .empty-state-icon {
250 font-size: 3rem;
251 margin-bottom: 1rem;
252 }
253
254 @media (max-width: 768px) {
255 h1 { font-size: 2rem; }
256 .subtitle { font-size: 1rem; }
257 th, td { padding: 0.75rem 1rem; font-size: 0.875rem; }
258 .stat-value { font-size: 2rem; }
259 }
260 </style>
261 <script>
262 let eventSource;
263
264 function connectSSE() {
265 console.log('Connecting to SSE...');
266 eventSource = new EventSource('http://localhost:8081');
267
268 eventSource.onopen = () => {
269 console.log('SSE connection established');
270 document.querySelector('.status-bar').style.borderColor = 'rgba(16, 185, 129, 0.4)';
271 };
272
273 eventSource.onmessage = (event) => {
274 try {
275 const entries = JSON.parse(event.data);
276 console.log('Updating leaderboard with', entries.length, 'entries');
277 updateLeaderboard(entries);
278 } catch (error) {
279 console.error('Failed to parse SSE data:', error);
280 }
281 };
282
283 eventSource.onerror = (error) => {
284 console.error('SSE error, reconnecting...', error);
285 document.querySelector('.status-bar').style.borderColor = 'rgba(239, 68, 68, 0.4)';
286 eventSource.close();
287 setTimeout(connectSSE, 5000);
288 };
289 }
290
291 function updateLeaderboard(entries) {
292 const tbody = document.querySelector('tbody');
293 if (!tbody) return;
294
295 if (entries.length === 0) {
296 tbody.innerHTML = '<tr><td colspan="8"><div class="empty-state"><div class="empty-state-icon">🎯</div><div>No submissions yet. Be the first to compete!</div></div></td></tr>';
297 return;
298 }
299
300 tbody.innerHTML = entries.map((e, i) => {
301 const rank = i + 1;
302 const winRate = e.WinPct.toFixed(1);
303 const winRateClass = e.WinPct >= 60 ? 'win-rate-high' : e.WinPct >= 40 ? 'win-rate-med' : 'win-rate-low';
304 const medals = ['🥇', '🥈', '🥉'];
305 const medal = medals[i] || rank;
306 const lastPlayed = new Date(e.LastPlayed).toLocaleString('en-US', {
307 month: 'short',
308 day: 'numeric',
309 hour: 'numeric',
310 minute: '2-digit'
311 });
312
313 return '<tr>' +
314 '<td class="rank rank-' + rank + '">' + medal + '</td>' +
315 '<td class="player-name">' + e.Username + '</td>' +
316 '<td><strong>' + e.Rating + '</strong> <span style="color: #94a3b8; font-size: 0.85em;">±' + e.RD + '</span></td>' +
317 '<td>' + e.Wins.toLocaleString() + '</td>' +
318 '<td>' + e.Losses.toLocaleString() + '</td>' +
319 '<td><span class="win-rate ' + winRateClass + '">' + winRate + '%</span></td>' +
320 '<td>' + e.AvgMoves.toFixed(1) + '</td>' +
321 '<td style="color: #64748b;">' + lastPlayed + '</td>' +
322 '</tr>';
323 }).join('');
324
325 // Update stats
326 const statValues = document.querySelectorAll('.stat-value');
327 statValues[0].textContent = entries.length;
328 const totalGames = entries.reduce((sum, e) => sum + e.Wins + e.Losses, 0);
329 statValues[1].textContent = totalGames.toLocaleString();
330 }
331
332 window.addEventListener('DOMContentLoaded', () => {
333 connectSSE();
334 });
335 </script>
336</head>
337<body>
338 <div class="container">
339 <header>
340 <h1>⚓ BATTLESHIP ARENA</h1>
341 <p class="subtitle">AI Strategy Competition</p>
342 </header>
343
344 <div class="status-bar">
345 <div class="live-dot"></div>
346 <span>Live Updates</span>
347 </div>
348
349 <div class="stats-grid">
350 <div class="stat-card">
351 <div class="stat-value">{{.TotalPlayers}}</div>
352 <div class="stat-label">Active Players</div>
353 </div>
354 <div class="stat-card">
355 <div class="stat-value">{{.TotalGames}}</div>
356 <div class="stat-label">Games Played</div>
357 </div>
358 </div>
359
360 <div class="leaderboard">
361 <div class="leaderboard-header">
362 <h2>🏆 Leaderboard</h2>
363 </div>
364 <table>
365 <thead>
366 <tr>
367 <th>Rank</th>
368 <th>Player</th>
369 <th>Rating</th>
370 <th>Wins</th>
371 <th>Losses</th>
372 <th>Win Rate</th>
373 <th>Avg Moves</th>
374 <th>Last Active</th>
375 </tr>
376 </thead>
377 <tbody>
378 {{if .Entries}}
379 {{range $i, $e := .Entries}}
380 <tr>
381 <td class="rank rank-{{add $i 1}}">{{if lt $i 3}}{{medal $i}}{{else}}{{add $i 1}}{{end}}</td>
382 <td class="player-name">{{$e.Username}}</td>
383 <td><strong>{{$e.Rating}}</strong> <span style="color: #94a3b8; font-size: 0.85em;">±{{$e.RD}}</span></td>
384 <td>{{$e.Wins}}</td>
385 <td>{{$e.Losses}}</td>
386 <td><span class="win-rate {{winRateClass $e}}">{{winRate $e}}%</span></td>
387 <td>{{printf "%.1f" $e.AvgMoves}}</td>
388 <td style="color: #64748b;">{{$e.LastPlayed.Format "Jan 2, 3:04 PM"}}</td>
389 </tr>
390 {{end}}
391 {{else}}
392 <tr>
393 <td colspan="8">
394 <div class="empty-state">
395 <div class="empty-state-icon">🎯</div>
396 <div>No submissions yet. Be the first to compete!</div>
397 </div>
398 </td>
399 </tr>
400 {{end}}
401 </tbody>
402 </table>
403 </div>
404
405 <div class="info-card">
406 <h3>📤 How to Submit</h3>
407 <p>Connect via SSH to submit your battleship AI:</p>
408 <p><code>ssh -p 2222 username@localhost</code></p>
409 <p style="margin-top: 1rem;">Upload your <code>memory_functions_*.cpp</code> file and compete in the arena!</p>
410 </div>
411 </div>
412</body>
413</html>
414`
415
416var tmpl = template.Must(template.New("leaderboard").Funcs(template.FuncMap{
417 "add": func(a, b int) int {
418 return a + b
419 },
420 "medal": func(i int) string {
421 medals := []string{"🥇", "🥈", "🥉"}
422 if i < len(medals) {
423 return medals[i]
424 }
425 return ""
426 },
427 "winRate": func(e LeaderboardEntry) string {
428 return formatFloat(e.WinPct, 1)
429 },
430 "winRateClass": func(e LeaderboardEntry) string {
431 if e.WinPct >= 60 {
432 return "win-rate-high"
433 } else if e.WinPct >= 40 {
434 return "win-rate-med"
435 }
436 return "win-rate-low"
437 },
438}).Parse(leaderboardHTML))
439
440func formatFloat(f float64, decimals int) string {
441 return fmt.Sprintf("%.1f", f)
442}
443
444func handleLeaderboard(w http.ResponseWriter, r *http.Request) {
445 entries, err := getLeaderboard(50)
446 if err != nil {
447 http.Error(w, fmt.Sprintf("Failed to load leaderboard: %v", err), http.StatusInternalServerError)
448 return
449 }
450
451 // Empty leaderboard is fine
452 if entries == nil {
453 entries = []LeaderboardEntry{}
454 }
455
456 // Get matches for bracket
457 matches, err := getAllMatches()
458 if err != nil {
459 matches = []MatchResult{}
460 }
461
462 data := struct {
463 Entries []LeaderboardEntry
464 Matches []MatchResult
465 TotalPlayers int
466 TotalGames int
467 }{
468 Entries: entries,
469 Matches: matches,
470 TotalPlayers: len(entries),
471 TotalGames: calculateTotalGames(entries),
472 }
473
474 if err := tmpl.Execute(w, data); err != nil {
475 http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
476 }
477}
478
479func handleAPILeaderboard(w http.ResponseWriter, r *http.Request) {
480 entries, err := getLeaderboard(50)
481 if err != nil {
482 http.Error(w, fmt.Sprintf("Failed to load leaderboard: %v", err), http.StatusInternalServerError)
483 return
484 }
485
486 // Empty leaderboard is fine
487 if entries == nil {
488 entries = []LeaderboardEntry{}
489 }
490
491 w.Header().Set("Content-Type", "application/json")
492 json.NewEncoder(w).Encode(entries)
493}
494
495
496
497func calculateTotalGames(entries []LeaderboardEntry) int {
498 total := 0
499 for _, e := range entries {
500 total += e.Wins + e.Losses
501 }
502 return total / 2 // Each game counted twice (win+loss)
503}