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
+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)