diff --git a/server.py b/server.py index 568e4b0..7802ab9 100644 --- a/server.py +++ b/server.py @@ -310,6 +310,19 @@ def public_press(p): } +def public_leaderboard(): + """Public score totals only; do not expose sessions or individual presses.""" + scores = { + pid: {"name": player["name"], "total_points": 0, "press_count": 0} + for pid, player in state["players"].items() + } + for press in state["presses"]: + score = scores[press["player_id"]] + score["total_points"] += press["points"] + score["press_count"] += 1 + return sorted(scores.values(), key=lambda p: (-p["total_points"], p["name"].lower())) + + def global_stats(): counts = empty_zone_counts() for p in state["presses"]: @@ -397,6 +410,9 @@ class Handler(BaseHTTPRequestHandler): "total_presses": len(state["presses"]), "zones": ZONES, }) + if path == "/api/leaderboard": + with lock: + return self.send_json(HTTPStatus.OK, {"leaderboard": public_leaderboard()}) if path == "/api/me": with lock: pid = self.authed_player() diff --git a/static/app.js b/static/app.js index c93318a..1cabb61 100644 --- a/static/app.js +++ b/static/app.js @@ -9,6 +9,40 @@ let token = localStorage.getItem(TOKEN_KEY); let statsLoaded = false; let subscribed = false; let currentShareText = ""; +let selectedSection = null; + +function renderSections() { + const available = { + "account-section": true, + "leaderboard-section": true, + "stats-section": statsLoaded, + rules: true, + "mailing-list": !!token && !subscribed, + "share-controls": !!currentShareText, + more: true, + donate: true, + }; + if (!available[selectedSection]) selectedSection = null; + document.querySelectorAll("[data-section]").forEach((button) => { + const id = button.dataset.section; + button.hidden = !available[id]; + button.setAttribute("aria-expanded", String(selectedSection === id)); + if (id === "account-section") button.textContent = token ? "Your score" : "Log in / Join"; + }); + document.querySelectorAll("[data-panel]").forEach((panel) => { + panel.hidden = panel.id !== selectedSection; + }); + $("section-hint").hidden = selectedSection !== null; +} + +$("section-nav").addEventListener("click", (event) => { + const button = event.target.closest("[data-section]"); + if (!button) return; + selectedSection = selectedSection === button.dataset.section ? null : button.dataset.section; + renderSections(); + if (selectedSection === "account-section") loadMe(); + if (selectedSection === "leaderboard-section") loadLeaderboard(); +}); async function api(method, path, body) { const headers = { "Content-Type": "application/json" }; @@ -150,13 +184,15 @@ async function refreshState() { if (first) buildStaticUi(); if (game.ended && !statsLoaded) loadStats(); updateButton(); + await loadLeaderboard(); } function updateButton() { $("the-button").disabled = !token || !game || game.ended; $("register-section").hidden = !!token; - $("mailing-list").hidden = !token || subscribed; + $("me-section").hidden = !token; $("my-subscribed").hidden = !token || !subscribed; + renderSections(); } function tick() { @@ -179,7 +215,6 @@ async function loadMe() { if (!token) return; try { const me = await api("GET", "/api/me"); - $("me-section").hidden = false; $("my-name").textContent = me.name; $("my-points").textContent = me.total_points.toLocaleString(); $("my-presses").textContent = me.press_count; @@ -202,11 +237,28 @@ async function loadMe() { } } +async function loadLeaderboard() { + try { + const { leaderboard } = await api("GET", "/api/leaderboard"); + fillTable( + $("leaderboard"), + ["Rank", "Player", "Points", "Presses"], + leaderboard.map((player, index) => [ + index + 1, player.name, player.total_points.toLocaleString(), player.press_count.toLocaleString(), + ]), + ); + $("leaderboard").hidden = leaderboard.length === 0; + $("leaderboard-status").textContent = leaderboard.length ? "" : "No players yet. Be the first to join!"; + } catch { + $("leaderboard-status").textContent = "Scores could not be refreshed. Retrying automatically..."; + } +} + async function loadStats() { try { const s = await api("GET", "/api/stats"); statsLoaded = true; - $("stats-section").hidden = false; + renderSections(); const summary = $("stats-summary"); summary.replaceChildren( el("p", `Lasted ${formatMs(s.ended_at - s.started_at)} \u00b7 ${s.total_players} players \u00b7 ${s.total_presses} presses`), @@ -242,8 +294,8 @@ async function loadStats() { $("the-button").addEventListener("click", async () => { const btn = $("the-button"); btn.disabled = true; - $("share-controls").hidden = true; currentShareText = ""; + renderSections(); try { const r = await api("POST", "/api/press"); game.deadline = r.deadline; @@ -251,8 +303,8 @@ $("the-button").addEventListener("click", async () => { $("press-result").textContent = `You pressed in the ${r.press.zone} zone with ${formatPrecise(r.press.remaining_ms)} remaining: +${r.press.points} points`; updateShareLinks(); - $("share-controls").hidden = false; await loadMe(); + await loadLeaderboard(); } catch (e) { $("press-result").textContent = e.message; await refreshState().catch(() => {}); @@ -277,6 +329,7 @@ $("register-form").addEventListener("submit", async (ev) => { $("password-input").value = ""; updateButton(); await loadMe(); + await loadLeaderboard(); } catch (e) { $("register-error").textContent = e.message; } diff --git a/static/index.html b/static/index.html index 4555313..3599865 100644 --- a/static/index.html +++ b/static/index.html @@ -16,6 +16,7 @@
+

THE BUTTON

@@ -28,7 +29,24 @@

-
+
+ +
+ +

Select a section above to explore the game.

+ +
- + -