Engagement features
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user