Engagement features

This commit is contained in:
2026-10-21 13:10:25 -04:00
parent f4c10c9d7d
commit f9afa8ccbe
6 changed files with 405 additions and 4 deletions
+29
View File
@@ -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?}` |
+78 -4
View File
@@ -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)
+98
View File
@@ -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) => {
+21
View File
@@ -26,6 +26,7 @@
<div id="zone-label"></div>
<div id="zone-bar"><div id="zone-marker"></div></div>
<p id="history-global-summary" aria-live="polite"></p>
<p id="presence-count" class="presence-count" role="status">Checking who's here&hellip;</p>
</section>
<section id="button-section">
@@ -110,10 +111,26 @@
</header>
<div class="panel-body">
<p id="my-subscribed" hidden>You're on the mailing list. Thanks!</p>
<div class="presence-preference">
<label for="presence-opt-in"><input id="presence-opt-in" type="checkbox" aria-describedby="presence-help"> Show me online</label>
<p id="presence-help">Optional. Share that you're here, and your name in chat while viewing it. Only visible tabs count; no activity history is saved.</p>
<p id="presence-error" class="error" role="status"></p>
</div>
<div class="score-grid">
<div class="metric-card"><span>Total points</span><strong id="my-points">0</strong><small>Every moment counts</small></div>
<div class="metric-card"><span>Button presses</span><strong id="my-presses">0</strong><small>Times you kept us going</small></div>
</div>
<h3 class="subheading">Your personal bests</h3>
<div class="score-grid personal-records">
<div class="metric-card"><span>Closest call</span><strong id="my-closest-call">&mdash;</strong><small id="my-closest-detail">Your first press starts the story.</small></div>
<div class="metric-card"><span>Highest-scoring press</span><strong id="my-best-press">&mdash;</strong><small id="my-best-detail">Wait for your moment.</small></div>
</div>
<div class="rival-card" aria-labelledby="rival-heading">
<div class="header-topline"><p id="rival-heading" class="eyebrow">YOUR NEXT CHALLENGE</p><span id="my-position" class="site-badge"></span></div>
<h3 id="my-rival-title">Finding your place&hellip;</h3>
<p id="my-rival-description"></p>
<p class="fine-print">Positions follow the leaderboard: total points, then player name for ties. Any positive points gain puts you ahead of an equal score.</p>
</div>
<h3 class="subheading">Your scoring zones</h3>
<div class="table-scroll"><table id="my-zones" class="zone-table"></table></div>
<details>
@@ -147,6 +164,10 @@
</div>
<span class="chat-badge">Public room</span>
</header>
<div class="chat-presence" aria-label="Players viewing chat">
<p id="chat-presence-summary">Who's in the lounge</p>
<ul id="chat-presence-names" aria-label="Online chat players"></ul>
</div>
<div class="chat-feed">
<div class="chat-feed-label"><span>Conversation</span><span>Latest 100 messages</span></div>
<ol id="chat-messages" aria-label="Chat messages" tabindex="0"></ol>
+19
View File
@@ -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) {
+160
View File
@@ -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": "<script>"},
{**update, "tab_id": None}):
self.assertEqual(request("POST", invalid, "alice-token")[0], 400)
self.assertEqual(request()[1], {"online_count": 0, "chat_players": []})
self.assertNotIn("presence", self.game.load_state())
def test_presence_deduplicates_tabs_sessions_and_updates_chat(self):
request = self.presence_client()
update = {"tab_id": "a" * 32, "active": True, "in_chat": False}
self.assertEqual(request("POST", update, "alice-token")[1], {"online_count": 1, "chat_players": []})
request("POST", {**update, "tab_id": "b" * 32, "in_chat": True}, "alice-token")
request("POST", {**update, "in_chat": True}, "alice-second")
request("POST", {**update, "in_chat": True, "name": "Imposter"}, "bob-token")
expected = {"online_count": 2, "chat_players": [self.game.state["players"]["alice"]["name"], "Bøb"]}
self.assertEqual(request()[1], expected)
request("POST", {**update, "tab_id": "b" * 32, "active": False}, "alice-token")
self.assertEqual(request()[1], expected) # other session still viewing chat
request("POST", update, "alice-second")
self.assertEqual(request()[1], {"online_count": 2, "chat_players": ["Bøb"]})
request("POST", {**update, "active": False}, "alice-token")
self.assertEqual(request()[1]["online_count"], 2) # second session still online
request("POST", {**update, "active": False}, "alice-second")
self.assertEqual(request()[1], {"online_count": 1, "chat_players": ["Bøb"]})
def test_presence_expires_and_revoked_sessions_disappear(self):
request = self.presence_client()
update = {"tab_id": "a" * 32, "active": True, "in_chat": True}
with patch.object(self.game.time, "monotonic", return_value=100):
request("POST", update, "alice-token")
with patch.object(self.game.time, "monotonic", return_value=144):
self.assertEqual(request()[1]["online_count"], 1)
request("POST", update, "alice-token") # heartbeat renews TTL
with patch.object(self.game.time, "monotonic", return_value=188):
self.assertEqual(request()[1]["online_count"], 1)
with patch.object(self.game.time, "monotonic", return_value=189):
self.assertEqual(request()[1], {"online_count": 0, "chat_players": []})
request("POST", update, "alice-token")
self.game.token_index.pop(self.game.hash_token("alice-token"))
self.assertEqual(request()[1]["online_count"], 0)
self.assertEqual(self.game.presence, {})
def test_presence_bounded_tabs_and_logout_cleanup(self):
request = self.presence_client()
for index in range(self.game.MAX_PRESENCE_TABS_PER_SESSION):
update = {"tab_id": f"{index:032x}", "active": True, "in_chat": True}
self.assertEqual(request("POST", update, "alice-token")[0], 200)
update = {"tab_id": "f" * 32, "active": True}
self.assertEqual(request("POST", update, "alice-token")[0], 429)
self.assertEqual(request("POST", {**update, "tab_id": "0" * 32}, "alice-token")[0], 200)
self.assertEqual(request("POST", {}, "alice-token", "/api/logout")[0], 200)
self.assertEqual(request()[1], {"online_count": 0, "chat_players": []})
self.assertEqual(self.game.presence, {})
def test_personal_bests_use_only_own_presses_and_existing_history(self):
self.game.state = self.sample_state()
summary = self.game.player_summary("alice")
self.assertEqual(summary["personal_bests"]["closest_call"], {
"at": 100, "remaining_ms": 20, "zone": "white", "points": 32,
})
self.assertEqual(summary["personal_bests"]["highest_scoring_press"]["points"], 32)
bob = self.game.player_summary("bob")
self.assertEqual(bob["personal_bests"]["closest_call"]["remaining_ms"], 50)
self.assertEqual(bob["personal_bests"]["highest_scoring_press"]["points"], 16)
self.assertEqual(bob["ranking"], {
"position": 2, "total_players": 2, "tied_for_lead": False,
"rival": {"name": summary["name"], "total_points": 40, "points_to_tie": 24, "points_to_pass": 25},
})
self.assertIsNone(summary["ranking"]["rival"])
self.assertFalse(summary["ranking"]["tied_for_lead"])
self.game.load_state()
self.game.save_state(self.game.state)
self.game.state = self.game.load_state()
self.assertEqual(self.game.player_summary("alice")["personal_bests"], summary["personal_bests"])
def test_personal_records_empty_zero_points_and_deterministic_ties(self):
self.game.state = self.game.new_game()
self.game.state["players"] = {"solo": {"name": "Solo"}}
summary = self.game.player_summary("solo")
self.assertEqual(summary["personal_bests"], {"closest_call": None, "highest_scoring_press": None})
self.assertEqual(summary["ranking"], {"position": 1, "total_players": 1, "tied_for_lead": False, "rival": None})
self.game.state["presses"] = [
{"player_id": "solo", "at": at, "remaining_ms": remaining, "zone": "green", "points": 0}
for at, remaining in [(100, 6000), (300, 5000), (200, 5000)]
]
records = self.game.player_summary("solo")["personal_bests"]
self.assertEqual(records["highest_scoring_press"]["points"], 0)
self.assertEqual(records["highest_scoring_press"]["at"], 200)
self.assertEqual(records["closest_call"]["at"], 200)
def test_rivals_follow_alphabetical_ties_and_shared_lead(self):
self.game.state = self.game.new_game()
self.game.state["players"] = {"bob": {"name": "Bob"}, "alice": {"name": "alice"}, "charlie": {"name": "Charlie"}}
alice = self.game.player_summary("alice")["ranking"]
bob = self.game.player_summary("bob")["ranking"]
charlie = self.game.player_summary("charlie")["ranking"]
self.assertEqual(alice["position"], 1)
self.assertTrue(alice["tied_for_lead"])
self.assertIsNone(alice["rival"])
self.assertEqual(bob["position"], 2)
self.assertEqual(bob["rival"], {"name": "alice", "total_points": 0, "points_to_tie": 0, "points_to_pass": 1})
self.assertEqual(charlie["rival"]["name"], "Bob")
self.game.state["players"]["bob"]["name"] = "alice" # legacy duplicate names remain distinct IDs
self.assertNotEqual(self.game.player_summary("bob")["ranking"]["position"], self.game.player_summary("alice")["ranking"]["position"])
def test_rival_changes_when_another_player_overtakes(self):
self.game.state = self.sample_state()
self.game.state["presses"].append({"player_id": "bob", "at": 300, "remaining_ms": 1, "zone": "white", "points": 32})
summary = self.game.player_summary("alice")
self.assertEqual(summary["ranking"]["rival"], {"name": "Bøb", "total_points": 48, "points_to_tie": 8, "points_to_pass": 9})
self.assertEqual(summary["personal_bests"]["highest_scoring_press"]["points"], 32)
public = self.game.public_leaderboard()
for row in public:
self.assertEqual(set(row), {"name", "total_points", "press_count"})
def test_personal_records_endpoint_requires_authentication(self):
request = self.presence_client()
self.assertEqual(request(path="/api/me")[0], 401)
status, summary = request(token="bob-token", path="/api/me")
self.assertEqual(status, 200)
self.assertEqual(summary["personal_bests"]["highest_scoring_press"]["points"], 16)
self.assertEqual(set(summary["ranking"]["rival"]), {"name", "total_points", "points_to_tie", "points_to_pass"})
def test_fresh_game_survives_restart_without_creating_json(self):
state = self.game.load_state()
self.assertEqual(state, self.game.load_state())