diff --git a/README.md b/README.md index f205c33..6c15307 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,33 @@ The Chat section is a simple public chatroom. Logged-in players can post plain-t Every successful button press also posts an automatic chat announcement with the player's name, exact time remaining, and points earned. These announcements are saved with the press and do not count toward the player's chat cooldown. +## Engagement roadmap + +The goal is to make waiting social and consequential without rewarding button spam or changing the current game's rules. Planned delivery order: + +1. **Optional live presence — implemented.** Show participating players watching the clock and who is viewing Chat. Off by default; ephemeral heartbeats, automatic expiry, and no activity history. See the details below. +2. **Personal bests and rivalries — implemented.** Your score shows closest call, highest-scoring press, leaderboard position, and the player immediately ahead with points needed to catch up. Existing history is credited automatically; no data migration is needed. +3. **Achievements.** Start with Close call (under a minute remaining), Full spectrum (every zone), and Keeping us alive (a meaningful quiet stretch; threshold to be decided). Credit existing history where possible. Avoid rewards for raw press volume or repeated resets. +4. **Infrequent community milestones.** Announce survival milestones, notable press totals, and new closest-call records in chat. Deduplicate announcements and keep them rare. Define thresholds and historic backfill behavior before implementation. +5. **Optional zone alerts.** Let players choose a zone and opt into sound or browser notifications. Request permissions only after an explicit action, alert on transitions rather than polls, and handle resets and denied permissions. Closed-browser delivery is separate work, not promised by basic tab notifications. +6. **Separate one-life mode — exploratory.** Allow one press per player per round in a separate mode with its own rounds and leaderboard. Never silently change the current game or reset its data. + +Keep new features consistent with the Catppuccin Mocha styling and selectable-section navigation. Preserve visible countdown/button controls on desktop and mobile, authenticated mutations, bounded input, safe text rendering, and server-authoritative scoring. Verify changes using isolated databases rather than live game data. + +### Personal bests and rivalries + +Your score includes private personal records derived from your existing press history. Closest call means the least time remaining; highest-scoring press means the most points in a single press. Ties prefer the lower remaining time, then the earlier press. A zero-point press is still a valid record; new players see placeholders until their first press. + +Leaderboard positions match the public ordering (points descending, then name alphabetically), not shared competition ranks. The rival is the player immediately above you, including alphabetical ties. The score card shows the gap to match their total and the minimum additional whole-number points to exceed it; actual presses still award fixed zone values, so you may jump past that threshold. Equal top scores are described as a shared lead; solo players and outright leaders receive their own messages. These cards refresh every five seconds while Your score is open and after pressing. Rival information exposes only public names and totals, never another player's records or press history. Achievements are the next roadmap step. + +### Live presence + +Logged-in players can enable **Show me online** in their score section. Presence is off by default. The count near the clock includes only opted-in players with a visible tab; Chat also lists opted-in players currently viewing that section. Multiple tabs/sessions count as one player. Updates run every 15 seconds, and stale presence expires after 45 seconds. Hiding a tab or opting out removes that tab's presence; logging out removes all presence for that session. Failed disconnect updates fall back to expiry. Presence lives only in server memory and is cleared on restart. Guests and players who leave the setting off are not tracked, so the count is not a total visitor count. + +Public `GET /api/presence` returns `{online_count, chat_players}`. Authenticated `POST /api/presence` accepts `{tab_id, active, in_chat}` with a 32-character lowercase hexadecimal tab ID and boolean flags. At most 10 active tabs per login session are accepted. No player IDs, session IDs, or last-seen timestamps are returned publicly. + +Presence tests cover authentication, validation, expiry and heartbeat renewal, tab/session deduplication, chat membership, revoked sessions, bounded entries, and logout. Browser checks cover opt-in/out, tab visibility changes, chat membership, logout, and responsive layout. + ## Running ### Docker (recommended) @@ -96,6 +123,8 @@ Tip: `BUTTON_DURATION_SECONDS=60` makes for quick testing. | GET | `/api/leaderboard` | | Public player names, total points, and press counts | | GET | `/api/chat` | | Latest 100 public chat messages | | POST | `/api/chat` | ✓ | `{message}` → saved message; 200-character limit | +| GET | `/api/presence` | | Opted-in online count and chat display names | +| POST | `/api/presence` | ✓ | `{tab_id, active, in_chat}` presence heartbeat or removal | | GET | `/api/stats` | | Global stats (403 until the game ends) | | POST | `/api/subscribe` | ✓ | `{first_name, email, last_name?, phone?}` | diff --git a/server.py b/server.py index 87df8f6..3cac784 100644 --- a/server.py +++ b/server.py @@ -37,6 +37,8 @@ LOGIN_LOCKOUT_MS = 15 * 60 * 1000 MAX_CHAT_LENGTH = 200 CHAT_HISTORY_LIMIT = 100 CHAT_COOLDOWN_MS = 2000 +PRESENCE_TTL_SECONDS = 45 +MAX_PRESENCE_TABS_PER_SESSION = 10 # Ordered from a freshly reset countdown to an almost-expired one. # "share" is each zone's relative slice of the timer, so high-value zones are brief. @@ -247,6 +249,26 @@ def save_state(state): state = load_state() token_index = {th: pid for pid, p in state["players"].items() for th in p["token_hashes"]} failed_logins = {} # lowercase name -> (failure count, first failure ms) +presence = {} # (token hash, tab id) -> (monotonic heartbeat seconds, viewing chat) + + +def prune_presence(): + """Must be called with the lock held. Presence is never persisted.""" + cutoff = time.monotonic() - PRESENCE_TTL_SECONDS + for key, (seen, _) in list(presence.items()): + if seen <= cutoff or key[0] not in token_index: + del presence[key] + + +def public_presence(): + """Public aggregates and opted-in chat names only; no session identifiers.""" + prune_presence() + online = {token_index[th] for th, _ in presence} + chatting = {token_index[th] for (th, _), (_, in_chat) in presence.items() if in_chat} + return { + "online_count": len(online), + "chat_players": sorted((state["players"][pid]["name"] for pid in chatting), key=str.lower), + } def chat_messages(): @@ -308,6 +330,16 @@ def player_summary(pid): counts = empty_zone_counts() for p in presses: counts[p["zone"]] += 1 + standings = ranked_players() + position = next(i for i, (player_id, _) in enumerate(standings) if player_id == pid) + points = standings[position][1]["total_points"] + rival = standings[position - 1][1] if position else None + + def record(press): + return {key: press[key] for key in ("at", "remaining_ms", "zone", "points")} if press else None + + closest = min(presses, key=lambda p: (p["remaining_ms"], p["at"]), default=None) + best = min(presses, key=lambda p: (-p["points"], p["remaining_ms"], p["at"]), default=None) return { "name": state["players"][pid]["name"], "subscribed": state["players"][pid].get("subscribed", False), @@ -317,6 +349,19 @@ def player_summary(pid): "press_count": len(presses), "zone_counts": counts, "best_remaining_ms": min((p["remaining_ms"] for p in presses), default=None), + "personal_bests": {"closest_call": record(closest), "highest_scoring_press": record(best)}, + "ranking": { + "position": position + 1, + "total_players": len(standings), + "tied_for_lead": len(standings) > 1 and points == standings[0][1]["total_points"] + and sum(score["total_points"] == points for _, score in standings) > 1, + "rival": { + "name": rival["name"], + "total_points": rival["total_points"], + "points_to_tie": rival["total_points"] - points, + "points_to_pass": rival["total_points"] - points + 1, + } if rival else None, + }, "presses": [ {k: p[k] for k in ("at", "remaining_ms", "zone", "points")} for p in presses ], @@ -333,8 +378,8 @@ def public_press(p): } -def public_leaderboard(): - """Public score totals only; do not expose sessions or individual presses.""" +def ranked_players(): + """Internal standings retain IDs to identify players even in legacy data.""" scores = { pid: {"name": player["name"], "total_points": 0, "press_count": 0} for pid, player in state["players"].items() @@ -343,7 +388,12 @@ def public_leaderboard(): 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())) + return sorted(scores.items(), key=lambda item: (-item[1]["total_points"], item[1]["name"].lower())) + + +def public_leaderboard(): + """Public score totals only; do not expose sessions or individual presses.""" + return [score for _, score in ranked_players()] def global_stats(): @@ -353,7 +403,7 @@ def global_stats(): leaderboard = [] for pid in state["players"]: s = player_summary(pid) - del s["presses"], s["subscribed"] + del s["presses"], s["subscribed"], s["personal_bests"], s["ranking"] leaderboard.append(s) leaderboard.sort(key=lambda s: (-s["total_points"], s["name"].lower())) presses = state["presses"] @@ -438,6 +488,9 @@ class Handler(BaseHTTPRequestHandler): return self.send_json(HTTPStatus.OK, {"leaderboard": public_leaderboard()}) if path == "/api/chat": return self.send_json(HTTPStatus.OK, {"messages": chat_messages()}) + if path == "/api/presence": + with lock: + return self.send_json(HTTPStatus.OK, public_presence()) if path == "/api/me": with lock: pid = self.authed_player() @@ -469,6 +522,26 @@ class Handler(BaseHTTPRequestHandler): except (ValueError, UnicodeDecodeError): return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid JSON body"}) + if path == "/api/presence": + with lock: + th = self.bearer_hash() + if th not in token_index: + return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Log in to share your presence"}) + tab = data.get("tab_id") + active, in_chat = data.get("active"), data.get("in_chat", False) + if (not isinstance(tab, str) or re.fullmatch(r"[a-f0-9]{32}", tab) is None + or not isinstance(active, bool) or not isinstance(in_chat, bool)): + return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid presence update"}) + prune_presence() + key = (th, tab) + if not active: + presence.pop(key, None) + else: + if key not in presence and sum(k[0] == th for k in presence) >= MAX_PRESENCE_TABS_PER_SESSION: + return self.send_json(HTTPStatus.TOO_MANY_REQUESTS, {"error": "Too many active tabs"}) + presence[key] = (time.monotonic(), in_chat) + return self.send_json(HTTPStatus.OK, public_presence()) + if path == "/api/chat": with lock: pid = self.authed_player() @@ -545,6 +618,7 @@ class Handler(BaseHTTPRequestHandler): with lock: th = self.bearer_hash() pid = token_index.pop(th, None) if th else None + prune_presence() if pid is not None: state["players"][pid]["token_hashes"].remove(th) save_state(state) diff --git a/static/app.js b/static/app.js index 21e9c5f..e9cfaac 100644 --- a/static/app.js +++ b/static/app.js @@ -12,6 +12,73 @@ let currentShareText = ""; let selectedSection = null; let chatSignature = null; let chatLoading = false; +const PRESENCE_KEY = "thebutton-share-presence"; +let sharePresence = localStorage.getItem(PRESENCE_KEY) === "true"; +let presenceTab = sessionStorage.getItem("thebutton-presence-tab"); +if (!presenceTab) { + presenceTab = Array.from(crypto.getRandomValues(new Uint8Array(16)), (n) => n.toString(16).padStart(2, "0")).join(""); + sessionStorage.setItem("thebutton-presence-tab", presenceTab); +} +let presenceQueue = Promise.resolve(); +let lastPresenceUpdate = null; + +function renderPresence(data) { + const count = data.online_count; + const countText = `${count.toLocaleString()} ${count === 1 ? "player" : "players"} watching`; + if ($("presence-count").textContent !== countText) $("presence-count").textContent = countText; + $("chat-presence-summary").textContent = data.chat_players.length + ? `${data.chat_players.length} ${data.chat_players.length === 1 ? "player" : "players"} in the lounge` + : "No players sharing chat presence yet"; + const list = $("chat-presence-names"); + // Player names are text, never HTML. + if (list.dataset.names !== JSON.stringify(data.chat_players)) { + list.replaceChildren(...data.chat_players.map((name) => el("li", name))); + list.dataset.names = JSON.stringify(data.chat_players); + } +} + +function syncPresence() { + presenceQueue = presenceQueue.then(async () => { + const active = !!token && sharePresence && !document.hidden; + const inChat = active && selectedSection === "chat-section"; + const changed = !lastPresenceUpdate || lastPresenceUpdate.token !== token + || lastPresenceUpdate.active !== active || lastPresenceUpdate.inChat !== inChat; + const due = active && (!lastPresenceUpdate || performance.now() - lastPresenceUpdate.at >= 15000); + if (token && (changed || due) && (active || lastPresenceUpdate?.active)) { + const data = await api("POST", "/api/presence", { tab_id: presenceTab, active, in_chat: inChat }); + lastPresenceUpdate = { token, active, inChat, at: performance.now() }; + $("presence-error").textContent = ""; + renderPresence(data); + } else if (!document.hidden) { + renderPresence(await api("GET", "/api/presence")); + } + }).catch((error) => { + $("presence-count").textContent = "Online status temporarily unavailable"; + if (token && sharePresence) $("presence-error").textContent = "Couldn't update online status. Retrying automatically."; + if (error.status === 401) { + token = null; + localStorage.removeItem(TOKEN_KEY); + lastPresenceUpdate = null; + updateButton(); + } + }); + return presenceQueue; +} + +$("presence-opt-in").addEventListener("change", () => { + sharePresence = $("presence-opt-in").checked; + localStorage.setItem(PRESENCE_KEY, String(sharePresence)); + syncPresence(); +}); +document.addEventListener("visibilitychange", syncPresence); +window.addEventListener("storage", (event) => { + if (event.key === PRESENCE_KEY || event.key === TOKEN_KEY) { + sharePresence = localStorage.getItem(PRESENCE_KEY) === "true"; + token = localStorage.getItem(TOKEN_KEY); + updateButton(); + syncPresence(); + } +}); function renderSections() { const available = { @@ -38,6 +105,7 @@ function renderSections() { $("chat-form").hidden = !token; $("chat-login").hidden = !!token; $("section-hint").hidden = selectedSection !== null; + $("presence-opt-in").checked = sharePresence; } $("section-nav").addEventListener("click", (event) => { @@ -48,6 +116,7 @@ $("section-nav").addEventListener("click", (event) => { if (selectedSection === "account-section") loadMe(); if (selectedSection === "leaderboard-section") loadLeaderboard(); if (selectedSection === "chat-section") loadChat(); + syncPresence(); }); async function api(method, path, body) { @@ -190,8 +259,10 @@ async function refreshState() { if (first) buildStaticUi(); if (game.ended && !statsLoaded) loadStats(); updateButton(); + await syncPresence(); await loadLeaderboard(); if (selectedSection === "chat-section") await loadChat(); + if (selectedSection === "account-section") await loadMe(); } function updateButton() { @@ -225,6 +296,31 @@ async function loadMe() { $("my-name").textContent = me.name; $("my-points").textContent = me.total_points.toLocaleString(); $("my-presses").textContent = me.press_count; + const closest = me.personal_bests.closest_call; + const best = me.personal_bests.highest_scoring_press; + $("my-closest-call").textContent = closest ? formatPrecise(closest.remaining_ms) : "--"; + $("my-closest-detail").textContent = closest + ? `${closest.zone} zone / ${new Date(closest.at).toLocaleDateString()}` + : "Your first press starts the story."; + $("my-best-press").textContent = best ? `${best.points.toLocaleString()} pts` : "--"; + $("my-best-detail").textContent = best + ? `${best.zone} zone / ${formatPrecise(best.remaining_ms)} left` + : "No presses yet. Wait for your moment."; + const ranking = me.ranking; + $("my-position").textContent = `#${ranking.position} of ${ranking.total_players}`; + const rival = ranking.rival; + if (rival) { + $("my-rival-title").textContent = rival.points_to_tie === 0 ? `Tied with ${rival.name}` : `Next up: ${rival.name}`; + $("my-rival-description").textContent = rival.points_to_tie === 0 + ? `You're both on ${me.total_points.toLocaleString()} points. Gain points to move ahead.` + : `${rival.points_to_tie.toLocaleString()} more points to match their ${rival.total_points.toLocaleString()}; ${rival.points_to_pass.toLocaleString()} to beat it.`; + } else { + $("my-rival-title").textContent = ranking.total_players === 1 ? "The first to take a seat." : ranking.tied_for_lead ? "Sharing the lead." : "You're setting the pace."; + $("my-rival-description").textContent = ranking.total_players === 1 + ? "You're the only player so far. Your next challenge is your own best." : ranking.tied_for_lead + ? "You're first alphabetically among equal top scores. Gain points to take a clear lead." + : "You're at the top of the leaderboard. Make your next press count."; + } subscribed = me.subscribed; updateButton(); fillTable($("my-zones"), ["Zone", "Points each", "Your presses"], zoneRows(me.zone_counts)); @@ -394,6 +490,7 @@ $("register-form").addEventListener("submit", async (ev) => { }); token = r.token; localStorage.setItem(TOKEN_KEY, token); + syncPresence(); $("password-input").value = ""; updateButton(); await loadMe(); @@ -412,6 +509,7 @@ $("logout-button").addEventListener("click", async () => { $("press-result").textContent = ""; $("subscribe-result").textContent = ""; updateButton(); + syncPresence(); }); $("subscribe-form").addEventListener("submit", async (ev) => { diff --git a/static/index.html b/static/index.html index 725bfc7..7321c22 100644 --- a/static/index.html +++ b/static/index.html @@ -26,6 +26,7 @@

