Spaces:
Running
Add admin leaderboard, LLM comparison, random puzzles, clearer rules
Browse filesFrontend
- 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 +2 -0
- frontend/src/components/AdminButton.tsx +91 -0
- frontend/src/components/Leaderboard.tsx +130 -0
- frontend/src/index.css +321 -0
- frontend/src/lib/admin.ts +71 -0
- frontend/src/lib/api.ts +46 -1
- frontend/src/lib/puzzles.ts +66 -0
- frontend/src/routes/DonePage.tsx +107 -5
- frontend/src/routes/HomePage.tsx +88 -48
- frontend/src/routes/PlayPage.tsx +41 -39
- space_app/db.py +68 -2
- space_app/llm_results.json +0 -0
- space_app/llm_results.py +29 -0
- space_app/main.py +81 -8
- space_app/schemas.py +21 -1
|
@@ -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 />} />
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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;
|
|
@@ -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 |
+
}
|
|
@@ -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,
|
|
@@ -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>;
|
|
@@ -1,6 +1,9 @@
|
|
| 1 |
-
import {
|
|
|
|
| 2 |
|
| 3 |
-
import
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
);
|
|
@@ -9,17 +9,29 @@ import {
|
|
| 9 |
type PuzzleOption,
|
| 10 |
type PuzzleType,
|
| 11 |
} from "../lib/api";
|
|
|
|
| 12 |
|
| 13 |
-
const
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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(
|
| 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: {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
| 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 |
-
{
|
| 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 |
-
|
| 155 |
-
<
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
<
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
| 180 |
</div>
|
| 181 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
</button>
|
| 193 |
</div>
|
| 194 |
</form>
|
| 195 |
</section>
|
| 196 |
|
| 197 |
<section className="panel card hint-grid">
|
| 198 |
-
{
|
| 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>
|
|
@@ -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 [
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 116 |
} finally {
|
| 117 |
setSubmitting(false);
|
| 118 |
}
|
|
@@ -134,8 +124,7 @@ export function PlayPage() {
|
|
| 134 |
return;
|
| 135 |
}
|
| 136 |
setBoardAscii(session.payload.current_board_ascii);
|
| 137 |
-
|
| 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 |
-
{
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
);
|
|
@@ -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 |
-
|
| 189 |
elapsed_ms,
|
| 190 |
submission_count,
|
| 191 |
int(solved),
|
| 192 |
status,
|
| 193 |
submitted_artifact,
|
| 194 |
-
|
| 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] = []
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -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)
|
|
@@ -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 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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),
|
|
@@ -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]
|