Mayug Maniparambil Claude Opus 4.7 (1M context) commited on
Commit
c52f47a
·
1 Parent(s): 4d7b4ed

Add admin leaderboard, LLM comparison, random puzzles, clearer rules

Browse files

Frontend
- Random puzzle assignment by default on home page; manual picker hidden
behind ?dev=1 (sessionStorage-persisted)
- Per-family tutorial video links on home + play (puzzles.ts is single
source of truth, replaces local PUZZLE_OPTIONS / PUZZLE_HELP maps)
- Rewrote per-family rules (3-4 sentences each) matching the actual
editor cycle behavior; sidebar now shows a How to play box
- Removed duplicate reference PNG + ASCII preview from play sidebar
- Added a Use 'test' for practice hint under the player name input
- Wrong-submit feedback moved to a colored banner directly under the
editor; collapsed to a single 'Not solved yet' message regardless of
invalid vs valid-but-wrong (strict LLM parity)
- New done page: per-puzzle LLM comparison panel (solved/failed columns)
- Admin auth: sessionStorage-backed useAdmin hook, top-right unlock
button, AdminButton + Leaderboard components that auto-mount on play +
done pages when admin is unlocked

Backend
- POST /api/sessions now accepts puzzle_id=null and picks server-side,
excluding puzzles already solved by this player_name_norm (falls back
to full pool when exhausted)
- New submission_attempts table + per-attempt INSERT so every wrong
submission is logged with board content and timing (sessions row keeps
the rolled-up summary)
- New endpoints behind admin Bearer auth:
- GET /api/admin/check (token validator)
- GET /api/admin/leaderboard/{puzzle_id} (per-puzzle leaderboard,
test filter, sorted solved-by-time then unsolved-by-attempts)
- New GET /api/llm-results/{puzzle_id} reading from
space_app/llm_results.json (ASCII-variant verdicts for 9 models x 900
puzzles, sourced from multimodal_cot/evals)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

frontend/src/App.tsx CHANGED
@@ -1,5 +1,6 @@
1
  import { BrowserRouter, Route, Routes } from "react-router-dom";
2
 
 
3
  import { DonePage } from "./routes/DonePage";
4
  import { HomePage } from "./routes/HomePage";
5
  import { PlayPage } from "./routes/PlayPage";