+

Checking who's here…

@@ -110,10 +111,26 @@
+
+ +

Optional. Share that you're here, and your name in chat while viewing it. Only visible tabs count; no activity history is saved.

+

+
Total points0Every moment counts
Button presses0Times you kept us going
+

Your personal bests

+
+
Closest call—Your first press starts the story.
+
Highest-scoring press—Wait for your moment.
+
+
+

YOUR NEXT CHALLENGE

+

Finding your place…

+

+

Positions follow the leaderboard: total points, then player name for ties. Any positive points gain puts you ahead of an equal score.

+

Your scoring zones

@@ -147,6 +164,10 @@
Public room +
+

Who's in the lounge

+ +
ConversationLatest 100 messages
    diff --git a/static/style.css b/static/style.css index a1ce5f4..e5bb3c0 100644 --- a/static/style.css +++ b/static/style.css @@ -196,6 +196,12 @@ details[open] summary { margin-bottom: 1rem; } .metric-card strong { display: block; font-size: clamp(1.7rem, 3vw, 2.4rem); color: var(--mauve); line-height: 1.5; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } .metric-card:nth-child(2) strong { color: var(--teal); } .metric-card small { font-size: 0.65rem; color: var(--subtext); } +.personal-records .metric-card strong { font-size: clamp(1rem, 1.8vw, 1.35rem); letter-spacing: -0.04em; padding-block: 0.4rem; } +.personal-records .metric-card small { display: block; line-height: 1.6; } +.rival-card { padding: 1.1rem; margin-top: 1rem; border: 1px solid #cba6f730; border-radius: 12px; background: linear-gradient(110deg, #cba6f70c, transparent), var(--base); } +.rival-card #my-rival-title { margin: 0.8rem 0 0.4rem; font-size: 1rem; color: var(--lavender); overflow-wrap: anywhere; } +#my-rival-description { font-size: 0.85rem; color: var(--text); margin: 0; } +.rival-card .fine-print { font-size: 0.65rem; margin-bottom: 0; } #my-subscribed { padding: 0.65rem; border-radius: 8px; background: #a6e3a110; color: var(--green); font-size: 0.75rem; } #leaderboard-status:empty { display: none; } @@ -367,6 +373,19 @@ stripe-buy-button { display: block; max-width: 100%; margin: 1.5rem 0; } #chat-login { margin: 0; color: var(--lavender); font-size: 0.85rem; line-height: 1.6; } #chat-status:empty, #chat-error:empty { display: none; } +.game-panel #presence-count { color: var(--teal); font-size: 0.7rem; margin: 0.65rem 0 0; } +.presence-count::before { content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 0.4rem; background: var(--teal); } +.presence-preference { border: 1px solid var(--surface); border-radius: 12px; background: var(--base); padding: 0.9rem 1rem; margin-bottom: 1rem; } +.presence-preference label { display: flex; align-items: center; gap: 0.6rem; font-size: 0.8rem; font-weight: 600; color: var(--lavender); cursor: pointer; } +#presence-opt-in { width: 18px; height: 18px; min-height: 0; margin: 0; padding: 0; accent-color: var(--mauve); flex-shrink: 0; } +#presence-help { margin: 0.5rem 0 0; font-size: 0.7rem; line-height: 1.6; color: var(--subtext); } +#presence-error { margin-bottom: 0; font-size: 0.75rem; } +.chat-presence { padding: 0.9rem 1.5rem; border-bottom: 1px solid var(--surface); background: #94e2d504; } +#chat-presence-summary { font-size: 0.7rem; color: var(--subtext); margin: 0; } +#chat-presence-names { display: flex; flex-wrap: wrap; gap: 0.4rem; list-style: none; padding: 0; margin: 0.5rem 0 0; } +#chat-presence-names:empty { display: none; } +#chat-presence-names li { font-size: 0.7rem; padding: 0.15rem 0.55rem; border-radius: 20px; color: var(--teal); background: #94e2d50c; border: 1px solid #94e2d525; overflow-wrap: anywhere; max-width: 100%; } + #rules { text-align: left; font-size: 0.9rem; color: var(--subtext); } @media (max-width: 760px) { diff --git a/test_server.py b/test_server.py index c397353..536036d 100755 --- a/test_server.py +++ b/test_server.py @@ -36,6 +36,7 @@ class PersistenceTests(unittest.TestCase): self.game.MAILING_LIST_FILE = root / "mailing.csv" self.game.failed_logins.clear() self.game.subscribed_emails.clear() + self.game.presence.clear() def sample_state(self): state = self.game.new_game() @@ -65,6 +66,165 @@ class PersistenceTests(unittest.TestCase): def write_legacy(self, state): self.game.DATA_FILE.write_text(json.dumps(state), encoding="utf-8") + def presence_client(self): + self.game.load_state() + self.game.state = self.sample_state() + self.game.state["players"]["alice"]["token_hashes"] = [self.game.hash_token("alice-token"), self.game.hash_token("alice-second")] + self.game.state["players"]["bob"]["token_hashes"] = [self.game.hash_token("bob-token")] + self.game.save_state(self.game.state) + self.game.token_index = { + th: pid for pid, player in self.game.state["players"].items() for th in player["token_hashes"] + } + httpd = ThreadingHTTPServer(("127.0.0.1", 0), self.game.Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + self.addCleanup(httpd.server_close) + self.addCleanup(thread.join) + self.addCleanup(httpd.shutdown) + + def request(method="GET", data=None, token=None, path="/api/presence"): + connection = HTTPConnection(*httpd.server_address, timeout=5) + try: + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = "Bearer " + token + connection.request(method, path, json.dumps(data) if data is not None else None, headers) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + + return request + + def test_presence_auth_validation_and_default_privacy(self): + request = self.presence_client() + self.assertEqual(request(), (200, {"online_count": 0, "chat_players": []})) + update = {"tab_id": "a" * 32, "active": True, "in_chat": True} + self.assertEqual(request("POST", update)[0], 401) + self.assertEqual(request("POST", update, "not-a-token")[0], 401) + for invalid in ({}, {**update, "active": 1}, {**update, "in_chat": "true"}, + {**update, "tab_id": "a" * 33}, {**update, "tab_id": "