a geicko-2 based round robin ranking system designed to test c++ battleship submissions
battleship.dunkirk.sh
1package server
2
3import (
4 "fmt"
5 "html/template"
6 "log"
7 "net/http"
8 "strings"
9
10 "github.com/go-chi/chi/v5"
11 gossh "golang.org/x/crypto/ssh"
12
13 "battleship-arena/internal/storage"
14)
15
16func HandleUserProfile(w http.ResponseWriter, r *http.Request) {
17 username := chi.URLParam(r, "username")
18 if username == "" {
19 http.Redirect(w, r, "/", http.StatusSeeOther)
20 return
21 }
22
23 user, err := storage.GetUserByUsername(username)
24 if err != nil {
25 http.Error(w, "Error loading user", http.StatusInternalServerError)
26 return
27 }
28 if user == nil {
29 http.Error(w, "User not found", http.StatusNotFound)
30 return
31 }
32
33 // Get user's submission stats
34 entries, _ := storage.GetLeaderboard(100)
35 var userEntry *storage.LeaderboardEntry
36 for _, e := range entries {
37 if e.Username == username {
38 userEntry = &e
39 break
40 }
41 }
42
43 // Get user's submissions with stats
44 submissions, err := storage.GetUserSubmissionsWithStats(username)
45 if err != nil {
46 log.Printf("Error getting submissions for %s: %v", username, err)
47 submissions = []storage.SubmissionWithStats{}
48 }
49 if submissions == nil {
50 submissions = []storage.SubmissionWithStats{}
51 }
52 log.Printf("Found %d submissions for %s", len(submissions), username)
53
54 // Parse public key for display
55 publicKeyDisplay := formatPublicKey(user.PublicKey)
56
57 tmpl := template.Must(template.New("user").Parse(userProfileHTML))
58 data := struct {
59 User *storage.User
60 Entry *storage.LeaderboardEntry
61 Submissions []storage.SubmissionWithStats
62 PublicKeyDisplay string
63 }{
64 User: user,
65 Entry: userEntry,
66 Submissions: submissions,
67 PublicKeyDisplay: publicKeyDisplay,
68 }
69 tmpl.Execute(w, data)
70}
71
72func HandleUsers(w http.ResponseWriter, r *http.Request) {
73 users, err := storage.GetAllUsers()
74 if err != nil {
75 http.Error(w, "Error loading users", http.StatusInternalServerError)
76 return
77 }
78
79 tmpl := template.Must(template.New("users").Parse(usersListHTML))
80 tmpl.Execute(w, users)
81}
82
83func formatPublicKey(key string) string {
84 key = strings.TrimSpace(key)
85 parts := strings.Fields(key)
86 if len(parts) < 2 {
87 return key
88 }
89
90 // Parse the key to get fingerprint
91 pubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(key))
92 if err != nil {
93 return key
94 }
95
96 fingerprint := gossh.FingerprintSHA256(pubKey)
97 return fmt.Sprintf("%s %s", parts[0], fingerprint)
98}
99
100const userProfileHTML = `
101<!DOCTYPE html>
102<html lang="en">
103<head>
104 <title>{{.User.Name}} (@{{.User.Username}}) - Battleship Arena</title>
105 <meta charset="UTF-8">
106 <meta name="viewport" content="width=device-width, initial-scale=1.0">
107 <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>">
108 <style>
109 * {
110 margin: 0;
111 padding: 0;
112 box-sizing: border-box;
113 }
114
115 body {
116 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
117 background: #0f172a;
118 color: #e2e8f0;
119 min-height: 100vh;
120 padding: 2rem 1rem;
121 }
122
123 .container {
124 max-width: 900px;
125 margin: 0 auto;
126 }
127
128 .back-link {
129 display: inline-block;
130 margin-bottom: 2rem;
131 color: #60a5fa;
132 text-decoration: none;
133 font-size: 0.9rem;
134 }
135
136 .back-link:hover {
137 text-decoration: underline;
138 }
139
140 .profile-header {
141 background: #1e293b;
142 border: 1px solid #334155;
143 border-radius: 12px;
144 padding: 2rem;
145 margin-bottom: 2rem;
146 }
147
148 .username {
149 font-size: 2rem;
150 font-weight: 700;
151 color: #e2e8f0;
152 margin-bottom: 0.5rem;
153 }
154
155 .handle {
156 font-size: 1.2rem;
157 color: #94a3b8;
158 margin-bottom: 1rem;
159 }
160
161 .bio {
162 color: #cbd5e1;
163 margin-bottom: 1rem;
164 line-height: 1.6;
165 }
166
167 .link {
168 color: #60a5fa;
169 text-decoration: none;
170 }
171
172 .link:hover {
173 text-decoration: underline;
174 }
175
176 .stats-grid {
177 display: grid;
178 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
179 gap: 1rem;
180 margin-bottom: 2rem;
181 }
182
183 .stat-card {
184 background: #1e293b;
185 border: 1px solid #334155;
186 border-radius: 12px;
187 padding: 1.5rem;
188 }
189
190 .stat-label {
191 font-size: 0.875rem;
192 color: #94a3b8;
193 margin-bottom: 0.5rem;
194 }
195
196 .stat-value {
197 font-size: 2rem;
198 font-weight: 700;
199 color: #60a5fa;
200 }
201
202 .key-section {
203 background: #1e293b;
204 border: 1px solid #334155;
205 border-radius: 12px;
206 padding: 2rem;
207 }
208
209 .section-title {
210 font-size: 1.25rem;
211 font-weight: 600;
212 margin-bottom: 1rem;
213 color: #e2e8f0;
214 }
215
216 .key-display {
217 background: #0f172a;
218 padding: 1rem;
219 border-radius: 8px;
220 font-family: 'Monaco', 'Courier New', monospace;
221 font-size: 0.875rem;
222 color: #94a3b8;
223 word-break: break-all;
224 }
225
226 .metadata {
227 display: grid;
228 grid-template-columns: repeat(2, 1fr);
229 gap: 1rem;
230 margin-top: 1rem;
231 font-size: 0.875rem;
232 color: #64748b;
233 }
234 </style>
235</head>
236<body>
237 <div class="container">
238 <a href="/" class="back-link">← Back to Leaderboard</a>
239
240 <div class="profile-header">
241 <div class="username">{{.User.Name}}</div>
242 <div class="handle">@{{.User.Username}}</div>
243 {{if .User.Bio}}
244 <div class="bio">{{.User.Bio}}</div>
245 {{end}}
246 {{if .User.Link}}
247 <a href="{{.User.Link}}" class="link" target="_blank">🔗 {{.User.Link}}</a>
248 {{end}}
249 </div>
250
251 {{if .Entry}}
252 <div class="stats-grid">
253 <div class="stat-card">
254 <div class="stat-label">Rating</div>
255 <div class="stat-value">{{.Entry.Rating}}</div>
256 </div>
257 <div class="stat-card">
258 <div class="stat-label">Wins</div>
259 <div class="stat-value">{{.Entry.Wins}}</div>
260 </div>
261 <div class="stat-card">
262 <div class="stat-label">Losses</div>
263 <div class="stat-value">{{.Entry.Losses}}</div>
264 </div>
265 <div class="stat-card">
266 <div class="stat-label">Win Rate</div>
267 <div class="stat-value">{{printf "%.1f" .Entry.WinPct}}%</div>
268 </div>
269 </div>
270 {{end}}
271
272 {{if .Submissions}}
273 <div class="key-section" style="margin-bottom: 2rem;">
274 <h2 class="section-title">📤 Submissions</h2>
275 <div style="overflow-x: auto;">
276 <table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
277 <thead>
278 <tr style="border-bottom: 1px solid #334155;">
279 <th style="text-align: left; padding: 0.75rem 0.5rem; color: #94a3b8;">Filename</th>
280 <th style="text-align: left; padding: 0.75rem 0.5rem; color: #94a3b8;">Uploaded</th>
281 <th style="text-align: center; padding: 0.75rem 0.5rem; color: #94a3b8;">Rating</th>
282 <th style="text-align: center; padding: 0.75rem 0.5rem; color: #94a3b8;">Wins</th>
283 <th style="text-align: center; padding: 0.75rem 0.5rem; color: #94a3b8;">Losses</th>
284 <th style="text-align: center; padding: 0.75rem 0.5rem; color: #94a3b8;">Win Rate</th>
285 <th style="text-align: center; padding: 0.75rem 0.5rem; color: #94a3b8;">Avg Moves</th>
286 <th style="text-align: center; padding: 0.75rem 0.5rem; color: #94a3b8;">Status</th>
287 <th style="text-align: center; padding: 0.75rem 0.5rem; color: #94a3b8;">Active</th>
288 </tr>
289 </thead>
290 <tbody>
291 {{range .Submissions}}
292 <tr style="border-bottom: 1px solid #334155;">
293 <td style="padding: 0.75rem 0.5rem; font-family: Monaco, monospace;">{{.Filename}}</td>
294 <td style="padding: 0.75rem 0.5rem; color: #94a3b8;">{{.UploadTime.Format "Jan 2, 3:04 PM"}}</td>
295 <td style="padding: 0.75rem 0.5rem; text-align: center;">
296 {{if .HasMatches}}{{.Rating}} <span style="color: #94a3b8; font-size: 0.8em;">±{{.RD}}</span>{{else}}-{{end}}
297 </td>
298 <td style="padding: 0.75rem 0.5rem; text-align: center;">{{if .HasMatches}}{{.Wins}}{{else}}-{{end}}</td>
299 <td style="padding: 0.75rem 0.5rem; text-align: center;">{{if .HasMatches}}{{.Losses}}{{else}}-{{end}}</td>
300 <td style="padding: 0.75rem 0.5rem; text-align: center;">
301 {{if .HasMatches}}
302 {{if ge .WinPct 60.0}}<span style="color: #10b981;">{{printf "%.1f" .WinPct}}%</span>{{end}}
303 {{if and (lt .WinPct 60.0) (ge .WinPct 40.0)}}<span style="color: #f59e0b;">{{printf "%.1f" .WinPct}}%</span>{{end}}
304 {{if lt .WinPct 40.0}}<span style="color: #ef4444;">{{printf "%.1f" .WinPct}}%</span>{{end}}
305 {{else}}-{{end}}
306 </td>
307 <td style="padding: 0.75rem 0.5rem; text-align: center;">{{if .HasMatches}}{{printf "%.1f" .AvgMoves}}{{else}}-{{end}}</td>
308 <td style="padding: 0.75rem 0.5rem; text-align: center;">
309 {{if eq .Status "completed"}}<span style="color: #10b981;">✓</span>{{end}}
310 {{if eq .Status "pending"}}<span style="color: #fbbf24;">⏳</span>{{end}}
311 {{if eq .Status "testing"}}<span style="color: #3b82f6;">⚙️</span>{{end}}
312 {{if eq .Status "failed"}}<span style="color: #ef4444;">✗</span>{{end}}
313 </td>
314 <td style="padding: 0.75rem 0.5rem; text-align: center;">
315 {{if .IsActive}}<span style="color: #10b981;">●</span>{{else}}<span style="color: #64748b;">○</span>{{end}}
316 </td>
317 </tr>
318 {{end}}
319 </tbody>
320 </table>
321 </div>
322 </div>
323 {{end}}
324
325 <div class="key-section">
326 <h2 class="section-title">SSH Public Key</h2>
327 <div class="key-display">{{.PublicKeyDisplay}}</div>
328 <div class="metadata">
329 <div>Member since: {{.User.CreatedAt.Format "Jan 2, 2006"}}</div>
330 <div>Last login: {{.User.LastLoginAt.Format "Jan 2, 3:04 PM"}}</div>
331 </div>
332 </div>
333 </div>
334</body>
335</html>
336`
337
338const usersListHTML = `
339<!DOCTYPE html>
340<html lang="en">
341<head>
342 <title>Users - Battleship Arena</title>
343 <meta charset="UTF-8">
344 <meta name="viewport" content="width=device-width, initial-scale=1.0">
345 <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>">
346 <style>
347 * {
348 margin: 0;
349 padding: 0;
350 box-sizing: border-box;
351 }
352
353 body {
354 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
355 background: #0f172a;
356 color: #e2e8f0;
357 min-height: 100vh;
358 padding: 2rem 1rem;
359 }
360
361 .container {
362 max-width: 1200px;
363 margin: 0 auto;
364 }
365
366 h1 {
367 font-size: 2.5rem;
368 font-weight: 700;
369 margin-bottom: 0.5rem;
370 background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
371 -webkit-background-clip: text;
372 -webkit-text-fill-color: transparent;
373 }
374
375 .back-link {
376 display: inline-block;
377 margin-bottom: 2rem;
378 color: #60a5fa;
379 text-decoration: none;
380 font-size: 0.9rem;
381 }
382
383 .back-link:hover {
384 text-decoration: underline;
385 }
386
387 .users-grid {
388 display: grid;
389 grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
390 gap: 1.5rem;
391 }
392
393 .user-card {
394 background: #1e293b;
395 border: 1px solid #334155;
396 border-radius: 12px;
397 padding: 1.5rem;
398 transition: transform 0.2s, border-color 0.2s;
399 text-decoration: none;
400 color: inherit;
401 display: block;
402 }
403
404 .user-card:hover {
405 transform: translateY(-2px);
406 border-color: #60a5fa;
407 }
408
409 .user-name {
410 font-size: 1.25rem;
411 font-weight: 600;
412 color: #e2e8f0;
413 margin-bottom: 0.25rem;
414 }
415
416 .user-handle {
417 font-size: 0.9rem;
418 color: #94a3b8;
419 margin-bottom: 0.75rem;
420 }
421
422 .user-bio {
423 font-size: 0.875rem;
424 color: #cbd5e1;
425 line-height: 1.5;
426 }
427 </style>
428</head>
429<body>
430 <div class="container">
431 <a href="/" class="back-link">← Back to Leaderboard</a>
432 <h1>Players</h1>
433 <p style="color: #94a3b8; margin-bottom: 2rem;">{{len .}} registered users</p>
434
435 <div class="users-grid">
436 {{range .}}
437 <a href="/user/{{.Username}}" class="user-card">
438 <div class="user-name">{{.Name}}</div>
439 <div class="user-handle">@{{.Username}}</div>
440 {{if .Bio}}
441 <div class="user-bio">{{.Bio}}</div>
442 {{end}}
443 </a>
444 {{end}}
445 </div>
446 </div>
447</body>
448</html>
449`