@@ -7,6 +8,7 @@ import { PlayPage } from "./routes/PlayPage";
7
  export default function App() {
8
  return (
9
  <BrowserRouter>
 
10
  <Routes>
11
  <Route path="/" element={<HomePage />} />
12
  <Route path="/play/:sessionId" element={<PlayPage />} />
 
1
  import { BrowserRouter, Route, Routes } from "react-router-dom";
2
 
3
+ import { AdminButton } from "./components/AdminButton";
4
  import { DonePage } from "./routes/DonePage";
5
  import { HomePage } from "./routes/HomePage";
6
  import { PlayPage } from "./routes/PlayPage";
 
8
  export default function App() {
9
  return (
10
  <BrowserRouter>
11
+ <AdminButton />
12
  <Routes>
13
  <Route path="/" element={<HomePage />} />
14
  <Route path="/play/:sessionId" element={<PlayPage />} />
frontend/src/components/AdminButton.tsx ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import type { FormEvent } from "react";
3
+
4
+ import { lockAdmin, unlockAdmin, useAdmin } from "../lib/admin";
5
+
6
+ export function AdminButton() {
7
+ const { enabled } = useAdmin();
8
+ const [open, setOpen] = useState(false);
9
+ const [token, setToken] = useState("");
10
+ const [error, setError] = useState<string | null>(null);
11
+ const [submitting, setSubmitting] = useState(false);
12
+
13
+ async function onSubmit(event: FormEvent<HTMLFormElement>) {
14
+ event.preventDefault();
15
+ setSubmitting(true);
16
+ setError(null);
17
+ try {
18
+ const ok = await unlockAdmin(token);
19
+ if (!ok) {
20
+ setError("Invalid token.");
21
+ return;
22
+ }
23
+ setToken("");
24
+ setOpen(false);
25
+ } finally {
26
+ setSubmitting(false);
27
+ }
28
+ }
29
+
30
+ function onLogout() {
31
+ lockAdmin();
32
+ setOpen(false);
33
+ }
34
+
35
+ return (
36
+ <div className="admin-button-wrap">
37
+ <button
38
+ type="button"
39
+ className={`admin-button ${enabled ? "admin-button-on" : ""}`}
40
+ onClick={() => setOpen((value) => !value)}
41
+ aria-expanded={open}
42
+ >
43
+ {enabled ? "Admin ✓" : "Admin 🔒"}
44
+ </button>
45
+ {open ? (
46
+ <div className="admin-panel" role="dialog" aria-label="Admin token">
47
+ {enabled ? (
48
+ <>
49
+ <div className="admin-panel-text">
50
+ Admin features are unlocked for this browser tab.
51
+ </div>
52
+ <button type="button" className="ghost-button" onClick={onLogout}>
53
+ Log out
54
+ </button>
55
+ </>
56
+ ) : (
57
+ <form className="admin-form" onSubmit={onSubmit}>
58
+ <label htmlFor="adminToken">Admin token</label>
59
+ <input
60
+ id="adminToken"
61
+ type="password"
62
+ value={token}
63
+ onChange={(event) => setToken(event.target.value)}
64
+ placeholder="paste the admin token"
65
+ autoComplete="off"
66
+ autoFocus
67
+ />
68
+ {error ? <div className="admin-panel-error">{error}</div> : null}
69
+ <div className="button-row">
70
+ <button
71
+ type="submit"
72
+ className="primary-button"
73
+ disabled={submitting || !token.trim()}
74
+ >
75
+ {submitting ? "Checking..." : "Unlock"}
76
+ </button>
77
+ <button
78
+ type="button"
79
+ className="ghost-button"
80
+ onClick={() => setOpen(false)}
81
+ >
82
+ Cancel
83
+ </button>
84
+ </div>
85
+ </form>
86
+ )}
87
+ </div>
88
+ ) : null}
89
+ </div>
90
+ );
91
+ }
frontend/src/components/Leaderboard.tsx ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+
3
+ import { useAdmin } from "../lib/admin";
4
+ import { fetchLeaderboard, type Leaderboard as LeaderboardData } from "../lib/api";
5
+
6
+ function formatElapsed(ms: number | null) {
7
+ if (ms === null) {
8
+ return "—";
9
+ }
10
+ const totalSeconds = Math.floor(ms / 1000);
11
+ const minutes = Math.floor(totalSeconds / 60)
12
+ .toString()
13
+ .padStart(2, "0");
14
+ const seconds = (totalSeconds % 60).toString().padStart(2, "0");
15
+ const centiseconds = Math.floor((ms % 1000) / 10)
16
+ .toString()
17
+ .padStart(2, "0");
18
+ return `${minutes}:${seconds}.${centiseconds}`;
19
+ }
20
+
21
+ type Props = {
22
+ puzzleId: string;
23
+ };
24
+
25
+ export function Leaderboard({ puzzleId }: Props) {
26
+ const { enabled, token } = useAdmin();
27
+ const [data, setData] = useState<LeaderboardData | null>(null);
28
+ const [loading, setLoading] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+ const [includeTest, setIncludeTest] = useState(false);
31
+
32
+ useEffect(() => {
33
+ if (!enabled || !puzzleId) {
34
+ setData(null);
35
+ return;
36
+ }
37
+ let cancelled = false;
38
+ setLoading(true);
39
+ setError(null);
40
+ fetchLeaderboard(puzzleId, { includeTest })
41
+ .then((result) => {
42
+ if (!cancelled) {
43
+ setData(result);
44
+ }
45
+ })
46
+ .catch((caught) => {
47
+ if (!cancelled) {
48
+ setError(caught instanceof Error ? caught.message : "Could not load leaderboard.");
49
+ }
50
+ })
51
+ .finally(() => {
52
+ if (!cancelled) {
53
+ setLoading(false);
54
+ }
55
+ });
56
+ return () => {
57
+ cancelled = true;
58
+ };
59
+ }, [enabled, puzzleId, includeTest, token]);
60
+
61
+ if (!enabled) {
62
+ return null;
63
+ }
64
+
65
+ const entries = data?.entries ?? [];
66
+ const solvedCount = entries.filter((entry) => entry.solved).length;
67
+
68
+ return (
69
+ <section className="panel leaderboard-panel">
70
+ <div className="leaderboard-header">
71
+ <div>
72
+ <div className="eyebrow">Leaderboard · admin</div>
73
+ <div className="leaderboard-summary">
74
+ {loading
75
+ ? "Loading…"
76
+ : `${entries.length} session${entries.length === 1 ? "" : "s"} · ${solvedCount} solved`}
77
+ </div>
78
+ </div>
79
+ <label className="leaderboard-toggle">
80
+ <input
81
+ type="checkbox"
82
+ checked={includeTest}
83
+ onChange={(event) => setIncludeTest(event.target.checked)}
84
+ />
85
+ include <code>test</code>
86
+ </label>
87
+ </div>
88
+
89
+ {error ? <div className="status-box error">{error}</div> : null}
90
+
91
+ {!loading && entries.length === 0 ? (
92
+ <div className="leaderboard-empty">No sessions for this puzzle yet.</div>
93
+ ) : null}
94
+
95
+ {entries.length > 0 ? (
96
+ <div className="leaderboard-table-wrap">
97
+ <table className="leaderboard-table">
98
+ <thead>
99
+ <tr>
100
+ <th>#</th>
101
+ <th>Player</th>
102
+ <th>Time</th>
103
+ <th>Attempts</th>
104
+ <th>Status</th>
105
+ </tr>
106
+ </thead>
107
+ <tbody>
108
+ {entries.map((entry, index) => (
109
+ <tr
110
+ key={`${entry.player_name}-${entry.started_at}-${index}`}
111
+ className={entry.solved ? "row-solved" : "row-unsolved"}
112
+ >
113
+ <td className="leaderboard-rank">{entry.solved ? index + 1 : "—"}</td>
114
+ <td className="leaderboard-player">{entry.player_name}</td>
115
+ <td className="leaderboard-time">
116
+ {entry.solved ? formatElapsed(entry.elapsed_ms) : "—"}
117
+ </td>
118
+ <td className="leaderboard-attempts">{entry.submission_count}</td>
119
+ <td className="leaderboard-status">
120
+ {entry.solved ? "solved" : "attempted"}
121
+ </td>
122
+ </tr>
123
+ ))}
124
+ </tbody>
125
+ </table>
126
+ </div>
127
+ ) : null}
128
+ </section>
129
+ );
130
+ }
frontend/src/index.css CHANGED
@@ -243,6 +243,232 @@ a {
243
  margin-bottom: 6px;
244
  }
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  .play-layout {
247
  max-width: 1400px;
248
  margin: 0 auto;
@@ -690,6 +916,8 @@ a {
690
  max-width: 820px;
691
  margin: 0 auto;
692
  padding-top: 64px;
 
 
693
  }
694
 
695
  .done-card {
@@ -698,6 +926,99 @@ a {
698
  gap: 16px;
699
  }
700
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
701
  @media (max-width: 980px) {
702
  .play-grid {
703
  grid-template-columns: 1fr;
 
243
  margin-bottom: 6px;
244
  }
245
 
246
+ .admin-button-wrap {
247
+ position: fixed;
248
+ top: 18px;
249
+ right: 18px;
250
+ z-index: 1000;
251
+ display: flex;
252
+ flex-direction: column;
253
+ align-items: flex-end;
254
+ gap: 8px;
255
+ }
256
+
257
+ .admin-button {
258
+ border: 1px solid rgba(18, 49, 41, 0.18);
259
+ background: rgba(255, 255, 255, 0.85);
260
+ color: #2a4a3d;
261
+ padding: 6px 12px;
262
+ border-radius: 999px;
263
+ font-size: 0.82rem;
264
+ font-weight: 500;
265
+ backdrop-filter: blur(10px);
266
+ cursor: pointer;
267
+ }
268
+
269
+ .admin-button:hover {
270
+ border-color: rgba(18, 49, 41, 0.4);
271
+ }
272
+
273
+ .admin-button-on {
274
+ background: rgba(34, 145, 92, 0.18);
275
+ border-color: rgba(34, 145, 92, 0.45);
276
+ color: #145a37;
277
+ }
278
+
279
+ .admin-panel {
280
+ background: rgba(255, 255, 255, 0.96);
281
+ border: 1px solid rgba(18, 49, 41, 0.18);
282
+ border-radius: 14px;
283
+ padding: 14px;
284
+ width: 280px;
285
+ box-shadow: 0 18px 40px rgba(31, 51, 42, 0.16);
286
+ display: grid;
287
+ gap: 10px;
288
+ }
289
+
290
+ .admin-form {
291
+ display: grid;
292
+ gap: 8px;
293
+ }
294
+
295
+ .admin-form label {
296
+ font-size: 0.78em;
297
+ color: #46695d;
298
+ font-weight: 500;
299
+ }
300
+
301
+ .admin-form input {
302
+ padding: 8px 10px;
303
+ border-radius: 8px;
304
+ border: 1px solid rgba(18, 49, 41, 0.18);
305
+ font-size: 0.9rem;
306
+ }
307
+
308
+ .admin-panel-text {
309
+ font-size: 0.88em;
310
+ color: #46695d;
311
+ }
312
+
313
+ .admin-panel-error {
314
+ font-size: 0.85em;
315
+ color: #7f1e1e;
316
+ }
317
+
318
+ .leaderboard-panel {
319
+ margin-top: 18px;
320
+ padding: 20px 24px;
321
+ display: grid;
322
+ gap: 14px;
323
+ }
324
+
325
+ .leaderboard-header {
326
+ display: flex;
327
+ justify-content: space-between;
328
+ align-items: flex-end;
329
+ gap: 12px;
330
+ }
331
+
332
+ .leaderboard-summary {
333
+ font-size: 0.92rem;
334
+ color: #46695d;
335
+ margin-top: 2px;
336
+ }
337
+
338
+ .leaderboard-toggle {
339
+ font-size: 0.82em;
340
+ color: #46695d;
341
+ display: inline-flex;
342
+ align-items: center;
343
+ gap: 6px;
344
+ }
345
+
346
+ .leaderboard-empty {
347
+ font-size: 0.92em;
348
+ color: #5a7568;
349
+ font-style: italic;
350
+ }
351
+
352
+ .leaderboard-table-wrap {
353
+ overflow-x: auto;
354
+ }
355
+
356
+ .leaderboard-table {
357
+ width: 100%;
358
+ border-collapse: collapse;
359
+ font-size: 0.92rem;
360
+ }
361
+
362
+ .leaderboard-table th,
363
+ .leaderboard-table td {
364
+ text-align: left;
365
+ padding: 8px 10px;
366
+ border-bottom: 1px solid rgba(18, 49, 41, 0.08);
367
+ }
368
+
369
+ .leaderboard-table th {
370
+ font-size: 0.74em;
371
+ letter-spacing: 0.08em;
372
+ text-transform: uppercase;
373
+ font-weight: 600;
374
+ color: #46695d;
375
+ }
376
+
377
+ .leaderboard-table .row-solved td {
378
+ background: rgba(34, 145, 92, 0.05);
379
+ }
380
+
381
+ .leaderboard-table .row-unsolved td {
382
+ color: #6b7a73;
383
+ }
384
+
385
+ .leaderboard-rank {
386
+ width: 36px;
387
+ font-variant-numeric: tabular-nums;
388
+ }
389
+
390
+ .leaderboard-time {
391
+ font-variant-numeric: tabular-nums;
392
+ }
393
+
394
+ .leaderboard-status {
395
+ font-size: 0.82em;
396
+ text-transform: uppercase;
397
+ letter-spacing: 0.06em;
398
+ color: #46695d;
399
+ }
400
+
401
+ .submit-banner {
402
+ margin-top: 14px;
403
+ padding: 14px 18px;
404
+ border-radius: 14px;
405
+ font-weight: 500;
406
+ font-size: 1.02rem;
407
+ text-align: center;
408
+ border: 1px solid transparent;
409
+ animation: submit-banner-pop 220ms ease-out;
410
+ }
411
+
412
+ .submit-banner-wrong {
413
+ background: rgba(204, 142, 41, 0.12);
414
+ border-color: rgba(204, 142, 41, 0.32);
415
+ color: #8a5a14;
416
+ }
417
+
418
+ .submit-banner-error {
419
+ background: rgba(170, 44, 44, 0.1);
420
+ border-color: rgba(170, 44, 44, 0.32);
421
+ color: #7f1e1e;
422
+ }
423
+
424
+ @keyframes submit-banner-pop {
425
+ 0% { transform: translateY(-6px); opacity: 0; }
426
+ 100% { transform: translateY(0); opacity: 1; }
427
+ }
428
+
429
+ .field-hint {
430
+ display: block;
431
+ margin-top: 6px;
432
+ font-size: 0.82em;
433
+ color: #5a7568;
434
+ line-height: 1.4;
435
+ }
436
+
437
+ .field-hint code {
438
+ background: rgba(18, 54, 44, 0.08);
439
+ padding: 1px 6px;
440
+ border-radius: 6px;
441
+ font-size: 0.95em;
442
+ }
443
+
444
+ .rules-box strong {
445
+ display: block;
446
+ font-size: 0.78em;
447
+ letter-spacing: 0.08em;
448
+ text-transform: uppercase;
449
+ color: #46695d;
450
+ margin-bottom: 6px;
451
+ }
452
+
453
+ .rules-box p {
454
+ margin: 0;
455
+ line-height: 1.5;
456
+ }
457
+
458
+ .tutorial-link {
459
+ display: inline-block;
460
+ margin-top: 8px;
461
+ font-size: 0.85em;
462
+ color: #2a6151;
463
+ text-decoration: none;
464
+ border-bottom: 1px solid rgba(42, 97, 81, 0.3);
465
+ }
466
+
467
+ .tutorial-link:hover {
468
+ color: #18402f;
469
+ border-bottom-color: rgba(24, 64, 47, 0.65);
470
+ }
471
+
472
  .play-layout {
473
  max-width: 1400px;
474
  margin: 0 auto;
 
916
  max-width: 820px;
917
  margin: 0 auto;
918
  padding-top: 64px;
919
+ display: grid;
920
+ gap: 18px;
921
  }
922
 
923
  .done-card {
 
926
  gap: 16px;
927
  }
928
 
929
+ .comparison-card {
930
+ padding: 28px;
931
+ display: grid;
932
+ gap: 16px;
933
+ }
934
+
935
+ .comparison-headline {
936
+ margin: 0;
937
+ font-size: 1.15rem;
938
+ font-weight: 600;
939
+ line-height: 1.4;
940
+ color: #18402f;
941
+ }
942
+
943
+ .comparison-grid {
944
+ display: grid;
945
+ grid-template-columns: 1fr 1fr;
946
+ gap: 16px;
947
+ }
948
+
949
+ .comparison-column {
950
+ display: grid;
951
+ gap: 10px;
952
+ }
953
+
954
+ .comparison-column-title {
955
+ font-size: 0.78em;
956
+ letter-spacing: 0.08em;
957
+ text-transform: uppercase;
958
+ font-weight: 600;
959
+ }
960
+
961
+ .comparison-solved {
962
+ color: #1d6b46;
963
+ }
964
+
965
+ .comparison-failed {
966
+ color: #7a3a3a;
967
+ }
968
+
969
+ .comparison-list {
970
+ list-style: none;
971
+ margin: 0;
972
+ padding: 0;
973
+ display: flex;
974
+ flex-wrap: wrap;
975
+ gap: 6px;
976
+ }
977
+
978
+ .comparison-pill {
979
+ display: inline-block;
980
+ padding: 4px 10px;
981
+ border-radius: 999px;
982
+ font-size: 0.88em;
983
+ border: 1px solid transparent;
984
+ }
985
+
986
+ .comparison-pill-solved {
987
+ background: rgba(34, 145, 92, 0.12);
988
+ border-color: rgba(34, 145, 92, 0.28);
989
+ color: #145a37;
990
+ }
991
+
992
+ .comparison-pill-failed {
993
+ background: rgba(170, 70, 70, 0.1);
994
+ border-color: rgba(170, 70, 70, 0.25);
995
+ color: #7a3030;
996
+ }
997
+
998
+ .comparison-empty {
999
+ font-size: 0.9em;
1000
+ color: #5a7568;
1001
+ font-style: italic;
1002
+ }
1003
+
1004
+ .comparison-footnote {
1005
+ margin: 0;
1006
+ font-size: 0.82em;
1007
+ color: #5a7568;
1008
+ }
1009
+
1010
+ .comparison-footnote code {
1011
+ background: rgba(18, 54, 44, 0.08);
1012
+ padding: 1px 6px;
1013
+ border-radius: 6px;
1014
+ }
1015
+
1016
+ @media (max-width: 640px) {
1017
+ .comparison-grid {
1018
+ grid-template-columns: 1fr;
1019
+ }
1020
+ }
1021
+
1022
  @media (max-width: 980px) {
1023
  .play-grid {
1024
  grid-template-columns: 1fr;
frontend/src/lib/admin.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+
3
+ const ADMIN_TOKEN_KEY = "topobench:admin-token";
4
+ const ADMIN_CHANGE_EVENT = "topobench:admin-change";
5
+
6
+ export function getAdminToken(): string | null {
7
+ if (typeof window === "undefined") {
8
+ return null;
9
+ }
10
+ return window.sessionStorage.getItem(ADMIN_TOKEN_KEY);
11
+ }
12
+
13
+ function emitChange(): void {
14
+ if (typeof window !== "undefined") {
15
+ window.dispatchEvent(new Event(ADMIN_CHANGE_EVENT));
16
+ }
17
+ }
18
+
19
+ export async function validateAdminToken(token: string): Promise<boolean> {
20
+ try {
21
+ const response = await fetch("/api/admin/check", {
22
+ headers: { Authorization: `Bearer ${token}` },
23
+ });
24
+ return response.ok;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ export async function unlockAdmin(token: string): Promise<boolean> {
31
+ const trimmed = token.trim();
32
+ if (!trimmed) {
33
+ return false;
34
+ }
35
+ const ok = await validateAdminToken(trimmed);
36
+ if (!ok) {
37
+ return false;
38
+ }
39
+ window.sessionStorage.setItem(ADMIN_TOKEN_KEY, trimmed);
40
+ emitChange();
41
+ return true;
42
+ }
43
+
44
+ export function lockAdmin(): void {
45
+ if (typeof window !== "undefined") {
46
+ window.sessionStorage.removeItem(ADMIN_TOKEN_KEY);
47
+ emitChange();
48
+ }
49
+ }
50
+
51
+ export function useAdmin(): { enabled: boolean; token: string | null } {
52
+ const [token, setToken] = useState<string | null>(getAdminToken);
53
+ useEffect(() => {
54
+ const handler = () => setToken(getAdminToken());
55
+ window.addEventListener(ADMIN_CHANGE_EVENT, handler);
56
+ return () => window.removeEventListener(ADMIN_CHANGE_EVENT, handler);
57
+ }, []);
58
+ return { enabled: Boolean(token), token };
59
+ }
60
+
61
+ export async function adminFetch(
62
+ input: string,
63
+ init: RequestInit = {},
64
+ ): Promise<Response> {
65
+ const token = getAdminToken();
66
+ const headers = new Headers(init.headers);
67
+ if (token) {
68
+ headers.set("Authorization", `Bearer ${token}`);
69
+ }
70
+ return fetch(input, { ...init, headers });
71
+ }
frontend/src/lib/api.ts CHANGED
@@ -1,3 +1,5 @@
 
 
1
  export type Difficulty = "easy" | "medium" | "hard";
2
  export type PuzzleType =
3
  | "bridges"
@@ -39,6 +41,26 @@ export type SubmitResponse = {
39
  verification: Record<string, unknown>;
40
  };
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  async function handle<T>(response: Response): Promise<T> {
43
  if (!response.ok) {
44
  let message = response.statusText;
@@ -57,7 +79,7 @@ export async function createSession(input: {
57
  player_name: string;
58
  puzzle_type: PuzzleType;
59
  difficulty: Difficulty;
60
- puzzle_id: string;
61
  }): Promise<SessionResponse> {
62
  return handle<SessionResponse>(
63
  await fetch("/api/sessions", {
@@ -91,6 +113,29 @@ export async function readySession(
91
  );
92
  }
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  export async function submitSession(
95
  sessionId: string,
96
  boardAscii: string,
 
1
+ import { adminFetch } from "./admin";
2
+
3
  export type Difficulty = "easy" | "medium" | "hard";
4
  export type PuzzleType =
5
  | "bridges"
 
41
  verification: Record<string, unknown>;
42
  };
43
 
44
+ export type LLMResults = {
45
+ puzzle_id: string;
46
+ models_solved: string[];
47
+ models_failed: string[];
48
+ };
49
+
50
+ export type LeaderboardEntry = {
51
+ player_name: string;
52
+ elapsed_ms: number | null;
53
+ submission_count: number;
54
+ solved: boolean;
55
+ started_at: string | null;
56
+ submitted_at: string | null;
57
+ };
58
+
59
+ export type Leaderboard = {
60
+ puzzle_id: string;
61
+ entries: LeaderboardEntry[];
62
+ };
63
+
64
  async function handle<T>(response: Response): Promise<T> {
65
  if (!response.ok) {
66
  let message = response.statusText;
 
79
  player_name: string;
80
  puzzle_type: PuzzleType;
81
  difficulty: Difficulty;
82
+ puzzle_id?: string;
83
  }): Promise<SessionResponse> {
84
  return handle<SessionResponse>(
85
  await fetch("/api/sessions", {
 
113
  );
114
  }
115
 
116
+ export async function fetchLeaderboard(
117
+ puzzleId: string,
118
+ options: { includeTest?: boolean } = {},
119
+ ): Promise<Leaderboard> {
120
+ const params = new URLSearchParams();
121
+ if (options.includeTest) {
122
+ params.set("include_test", "true");
123
+ }
124
+ const qs = params.toString() ? `?${params.toString()}` : "";
125
+ const response = await adminFetch(
126
+ `/api/admin/leaderboard/${encodeURIComponent(puzzleId)}${qs}`,
127
+ );
128
+ return handle<Leaderboard>(response);
129
+ }
130
+
131
+ export async function fetchLLMResults(puzzleId: string): Promise<LLMResults | null> {
132
+ const response = await fetch(`/api/llm-results/${encodeURIComponent(puzzleId)}`);
133
+ if (response.status === 404) {
134
+ return null;
135
+ }
136
+ return handle<LLMResults>(response);
137
+ }
138
+
139
  export async function submitSession(
140
  sessionId: string,
141
  boardAscii: string,
frontend/src/lib/puzzles.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { PuzzleType } from "./api";
2
+
3
+ export type PuzzleFamily = {
4
+ value: PuzzleType;
5
+ label: string;
6
+ blurb: string;
7
+ helpText: string;
8
+ tutorialUrl: string;
9
+ };
10
+
11
+ // To add a tutorial: paste a URL into the matching `tutorialUrl`.
12
+ // Empty string -> the "Watch tutorial" link is hidden.
13
+ export const PUZZLE_FAMILIES: PuzzleFamily[] = [
14
+ {
15
+ value: "bridges",
16
+ label: "Bridges",
17
+ blurb: "Connect islands with single or double links.",
18
+ helpText:
19
+ "Connect every numbered island into one network of bridges. The number on each island is exactly how many bridge-ends must touch it. Bridges run horizontally or vertically, never cross, and you may use at most two between any pair of islands. Click between two islands to cycle no bridge → single → double.",
20
+ tutorialUrl: "https://www.youtube.com/watch?v=i9RL_3cn4mo",
21
+ },
22
+ {
23
+ value: "flow_free",
24
+ label: "Flow Free",
25
+ blurb: "Fill the board with non-crossing color paths.",
26
+ helpText:
27
+ "Connect each pair of matching colored endpoints with a continuous path, leaving no empty cell. Paths cannot cross or share a cell. Click an endpoint to pick its color, then click adjacent empty cells to extend the path; clicking a filled cell with that same color erases it. The board is solved when every color is paired and every cell is covered.",
28
+ tutorialUrl: "https://www.youtube.com/shorts/mli8IdECi-g",
29
+ },
30
+ {
31
+ value: "galaxies",
32
+ label: "Galaxies",
33
+ blurb: "Draw region boundaries around rotationally symmetric clusters.",
34
+ helpText:
35
+ "Partition the board with interior walls so each region contains exactly one dot and is 180°-rotationally symmetric around that dot. For every cell in a galaxy, the cell on the opposite side of the dot must also belong to it. Click an interior segment to add or remove a wall. Submit when every cell is assigned to exactly one valid galaxy.",
36
+ tutorialUrl: "https://www.youtube.com/watch?v=legK2UDyHlg",
37
+ },
38
+ {
39
+ value: "loopy",
40
+ label: "Loopy",
41
+ blurb: "Toggle loop edges around numeric clues.",
42
+ helpText:
43
+ "Draw exactly one closed loop along the grid edges. The loop cannot branch or cross itself, and the number in each cell counts how many of its four sides are part of the loop. Click any segment (including the outer border) to toggle it between part of the loop and not part of it. The board is solved when every numeric clue is satisfied by a single continuous loop.",
44
+ tutorialUrl: "https://www.youtube.com/watch?v=nSLdxmHefts&t=22s",
45
+ },
46
+ {
47
+ value: "pattern",
48
+ label: "Pattern",
49
+ blurb: "Fill cells to satisfy run clues for every row and column.",
50
+ helpText:
51
+ "Each row and column clue lists the lengths of consecutive black runs, in order, separated by at least one white cell. Click a cell to toggle it between white and filled black. Solve by filling cells so every row and column simultaneously matches its clues — only the run lengths matter, no other connectivity rule applies.",
52
+ tutorialUrl: "https://www.youtube.com/watch?v=CRVEAxP-UUk",
53
+ },
54
+ {
55
+ value: "undead",
56
+ label: "Undead",
57
+ blurb: "Place monsters around mirrors and sightline clues.",
58
+ helpText:
59
+ "Place ghosts (G), vampires (V), and zombies (Z) so each edge clue equals the number of monsters seen looking down that row or column from that side. Vampires are visible only along direct line of sight; ghosts are visible only when at least one mirror redirects the sight; zombies are visible either way. The / and \\ symbols are fixed mirrors that bend the sightline by 90°. Click any empty cell to cycle blank → G → V → Z.",
60
+ tutorialUrl: "https://www.youtube.com/watch?v=L72V9O_anbg&t=187s",
61
+ },
62
+ ];
63
+
64
+ export const PUZZLE_FAMILY_BY_TYPE: Record<PuzzleType, PuzzleFamily> = Object.fromEntries(
65
+ PUZZLE_FAMILIES.map((family) => [family.value, family]),
66
+ ) as Record<PuzzleType, PuzzleFamily>;
frontend/src/routes/DonePage.tsx CHANGED
@@ -1,6 +1,9 @@
1
- import { useNavigate, useParams, useLocation } from "react-router-dom";
 
2
 
3
- import type { SessionResponse, SubmitResponse } from "../lib/api";
 
 
4
 
5
  type DoneState = {
6
  result?: SubmitResponse;
@@ -24,11 +27,48 @@ function formatElapsed(ms: number | null) {
24
  }
25
 
26
  export function DonePage() {
27
- const { sessionId = "" } = useParams();
28
  const location = useLocation();
29
  const navigate = useNavigate();
30
  const state = (location.state as DoneState | null) ?? null;
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  function playAgain() {
33
  navigate("/", {
34
  state: {
@@ -40,6 +80,12 @@ export function DonePage() {
40
  });
41
  }
42
 
 
 
 
 
 
 
43
  return (
44
  <div className="shell">
45
  <div className="done-layout">
@@ -47,8 +93,8 @@ export function DonePage() {
47
  <div className="eyebrow">Solved</div>
48
  <h1 style={{ margin: 0 }}>Puzzle complete.</h1>
49
  <p style={{ margin: 0, color: "#46695d" }}>
50
- Session <code>{sessionId}</code> finished in{" "}
51
- <strong>{formatElapsed(state?.result?.elapsed_ms ?? null)}</strong>.
52
  </p>
53
  <div className="button-row">
54
  <button className="primary-button" type="button" onClick={playAgain}>
@@ -59,6 +105,62 @@ export function DonePage() {
59
  </button>
60
  </div>
61
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  </div>
63
  </div>
64
  );
 
1
+ import { useEffect, useState } from "react";
2
+ import { useNavigate, useLocation } from "react-router-dom";
3
 
4
+ import { Leaderboard } from "../components/Leaderboard";
5
+ import { fetchLLMResults, type LLMResults, type SessionResponse, type SubmitResponse } from "../lib/api";
6
+ import { PUZZLE_FAMILY_BY_TYPE } from "../lib/puzzles";
7
 
8
  type DoneState = {
9
  result?: SubmitResponse;
 
27
  }
28
 
29
  export function DonePage() {
 
30
  const location = useLocation();
31
  const navigate = useNavigate();
32
  const state = (location.state as DoneState | null) ?? null;
33
 
34
+ const puzzleId = state?.session?.puzzle_id ?? "";
35
+ const puzzleType = state?.session?.puzzle_type ?? "";
36
+ const family = puzzleType ? PUZZLE_FAMILY_BY_TYPE[puzzleType as keyof typeof PUZZLE_FAMILY_BY_TYPE] : undefined;
37
+
38
+ const [llmResults, setLlmResults] = useState<LLMResults | null>(null);
39
+ const [llmLoading, setLlmLoading] = useState<boolean>(Boolean(puzzleId));
40
+ const [llmError, setLlmError] = useState<string | null>(null);
41
+
42
+ useEffect(() => {
43
+ if (!puzzleId) {
44
+ setLlmLoading(false);
45
+ return;
46
+ }
47
+ let cancelled = false;
48
+ setLlmLoading(true);
49
+ setLlmError(null);
50
+ fetchLLMResults(puzzleId)
51
+ .then((data) => {
52
+ if (cancelled) {
53
+ return;
54
+ }
55
+ setLlmResults(data);
56
+ })
57
+ .catch((caught) => {
58
+ if (!cancelled) {
59
+ setLlmError(caught instanceof Error ? caught.message : "Could not load LLM comparison.");
60
+ }
61
+ })
62
+ .finally(() => {
63
+ if (!cancelled) {
64
+ setLlmLoading(false);
65
+ }
66
+ });
67
+ return () => {
68
+ cancelled = true;
69
+ };
70
+ }, [puzzleId]);
71
+
72
  function playAgain() {
73
  navigate("/", {
74
  state: {
 
80
  });
81
  }
82
 
83
+ const totalModels = llmResults
84
+ ? llmResults.models_solved.length + llmResults.models_failed.length
85
+ : 0;
86
+ const solvedCount = llmResults?.models_solved.length ?? 0;
87
+ const familyLabel = family?.label ?? puzzleType;
88
+
89
  return (
90
  <div className="shell">
91
  <div className="done-layout">
 
93
  <div className="eyebrow">Solved</div>
94
  <h1 style={{ margin: 0 }}>Puzzle complete.</h1>
95
  <p style={{ margin: 0, color: "#46695d" }}>
96
+ {familyLabel ? <><strong>{familyLabel}</strong> · </> : null}
97
+ finished in <strong>{formatElapsed(state?.result?.elapsed_ms ?? null)}</strong>.
98
  </p>
99
  <div className="button-row">
100
  <button className="primary-button" type="button" onClick={playAgain}>
 
105
  </button>
106
  </div>
107
  </section>
108
+
109
+ <section className="panel comparison-card">
110
+ <div className="eyebrow">LLM comparison · this exact puzzle</div>
111
+ {llmLoading ? (
112
+ <div className="status-box">Loading LLM comparison…</div>
113
+ ) : llmError ? (
114
+ <div className="status-box error">{llmError}</div>
115
+ ) : !llmResults ? (
116
+ <div className="status-box">
117
+ No LLM benchmark data recorded for this puzzle yet. Comparisons will appear once results are added.
118
+ </div>
119
+ ) : totalModels === 0 ? (
120
+ <div className="status-box">No models have been benchmarked on this puzzle.</div>
121
+ ) : (
122
+ <>
123
+ <h2 className="comparison-headline">
124
+ You solved it. {solvedCount} of {totalModels} benchmarked LLMs also solved this exact puzzle.
125
+ </h2>
126
+ <div className="comparison-grid">
127
+ <div className="comparison-column">
128
+ <div className="comparison-column-title comparison-solved">
129
+ Solved ({llmResults.models_solved.length})
130
+ </div>
131
+ {llmResults.models_solved.length === 0 ? (
132
+ <div className="comparison-empty">No LLM solved this — you got something none of them could.</div>
133
+ ) : (
134
+ <ul className="comparison-list">
135
+ {llmResults.models_solved.map((model) => (
136
+ <li key={model} className="comparison-pill comparison-pill-solved">{model}</li>
137
+ ))}
138
+ </ul>
139
+ )}
140
+ </div>
141
+ <div className="comparison-column">
142
+ <div className="comparison-column-title comparison-failed">
143
+ Failed ({llmResults.models_failed.length})
144
+ </div>
145
+ {llmResults.models_failed.length === 0 ? (
146
+ <div className="comparison-empty">Every benchmarked LLM solved this one too.</div>
147
+ ) : (
148
+ <ul className="comparison-list">
149
+ {llmResults.models_failed.map((model) => (
150
+ <li key={model} className="comparison-pill comparison-pill-failed">{model}</li>
151
+ ))}
152
+ </ul>
153
+ )}
154
+ </div>
155
+ </div>
156
+ <p className="comparison-footnote">
157
+ Puzzle id <code>{puzzleId}</code>. LLM verdicts come from the TopoBench paper's eval on this same board — not a probabilistic comparison.
158
+ </p>
159
+ </>
160
+ )}
161
+ </section>
162
+
163
+ <Leaderboard puzzleId={puzzleId} />
164
  </div>
165
  </div>
166
  );
frontend/src/routes/HomePage.tsx CHANGED
@@ -9,17 +9,29 @@ import {
9
  type PuzzleOption,
10
  type PuzzleType,
11
  } from "../lib/api";
 
12
 
13
- const PUZZLE_OPTIONS: Array<{ value: PuzzleType; label: string; blurb: string }> = [
14
- { value: "bridges", label: "Bridges", blurb: "Connect islands with single or double links." },
15
- { value: "flow_free", label: "Flow Free", blurb: "Fill the board with non-crossing color paths." },
16
- { value: "galaxies", label: "Galaxies", blurb: "Draw region boundaries around rotationally symmetric clusters." },
17
- { value: "loopy", label: "Loopy", blurb: "Toggle loop edges around numeric clues." },
18
- { value: "pattern", label: "Pattern", blurb: "Fill cells to satisfy run clues while keeping one connected shape." },
19
- { value: "undead", label: "Undead", blurb: "Place monsters around mirrors and sightline clues." },
20
- ];
 
 
 
 
 
 
 
 
 
 
21
 
22
  export function HomePage() {
 
23
  const navigate = useNavigate();
24
  const location = useLocation();
25
  const defaults = (location.state as
@@ -30,11 +42,14 @@ export function HomePage() {
30
  const [difficulty, setDifficulty] = useState(defaults.difficulty ?? "easy");
31
  const [puzzleOptions, setPuzzleOptions] = useState<PuzzleOption[]>([]);
32
  const [selectedPuzzleId, setSelectedPuzzleId] = useState(defaults.puzzleId ?? "");
33
- const [loadingPuzzles, setLoadingPuzzles] = useState(true);
34
  const [error, setError] = useState<string | null>(null);
35
  const [submitting, setSubmitting] = useState(false);
36
 
37
  useEffect(() => {
 
 
 
38
  let cancelled = false;
39
  async function loadPuzzles() {
40
  setLoadingPuzzles(true);
@@ -71,7 +86,7 @@ export function HomePage() {
71
  return () => {
72
  cancelled = true;
73
  };
74
- }, [puzzleType, difficulty, defaults.puzzleId]);
75
 
76
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
77
  event.preventDefault();
@@ -82,10 +97,14 @@ export function HomePage() {
82
  player_name: playerName,
83
  puzzle_type: puzzleType,
84
  difficulty,
85
- puzzle_id: selectedPuzzleId,
86
  });
87
  navigate(`/play/${session.session_id}`, {
88
- state: { session, playerName, puzzleId: selectedPuzzleId },
 
 
 
 
89
  });
90
  } catch (caught) {
91
  setError(caught instanceof Error ? caught.message : "Could not create a session.");
@@ -98,13 +117,15 @@ export function HomePage() {
98
  <div className="shell">
99
  <div className="home-layout">
100
  <section className="panel hero">
101
- <div className="eyebrow">TopoBench Space</div>
102
  <h1>Play the benchmark, not just the report.</h1>
103
  <p>
104
- This Space pulls real TopoBench puzzles from Hugging Face, lets you choose
105
- intentionally from the first 50 boards in each set, times each solve on the
106
- server, and verifies your submission against the same native logic used by
107
- the benchmark repo.
 
 
108
  </p>
109
  </section>
110
 
@@ -121,6 +142,9 @@ export function HomePage() {
121
  maxLength={80}
122
  required
123
  />
 
 
 
124
  </div>
125
 
126
  <div className="field">
@@ -130,7 +154,7 @@ export function HomePage() {
130
  value={puzzleType}
131
  onChange={(event) => setPuzzleType(event.target.value as PuzzleType)}
132
  >
133
- {PUZZLE_OPTIONS.map((option) => (
134
  <option key={option.value} value={option.value}>
135
  {option.label}
136
  </option>
@@ -151,34 +175,36 @@ export function HomePage() {
151
  </select>
152
  </div>
153
 
154
- <div className="field">
155
- <label>Pick one of the first 50 puzzles</label>
156
- <div className="puzzle-picker">
157
- {loadingPuzzles ? (
158
- <div className="status-box">Loading puzzle choices...</div>
159
- ) : (
160
- puzzleOptions.map((option) => (
161
- <label
162
- key={option.puzzle_id}
163
- className={`puzzle-option ${selectedPuzzleId === option.puzzle_id ? "selected" : ""}`}
164
- >
165
- <input
166
- type="radio"
167
- name="puzzleId"
168
- value={option.puzzle_id}
169
- checked={selectedPuzzleId === option.puzzle_id}
170
- onChange={() => setSelectedPuzzleId(option.puzzle_id)}
171
- />
172
- <span className="puzzle-option-seq">{option.sequence.toString().padStart(2, "0")}</span>
173
- <span className="puzzle-option-text">
174
- <strong>{option.title}</strong>
175
- <span>{option.args}</span>
176
- </span>
177
- </label>
178
- ))
179
- )}
 
 
180
  </div>
181
- </div>
182
 
183
  {error ? <div className="status-box error">{error}</div> : null}
184
 
@@ -186,19 +212,33 @@ export function HomePage() {
186
  <button
187
  className="primary-button"
188
  type="submit"
189
- disabled={submitting || loadingPuzzles || !selectedPuzzleId}
190
  >
191
- {submitting ? "Starting..." : "Start Chosen Puzzle"}
 
 
 
 
192
  </button>
193
  </div>
194
  </form>
195
  </section>
196
 
197
  <section className="panel card hint-grid">
198
- {PUZZLE_OPTIONS.map((option) => (
199
  <div key={option.value} className="hint-box">
200
  <strong>{option.label}</strong>
201
  <div>{option.blurb}</div>
 
 
 
 
 
 
 
 
 
 
202
  </div>
203
  ))}
204
  </section>
 
9
  type PuzzleOption,
10
  type PuzzleType,
11
  } from "../lib/api";
12
+ import { PUZZLE_FAMILIES } from "../lib/puzzles";
13
 
14
+ const DEV_FLAG_KEY = "topobench:dev";
15
+
16
+ function readDevMode(): boolean {
17
+ if (typeof window === "undefined") {
18
+ return false;
19
+ }
20
+ const params = new URLSearchParams(window.location.search);
21
+ const urlValue = params.get("dev");
22
+ if (urlValue === "1") {
23
+ window.sessionStorage.setItem(DEV_FLAG_KEY, "1");
24
+ return true;
25
+ }
26
+ if (urlValue === "0") {
27
+ window.sessionStorage.removeItem(DEV_FLAG_KEY);
28
+ return false;
29
+ }
30
+ return window.sessionStorage.getItem(DEV_FLAG_KEY) === "1";
31
+ }
32
 
33
  export function HomePage() {
34
+ const [devMode] = useState(readDevMode);
35
  const navigate = useNavigate();
36
  const location = useLocation();
37
  const defaults = (location.state as
 
42
  const [difficulty, setDifficulty] = useState(defaults.difficulty ?? "easy");
43
  const [puzzleOptions, setPuzzleOptions] = useState<PuzzleOption[]>([]);
44
  const [selectedPuzzleId, setSelectedPuzzleId] = useState(defaults.puzzleId ?? "");
45
+ const [loadingPuzzles, setLoadingPuzzles] = useState(devMode);
46
  const [error, setError] = useState<string | null>(null);
47
  const [submitting, setSubmitting] = useState(false);
48
 
49
  useEffect(() => {
50
+ if (!devMode) {
51
+ return;
52
+ }
53
  let cancelled = false;
54
  async function loadPuzzles() {
55
  setLoadingPuzzles(true);
 
86
  return () => {
87
  cancelled = true;
88
  };
89
+ }, [devMode, puzzleType, difficulty, defaults.puzzleId]);
90
 
91
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
92
  event.preventDefault();
 
97
  player_name: playerName,
98
  puzzle_type: puzzleType,
99
  difficulty,
100
+ ...(devMode && selectedPuzzleId ? { puzzle_id: selectedPuzzleId } : {}),
101
  });
102
  navigate(`/play/${session.session_id}`, {
103
+ state: {
104
+ session,
105
+ playerName,
106
+ puzzleId: session.puzzle_id,
107
+ },
108
  });
109
  } catch (caught) {
110
  setError(caught instanceof Error ? caught.message : "Could not create a session.");
 
117
  <div className="shell">
118
  <div className="home-layout">
119
  <section className="panel hero">
120
+ <div className="eyebrow">TopoBench Space{devMode ? " · dev mode" : ""}</div>
121
  <h1>Play the benchmark, not just the report.</h1>
122
  <p>
123
+ This Space pulls real TopoBench puzzles from Hugging Face,{" "}
124
+ {devMode
125
+ ? "lets you choose intentionally from the first 50 boards in each set, "
126
+ : "assigns you a random board from each set, "}
127
+ times each solve on the server, and verifies your submission against the same
128
+ native logic used by the benchmark repo.
129
  </p>
130
  </section>
131
 
 
142
  maxLength={80}
143
  required
144
  />
145
+ <small className="field-hint">
146
+ Use <code>test</code> as your name to practice. Any other name (your real name or a handle) counts toward the study.
147
+ </small>
148
  </div>
149
 
150
  <div className="field">
 
154
  value={puzzleType}
155
  onChange={(event) => setPuzzleType(event.target.value as PuzzleType)}
156
  >
157
+ {PUZZLE_FAMILIES.map((option) => (
158
  <option key={option.value} value={option.value}>
159
  {option.label}
160
  </option>
 
175
  </select>
176
  </div>
177
 
178
+ {devMode ? (
179
+ <div className="field">
180
+ <label>Pick one of the first 50 puzzles</label>
181
+ <div className="puzzle-picker">
182
+ {loadingPuzzles ? (
183
+ <div className="status-box">Loading puzzle choices...</div>
184
+ ) : (
185
+ puzzleOptions.map((option) => (
186
+ <label
187
+ key={option.puzzle_id}
188
+ className={`puzzle-option ${selectedPuzzleId === option.puzzle_id ? "selected" : ""}`}
189
+ >
190
+ <input
191
+ type="radio"
192
+ name="puzzleId"
193
+ value={option.puzzle_id}
194
+ checked={selectedPuzzleId === option.puzzle_id}
195
+ onChange={() => setSelectedPuzzleId(option.puzzle_id)}
196
+ />
197
+ <span className="puzzle-option-seq">{option.sequence.toString().padStart(2, "0")}</span>
198
+ <span className="puzzle-option-text">
199
+ <strong>{option.title}</strong>
200
+ <span>{option.args}</span>
201
+ </span>
202
+ </label>
203
+ ))
204
+ )}
205
+ </div>
206
  </div>
207
+ ) : null}
208
 
209
  {error ? <div className="status-box error">{error}</div> : null}
210
 
 
212
  <button
213
  className="primary-button"
214
  type="submit"
215
+ disabled={submitting || (devMode && (loadingPuzzles || !selectedPuzzleId))}
216
  >
217
+ {submitting
218
+ ? "Starting..."
219
+ : devMode
220
+ ? "Start Chosen Puzzle"
221
+ : "Start Random Puzzle"}
222
  </button>
223
  </div>
224
  </form>
225
  </section>
226
 
227
  <section className="panel card hint-grid">
228
+ {PUZZLE_FAMILIES.map((option) => (
229
  <div key={option.value} className="hint-box">
230
  <strong>{option.label}</strong>
231
  <div>{option.blurb}</div>
232
+ {option.tutorialUrl ? (
233
+ <a
234
+ className="tutorial-link"
235
+ href={option.tutorialUrl}
236
+ target="_blank"
237
+ rel="noopener noreferrer"
238
+ >
239
+ Watch tutorial →
240
+ </a>
241
+ ) : null}
242
  </div>
243
  ))}
244
  </section>
frontend/src/routes/PlayPage.tsx CHANGED
@@ -1,17 +1,10 @@
1
  import { useEffect, useState } from "react";
2
  import { useLocation, useNavigate, useParams } from "react-router-dom";
3
 
 
4
  import { PuzzleEditor } from "../components/PuzzleEditor";
5
- import { fetchSession, readySession, submitSession, type SessionResponse } from "../lib/api";
6
-
7
- const PUZZLE_HELP: Record<string, string> = {
8
- bridges: "Connect every numbered island into a single network. Each route cycles empty, single bridge, then double bridge.",
9
- flow_free: "Choose a color from the palette, then paint a continuous path between matching endpoints without changing the endpoints themselves.",
10
- galaxies: "Partition the board with interior walls so each region has exactly one dot-symmetry center.",
11
- loopy: "Build one single loop. Every segment toggles between part of the loop and definitely not part of it, including the outer perimeter.",
12
- pattern: "Every square starts white. Click a square to toggle it between white and filled black so the row and column clues match.",
13
- undead: "Place ghosts, vampires, and zombies so the side clues and the global monster counts all line up.",
14
- };
15
 
16
  function formatElapsed(ms: number) {
17
  const totalSeconds = Math.floor(ms / 1000);
@@ -41,7 +34,7 @@ export function PlayPage() {
41
  const [startedAt, setStartedAt] = useState<string | null>(state?.session?.started_at ?? null);
42
  const [elapsedMs, setElapsedMs] = useState(0);
43
  const [error, setError] = useState<string | null>(null);
44
- const [statusMessage, setStatusMessage] = useState<string | null>(null);
45
  const [submitting, setSubmitting] = useState(false);
46
 
47
  useEffect(() => {
@@ -54,7 +47,6 @@ export function PlayPage() {
54
  }
55
  setSession(payload);
56
  setBoardAscii(payload.payload.current_board_ascii);
57
- setStatusMessage(null);
58
  if (!payload.started_at) {
59
  const ready = await readySession(sessionId);
60
  if (!cancelled) {
@@ -91,8 +83,7 @@ export function PlayPage() {
91
  return;
92
  }
93
  setSubmitting(true);
94
- setError(null);
95
- setStatusMessage(null);
96
  try {
97
  const result = await submitSession(session.session_id, boardAscii);
98
  if (result.solved) {
@@ -105,14 +96,13 @@ export function PlayPage() {
105
  },
106
  });
107
  } else {
108
- if (!result.verification.board_valid) {
109
- setError("That board is not structurally valid yet. Double-check fixed clues, endpoints, and wall or bridge placement.");
110
- } else {
111
- setStatusMessage("Close, but not solved yet. Keep going and submit again when you are ready.");
112
- }
113
  }
114
  } catch (caught) {
115
- setError(caught instanceof Error ? caught.message : "Could not submit the puzzle.");
 
 
 
116
  } finally {
117
  setSubmitting(false);
118
  }
@@ -134,8 +124,7 @@ export function PlayPage() {
134
  return;
135
  }
136
  setBoardAscii(session.payload.current_board_ascii);
137
- setError(null);
138
- setStatusMessage("Board reset to the original puzzle.");
139
  }
140
 
141
  if (!session) {
@@ -168,6 +157,15 @@ export function PlayPage() {
168
  boardAscii={boardAscii}
169
  onChange={setBoardAscii}
170
  />
 
 
 
 
 
 
 
 
 
171
  </section>
172
 
173
  <aside className="panel sidebar">
@@ -175,9 +173,25 @@ export function PlayPage() {
175
  Timer starts once the puzzle appears. The server keeps the official solve time.
176
  </div>
177
 
178
- {error ? <div className="status-box error">{error}</div> : null}
179
- {statusMessage ? <div className="status-box">{statusMessage}</div> : null}
180
- <div className="status-box">{PUZZLE_HELP[session.puzzle_type] ?? "Solve the puzzle, then submit to verify it."}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
  <div className="button-row">
183
  <button className="primary-button" type="button" onClick={onSubmit} disabled={submitting}>
@@ -190,22 +204,10 @@ export function PlayPage() {
190
  Choose Another Puzzle
191
  </button>
192
  </div>
193
-
194
- {session.payload.image_base64 ? (
195
- <div className="image-frame">
196
- <img
197
- src={`data:image/png;base64,${session.payload.image_base64}`}
198
- alt="Reference rendering of the puzzle"
199
- />
200
- </div>
201
- ) : null}
202
-
203
- <div>
204
- <strong>ASCII board</strong>
205
- <pre className="ascii-preview">{boardAscii}</pre>
206
- </div>
207
  </aside>
208
  </div>
 
 
209
  </div>
210
  </div>
211
  );
 
1
  import { useEffect, useState } from "react";
2
  import { useLocation, useNavigate, useParams } from "react-router-dom";
3
 
4
+ import { Leaderboard } from "../components/Leaderboard";
5
  import { PuzzleEditor } from "../components/PuzzleEditor";
6
+ import { fetchSession, readySession, submitSession, type PuzzleType, type SessionResponse } from "../lib/api";
7
+ import { PUZZLE_FAMILY_BY_TYPE } from "../lib/puzzles";
 
 
 
 
 
 
 
 
8
 
9
  function formatElapsed(ms: number) {
10
  const totalSeconds = Math.floor(ms / 1000);
 
34
  const [startedAt, setStartedAt] = useState<string | null>(state?.session?.started_at ?? null);
35
  const [elapsedMs, setElapsedMs] = useState(0);
36
  const [error, setError] = useState<string | null>(null);
37
+ const [feedback, setFeedback] = useState<{ kind: "wrong" | "error"; message: string } | null>(null);
38
  const [submitting, setSubmitting] = useState(false);
39
 
40
  useEffect(() => {
 
47
  }
48
  setSession(payload);
49
  setBoardAscii(payload.payload.current_board_ascii);
 
50
  if (!payload.started_at) {
51
  const ready = await readySession(sessionId);
52
  if (!cancelled) {
 
83
  return;
84
  }
85
  setSubmitting(true);
86
+ setFeedback(null);
 
87
  try {
88
  const result = await submitSession(session.session_id, boardAscii);
89
  if (result.solved) {
 
96
  },
97
  });
98
  } else {
99
+ setFeedback({ kind: "wrong", message: "Not solved yet — keep trying." });
 
 
 
 
100
  }
101
  } catch (caught) {
102
+ setFeedback({
103
+ kind: "error",
104
+ message: caught instanceof Error ? caught.message : "Could not submit the puzzle.",
105
+ });
106
  } finally {
107
  setSubmitting(false);
108
  }
 
124
  return;
125
  }
126
  setBoardAscii(session.payload.current_board_ascii);
127
+ setFeedback(null);
 
128
  }
129
 
130
  if (!session) {
 
157
  boardAscii={boardAscii}
158
  onChange={setBoardAscii}
159
  />
160
+ {feedback ? (
161
+ <div
162
+ className={`submit-banner submit-banner-${feedback.kind}`}
163
+ role="status"
164
+ aria-live="polite"
165
+ >
166
+ {feedback.message}
167
+ </div>
168
+ ) : null}
169
  </section>
170
 
171
  <aside className="panel sidebar">
 
173
  Timer starts once the puzzle appears. The server keeps the official solve time.
174
  </div>
175
 
176
+ {(() => {
177
+ const family = PUZZLE_FAMILY_BY_TYPE[session.puzzle_type as PuzzleType];
178
+ return (
179
+ <div className="status-box rules-box">
180
+ <strong>How to play</strong>
181
+ <p>{family?.helpText ?? "Solve the puzzle, then submit to verify it."}</p>
182
+ {family?.tutorialUrl ? (
183
+ <a
184
+ className="tutorial-link"
185
+ href={family.tutorialUrl}
186
+ target="_blank"
187
+ rel="noopener noreferrer"
188
+ >
189
+ Watch tutorial →
190
+ </a>
191
+ ) : null}
192
+ </div>
193
+ );
194
+ })()}
195
 
196
  <div className="button-row">
197
  <button className="primary-button" type="button" onClick={onSubmit} disabled={submitting}>
 
204
  Choose Another Puzzle
205
  </button>
206
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  </aside>
208
  </div>
209
+
210
+ <Leaderboard puzzleId={session.puzzle_id} />
211
  </div>
212
  </div>
213
  );
space_app/db.py CHANGED
@@ -64,6 +64,28 @@ class SessionStore:
64
  ON sessions (player_name_norm, puzzle_type, difficulty, solved)
65
  """
66
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
  def create_session(
69
  self,
@@ -176,7 +198,28 @@ class SessionStore:
176
  elapsed_ms = max(0, int((submitted_at - started_at).total_seconds() * 1000))
177
  submission_count = int(session["submission_count"]) + 1
178
  status = "solved" if solved else "attempted"
 
 
 
179
  with self._connect() as conn:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  conn.execute(
181
  """
182
  UPDATE sessions
@@ -185,13 +228,13 @@ class SessionStore:
185
  WHERE id = ?
186
  """,
187
  (
188
- to_iso(submitted_at),
189
  elapsed_ms,
190
  submission_count,
191
  int(solved),
192
  status,
193
  submitted_artifact,
194
- json.dumps(verification_payload),
195
  session_id,
196
  ),
197
  )
@@ -199,6 +242,29 @@ class SessionStore:
199
  assert updated is not None
200
  return updated
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  def list_solves(self, filters: dict[str, str | None]) -> list[dict[str, Any]]:
203
  clauses = ["submitted_at IS NOT NULL"]
204
  params: list[str] = []
 
64
  ON sessions (player_name_norm, puzzle_type, difficulty, solved)
65
  """
66
  )
67
+ conn.execute(
68
+ """
69
+ CREATE TABLE IF NOT EXISTS submission_attempts (
70
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
71
+ session_id TEXT NOT NULL,
72
+ attempt_n INTEGER NOT NULL,
73
+ submitted_at TEXT NOT NULL,
74
+ elapsed_ms INTEGER NOT NULL,
75
+ board_ascii TEXT NOT NULL,
76
+ solved INTEGER NOT NULL,
77
+ board_valid INTEGER NOT NULL,
78
+ verification_payload TEXT NOT NULL,
79
+ FOREIGN KEY (session_id) REFERENCES sessions (id)
80
+ )
81
+ """
82
+ )
83
+ conn.execute(
84
+ """
85
+ CREATE INDEX IF NOT EXISTS idx_attempts_session
86
+ ON submission_attempts (session_id, attempt_n)
87
+ """
88
+ )
89
 
90
  def create_session(
91
  self,
 
198
  elapsed_ms = max(0, int((submitted_at - started_at).total_seconds() * 1000))
199
  submission_count = int(session["submission_count"]) + 1
200
  status = "solved" if solved else "attempted"
201
+ board_valid = bool(verification_payload.get("board_valid", False))
202
+ verification_json = json.dumps(verification_payload)
203
+ submitted_at_iso = to_iso(submitted_at)
204
  with self._connect() as conn:
205
+ conn.execute(
206
+ """
207
+ INSERT INTO submission_attempts (
208
+ session_id, attempt_n, submitted_at, elapsed_ms,
209
+ board_ascii, solved, board_valid, verification_payload
210
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
211
+ """,
212
+ (
213
+ session_id,
214
+ submission_count,
215
+ submitted_at_iso,
216
+ elapsed_ms,
217
+ submitted_artifact,
218
+ int(solved),
219
+ int(board_valid),
220
+ verification_json,
221
+ ),
222
+ )
223
  conn.execute(
224
  """
225
  UPDATE sessions
 
228
  WHERE id = ?
229
  """,
230
  (
231
+ submitted_at_iso,
232
  elapsed_ms,
233
  submission_count,
234
  int(solved),
235
  status,
236
  submitted_artifact,
237
+ verification_json,
238
  session_id,
239
  ),
240
  )
 
242
  assert updated is not None
243
  return updated
244
 
245
+ def list_leaderboard(
246
+ self,
247
+ *,
248
+ puzzle_id: str,
249
+ include_test: bool = False,
250
+ ) -> list[dict[str, Any]]:
251
+ params: list[Any] = [puzzle_id]
252
+ where = ["puzzle_filename = ?", "started_at IS NOT NULL"]
253
+ if not include_test:
254
+ where.append("LOWER(player_name_norm) != 'test'")
255
+ query = f"""
256
+ SELECT player_name_raw, elapsed_ms, submission_count, solved, started_at, submitted_at
257
+ FROM sessions
258
+ WHERE {' AND '.join(where)}
259
+ ORDER BY solved DESC,
260
+ CASE WHEN solved = 1 THEN elapsed_ms END ASC,
261
+ submission_count DESC,
262
+ started_at DESC
263
+ """
264
+ with self._connect() as conn:
265
+ rows = conn.execute(query, params).fetchall()
266
+ return [dict(row) for row in rows]
267
+
268
  def list_solves(self, filters: dict[str, str | None]) -> list[dict[str, Any]]:
269
  clauses = ["submitted_at IS NOT NULL"]
270
  params: list[str] = []
space_app/llm_results.json ADDED
The diff for this file is too large to render. See raw diff
 
space_app/llm_results.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ class LLMResultsStore:
9
+ def __init__(self, results: dict[str, dict[str, list[str]]]) -> None:
10
+ self._results = results
11
+
12
+ @classmethod
13
+ def from_file(cls, path: Path) -> "LLMResultsStore":
14
+ if not path.exists():
15
+ return cls({})
16
+ with path.open("r", encoding="utf-8") as f:
17
+ payload: dict[str, Any] = json.load(f)
18
+ results = payload.get("results", {}) if isinstance(payload, dict) else {}
19
+ clean: dict[str, dict[str, list[str]]] = {}
20
+ for puzzle_id, verdicts in results.items():
21
+ if not isinstance(verdicts, dict):
22
+ continue
23
+ solved = [str(m) for m in verdicts.get("models_solved", []) if isinstance(m, str)]
24
+ failed = [str(m) for m in verdicts.get("models_failed", []) if isinstance(m, str)]
25
+ clean[str(puzzle_id)] = {"models_solved": solved, "models_failed": failed}
26
+ return cls(clean)
27
+
28
+ def get_for_puzzle(self, puzzle_id: str) -> dict[str, list[str]] | None:
29
+ return self._results.get(puzzle_id)
space_app/main.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import os
 
4
  from pathlib import Path
5
  from typing import Any
6
 
@@ -12,9 +13,13 @@ from .board_utils import normalize_board_for_display, normalize_board_for_submis
12
  from .config import Settings
13
  from .dataset import DatasetStore
14
  from .db import SessionStore
 
15
  from .models import engine_for_puzzle, normalize_player_name
16
  from .schemas import (
17
  CreateSessionRequest,
 
 
 
18
  PuzzleOptionResponse,
19
  SessionPayload,
20
  SessionResponse,
@@ -58,6 +63,7 @@ def create_app(
58
  dataset_store: DatasetStore | None = None,
59
  session_store: SessionStore | None = None,
60
  verifier: VerificationService | None = None,
 
61
  ) -> FastAPI:
62
  resolved_settings = settings or Settings.from_env()
63
  resolved_settings.prepare()
@@ -66,6 +72,9 @@ def create_app(
66
  app.state.dataset_store = dataset_store or DatasetStore.load_huggingface()
67
  app.state.session_store = session_store or SessionStore(resolved_settings.database_path)
68
  app.state.verifier = verifier or VerificationService()
 
 
 
69
 
70
  def get_settings() -> Settings:
71
  return app.state.settings
@@ -79,6 +88,9 @@ def create_app(
79
  def get_verifier() -> VerificationService:
80
  return app.state.verifier
81
 
 
 
 
82
  def require_admin(
83
  authorization: str | None = Header(default=None),
84
  current_settings: Settings = Depends(get_settings),
@@ -127,15 +139,32 @@ def create_app(
127
  player_name_norm = normalize_player_name(player_name_raw)
128
  if not player_name_norm:
129
  raise HTTPException(status_code=400, detail="Player name is required.")
130
- try:
131
- row = current_dataset_store.get_row(request.puzzle_id)
132
- except KeyError:
133
- raise HTTPException(status_code=404, detail="Puzzle not found.") from None
134
- if row.puzzlename != request.puzzle_type or row.difficulty != request.difficulty:
135
- raise HTTPException(
136
- status_code=400,
137
- detail="Selected puzzle does not match the chosen family and difficulty.",
 
 
 
 
 
138
  )
 
 
 
 
 
 
 
 
 
 
 
 
139
  session = current_session_store.create_session(
140
  player_name_raw=player_name_raw,
141
  player_name_norm=player_name_norm,
@@ -217,6 +246,50 @@ def create_app(
217
  verification=verification,
218
  )
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  @app.get("/api/admin/solves", dependencies=[Depends(require_admin)])
221
  def admin_solves(
222
  player_name_norm: str | None = Query(default=None),
 
1
  from __future__ import annotations
2
 
3
  import os
4
+ import random
5
  from pathlib import Path
6
  from typing import Any
7
 
 
13
  from .config import Settings
14
  from .dataset import DatasetStore
15
  from .db import SessionStore
16
+ from .llm_results import LLMResultsStore
17
  from .models import engine_for_puzzle, normalize_player_name
18
  from .schemas import (
19
  CreateSessionRequest,
20
+ LeaderboardEntry,
21
+ LeaderboardResponse,
22
+ LLMResultsResponse,
23
  PuzzleOptionResponse,
24
  SessionPayload,
25
  SessionResponse,
 
63
  dataset_store: DatasetStore | None = None,
64
  session_store: SessionStore | None = None,
65
  verifier: VerificationService | None = None,
66
+ llm_results_store: LLMResultsStore | None = None,
67
  ) -> FastAPI:
68
  resolved_settings = settings or Settings.from_env()
69
  resolved_settings.prepare()
 
72
  app.state.dataset_store = dataset_store or DatasetStore.load_huggingface()
73
  app.state.session_store = session_store or SessionStore(resolved_settings.database_path)
74
  app.state.verifier = verifier or VerificationService()
75
+ app.state.llm_results_store = llm_results_store or LLMResultsStore.from_file(
76
+ Path(__file__).parent / "llm_results.json"
77
+ )
78
 
79
  def get_settings() -> Settings:
80
  return app.state.settings
 
88
  def get_verifier() -> VerificationService:
89
  return app.state.verifier
90
 
91
+ def get_llm_results_store() -> LLMResultsStore:
92
+ return app.state.llm_results_store
93
+
94
  def require_admin(
95
  authorization: str | None = Header(default=None),
96
  current_settings: Settings = Depends(get_settings),
 
139
  player_name_norm = normalize_player_name(player_name_raw)
140
  if not player_name_norm:
141
  raise HTTPException(status_code=400, detail="Player name is required.")
142
+ if request.puzzle_id is None:
143
+ try:
144
+ pool = current_dataset_store.list_rows(
145
+ puzzle_type=request.puzzle_type,
146
+ difficulty=request.difficulty,
147
+ limit=50,
148
+ )
149
+ except ValueError as exc:
150
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
151
+ already_solved = current_session_store.get_solved_filenames(
152
+ player_name_norm=player_name_norm,
153
+ puzzle_type=request.puzzle_type,
154
+ difficulty=request.difficulty,
155
  )
156
+ unseen = [item for item in pool if item.filename not in already_solved]
157
+ row = random.choice(unseen) if unseen else random.choice(pool)
158
+ else:
159
+ try:
160
+ row = current_dataset_store.get_row(request.puzzle_id)
161
+ except KeyError:
162
+ raise HTTPException(status_code=404, detail="Puzzle not found.") from None
163
+ if row.puzzlename != request.puzzle_type or row.difficulty != request.difficulty:
164
+ raise HTTPException(
165
+ status_code=400,
166
+ detail="Selected puzzle does not match the chosen family and difficulty.",
167
+ )
168
  session = current_session_store.create_session(
169
  player_name_raw=player_name_raw,
170
  player_name_norm=player_name_norm,
 
246
  verification=verification,
247
  )
248
 
249
+ @app.get("/api/llm-results/{puzzle_id}", response_model=LLMResultsResponse)
250
+ def llm_results(
251
+ puzzle_id: str,
252
+ current_llm_results_store: LLMResultsStore = Depends(get_llm_results_store),
253
+ ) -> LLMResultsResponse:
254
+ verdicts = current_llm_results_store.get_for_puzzle(puzzle_id)
255
+ if verdicts is None:
256
+ raise HTTPException(
257
+ status_code=404,
258
+ detail="No LLM results recorded for this puzzle.",
259
+ )
260
+ return LLMResultsResponse(
261
+ puzzle_id=puzzle_id,
262
+ models_solved=verdicts["models_solved"],
263
+ models_failed=verdicts["models_failed"],
264
+ )
265
+
266
+ @app.get("/api/admin/check", dependencies=[Depends(require_admin)])
267
+ def admin_check() -> dict[str, bool]:
268
+ return {"ok": True}
269
+
270
+ @app.get("/api/admin/leaderboard/{puzzle_id}", response_model=LeaderboardResponse, dependencies=[Depends(require_admin)])
271
+ def admin_leaderboard(
272
+ puzzle_id: str,
273
+ include_test: bool = Query(default=False),
274
+ current_session_store: SessionStore = Depends(get_session_store),
275
+ ) -> LeaderboardResponse:
276
+ rows = current_session_store.list_leaderboard(
277
+ puzzle_id=puzzle_id,
278
+ include_test=include_test,
279
+ )
280
+ entries = [
281
+ LeaderboardEntry(
282
+ player_name=str(row["player_name_raw"]),
283
+ elapsed_ms=row["elapsed_ms"],
284
+ submission_count=int(row["submission_count"]),
285
+ solved=bool(row["solved"]),
286
+ started_at=row["started_at"],
287
+ submitted_at=row["submitted_at"],
288
+ )
289
+ for row in rows
290
+ ]
291
+ return LeaderboardResponse(puzzle_id=puzzle_id, entries=entries)
292
+
293
  @app.get("/api/admin/solves", dependencies=[Depends(require_admin)])
294
  def admin_solves(
295
  player_name_norm: str | None = Query(default=None),
space_app/schemas.py CHANGED
@@ -11,7 +11,7 @@ class CreateSessionRequest(BaseModel):
11
  player_name: str = Field(min_length=1, max_length=80)
12
  puzzle_type: str
13
  difficulty: str
14
- puzzle_id: str = Field(min_length=1)
15
 
16
  @field_validator("puzzle_type")
17
  @classmethod
@@ -64,3 +64,23 @@ class SubmitResponse(BaseModel):
64
  elapsed_ms: int | None
65
  status: str
66
  verification: dict[str, Any]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  player_name: str = Field(min_length=1, max_length=80)
12
  puzzle_type: str
13
  difficulty: str
14
+ puzzle_id: str | None = Field(default=None, min_length=1)
15
 
16
  @field_validator("puzzle_type")
17
  @classmethod
 
64
  elapsed_ms: int | None
65
  status: str
66
  verification: dict[str, Any]
67
+
68
+
69
+ class LLMResultsResponse(BaseModel):
70
+ puzzle_id: str
71
+ models_solved: list[str]
72
+ models_failed: list[str]
73
+
74
+
75
+ class LeaderboardEntry(BaseModel):
76
+ player_name: str
77
+ elapsed_ms: int | None
78
+ submission_count: int
79
+ solved: bool
80
+ started_at: str | None
81
+ submitted_at: str | None
82
+
83
+
84
+ class LeaderboardResponse(BaseModel):
85
+ puzzle_id: str
86
+ entries: list[LeaderboardEntry]