diff --git a/README.md b/README.md index 6c15307..38436c4 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The goal is to make waiting social and consequential without rewarding button sp 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. +3. **Achievements — implemented.** Close call, Full spectrum, and Keeping us alive appear under Your score with unlock dates and progress. Existing press history counts, with no bonus points or press-volume rewards. 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. @@ -48,7 +48,26 @@ Keep new features consistent with the Catppuccin Mocha styling and selectable-se 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. +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 + +Your score includes eight private achievements, derived from the game's existing press history: + +| Achievement | Requirement | +|-------------|-------------| +| Close call | A successful press with strictly less than 60 seconds remaining (and more than zero). | +| Full spectrum | At least one press in each of the six scoring zones; repeated presses in one zone do not advance it. | +| Red alert | Press in the red zone. | +| Into the dark | Press in the black zone. | +| White knuckle | Press in the white zone. | +| Photo finish | Press with strictly less than ten seconds remaining, and more than zero. | +| Last second | Press with strictly less than one second remaining, and more than zero. | +| Keeping us alive | Press after a global quiet stretch of at least half the saved game countdown: 12 hours for the standard 24-hour game. Scales with shorter test games. | + +Quiet time is measured from the previous press by **any** player, or from game start if there was no previous press. It is not the time since your own last press. The earliest qualifying press determines each unlock date. Progress is based on completed presses, not idle time accumulating while the page is open. These are badges only: they do not change scores, publish chat announcements, or reward repeated resets. + +Historical JSON imports and existing SQLite history are credited automatically. No new table or manual migration is required; badges are recomputed from history after restart. They remain available after game over and refresh with Your score. Personal achievements are returned only by authenticated `GET /api/me`, not by the public leaderboard or final leaderboard. Community milestones are next on the roadmap. ### Live presence diff --git a/server.py b/server.py index 3cac784..2018b60 100644 --- a/server.py +++ b/server.py @@ -325,6 +325,62 @@ def empty_zone_counts(): return {z["name"]: 0 for z in ZONES} +def player_achievements(pid): + """Derive private achievements from recorded history, including legacy games.""" + quiet_target = max(1, state["duration_ms"] // 2) + close_at = spectrum_at = quiet_at = None + visited = set() + longest_quiet = 0 + previous_at = state["started_at"] + zone_names = {zone["name"] for zone in ZONES} + extra_rules = [ + ("red_alert", "Red alert", "Press in the red zone.", "red", None), + ("into_the_dark", "Into the dark", "Press in the black zone.", "black", None), + ("white_knuckle", "White knuckle", "Press in the white zone.", "white", None), + ("photo_finish", "Photo finish", "Press with less than ten seconds remaining.", None, 10000), + ("last_second", "Last second", "Press with less than one second remaining.", None, 1000), + ] + extra_unlocks = {rule[0]: None for rule in extra_rules} + for press in state["presses"]: + quiet_ms = max(0, press["at"] - previous_at) + previous_at = press["at"] # measure the global gap, not this player's gap + if press["player_id"] != pid: + continue + for achievement_id, _, _, zone, threshold in extra_rules: + qualifies = press["zone"] == zone if zone is not None else 0 < press["remaining_ms"] < threshold + if extra_unlocks[achievement_id] is None and qualifies: + extra_unlocks[achievement_id] = press["at"] + if close_at is None and 0 < press["remaining_ms"] < 60000: + close_at = press["at"] + if press["zone"] in zone_names: + visited.add(press["zone"]) + if spectrum_at is None and visited == zone_names: + spectrum_at = press["at"] + longest_quiet = max(longest_quiet, quiet_ms) + if quiet_at is None and quiet_ms >= quiet_target: + quiet_at = press["at"] + return [ + {"id": "close_call", "name": "Close call", + "description": "Press with less than one minute remaining.", + "unlocked": close_at is not None, "unlocked_at": close_at, + "progress": int(close_at is not None), "target": 1}, + {"id": "full_spectrum", "name": "Full spectrum", + "description": "Press in all six scoring zones.", + "unlocked": spectrum_at is not None, "unlocked_at": spectrum_at, + "progress": len(visited), "target": len(zone_names)}, + {"id": "keeping_us_alive", "name": "Keeping us alive", + "description": "Press after nobody has pressed for at least half the game's countdown.", + "unlocked": quiet_at is not None, "unlocked_at": quiet_at, + "progress": min(longest_quiet, quiet_target), "target": quiet_target}, + ] + [ + {"id": achievement_id, "name": name, "description": description, + "unlocked": extra_unlocks[achievement_id] is not None, + "unlocked_at": extra_unlocks[achievement_id], + "progress": int(extra_unlocks[achievement_id] is not None), "target": 1} + for achievement_id, name, description, _, _ in extra_rules + ] + + def player_summary(pid): presses = [p for p in state["presses"] if p["player_id"] == pid] counts = empty_zone_counts() @@ -350,6 +406,7 @@ def player_summary(pid): "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)}, + "achievements": player_achievements(pid), "ranking": { "position": position + 1, "total_players": len(standings), @@ -403,7 +460,7 @@ def global_stats(): leaderboard = [] for pid in state["players"]: s = player_summary(pid) - del s["presses"], s["subscribed"], s["personal_bests"], s["ranking"] + del s["presses"], s["subscribed"], s["personal_bests"], s["ranking"], s["achievements"] leaderboard.append(s) leaderboard.sort(key=lambda s: (-s["total_points"], s["name"].lower())) presses = state["presses"] diff --git a/static/app.js b/static/app.js index e9cfaac..c4cdaf7 100644 --- a/static/app.js +++ b/static/app.js @@ -289,6 +289,42 @@ function tick() { requestAnimationFrame(tick); } +function renderAchievements(achievements) { + const list = $("my-achievements"); + const signature = JSON.stringify(achievements); + if (list.dataset.achievements === signature) return; + list.dataset.achievements = signature; + $("my-achievement-count").textContent = `${achievements.filter((a) => a.unlocked).length} / ${achievements.length} unlocked`; + list.replaceChildren(...achievements.map((achievement) => { + const item = el("li", undefined, `achievement-card${achievement.unlocked ? " is-unlocked" : ""}`); + const icon = el("span", achievement.unlocked ? "\u2713" : "\u25c7", "achievement-icon"); + icon.setAttribute("aria-hidden", "true"); + const content = el("div", undefined, "achievement-content"); + const heading = el("div", undefined, "achievement-title"); + heading.append(el("h4", achievement.name), el("span", achievement.unlocked ? "Unlocked" : "Locked", "achievement-state")); + content.append(heading, el("p", achievement.description)); + let progressText = "One well-timed press unlocks this badge."; + if (achievement.id === "full_spectrum") { + progressText = `${achievement.progress} / ${achievement.target} zones visited`; + } else if (achievement.id === "keeping_us_alive") { + progressText = `Longest quiet stretch before your press: ${formatPrecise(achievement.progress)} / ${formatPrecise(achievement.target)} needed`; + } + if (achievement.unlocked) { + progressText = `Unlocked ${new Date(achievement.unlocked_at).toLocaleString()}`; + } + content.append(el("small", progressText)); + if (["full_spectrum", "keeping_us_alive"].includes(achievement.id) && !achievement.unlocked) { + const progress = el("progress"); + progress.max = achievement.target; + progress.value = achievement.progress; + progress.setAttribute("aria-label", `${achievement.name} progress`); + content.append(progress); + } + item.append(icon, content); + return item; + })); +} + async function loadMe() { if (!token) return; try { @@ -296,6 +332,7 @@ async function loadMe() { $("my-name").textContent = me.name; $("my-points").textContent = me.total_points.toLocaleString(); $("my-presses").textContent = me.press_count; + renderAchievements(me.achievements); const closest = me.personal_bests.closest_call; const best = me.personal_bests.highest_scoring_press; $("my-closest-call").textContent = closest ? formatPrecise(closest.remaining_ms) : "--"; diff --git a/static/index.html b/static/index.html index 7321c22..ab9f53a 100644 --- a/static/index.html +++ b/static/index.html @@ -131,6 +131,9 @@

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

+

Your achievements

Loading badges…
+

Earned through timing, not volume. Your existing history counts. No bonus points.

+

Your scoring zones

diff --git a/static/style.css b/static/style.css index e5bb3c0..ab80f02 100644 --- a/static/style.css +++ b/static/style.css @@ -202,6 +202,21 @@ details[open] summary { margin-bottom: 1rem; } .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; } +.achievements-heading { margin: 1.75rem 0 0.5rem; } +.achievements-heading .subheading { margin: 0; } +.achievement-list { display: grid; gap: 0.7rem; list-style: none; margin: 1rem 0; padding: 0; } +.achievement-card { display: flex; align-items: flex-start; gap: 0.85rem; padding: 1rem; background: var(--base); border: 1px solid var(--surface); border-radius: 12px; } +.achievement-icon { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 34px; border-radius: 11px; color: var(--subtext); background: var(--surface); font-size: 1.1rem; } +.achievement-content { flex: 1; min-width: 0; } +.achievement-title { display: flex; align-items: center; flex-wrap: wrap; justify-content: space-between; gap: 0.4rem; } +.achievement-title h4 { margin: 0; color: var(--text); font-size: 0.85rem; } +.achievement-state { color: var(--subtext); font-size: 0.65rem; } +.achievement-content p { color: var(--subtext); font-size: 0.75rem; margin: 0.3rem 0; line-height: 1.6; } +.achievement-content small { display: block; color: var(--subtext); font-size: 0.65rem; overflow-wrap: anywhere; } +.achievement-content progress { display: block; width: 100%; height: 5px; margin-top: 0.7rem; accent-color: var(--mauve); } +.achievement-card.is-unlocked { border-color: #a6e3a135; background: linear-gradient(110deg, #a6e3a108, transparent), var(--base); } +.is-unlocked .achievement-icon { background: #a6e3a118; color: var(--green); } +.is-unlocked .achievement-state { color: var(--green); } #my-subscribed { padding: 0.65rem; border-radius: 8px; background: #a6e3a110; color: var(--green); font-size: 0.75rem; } #leaderboard-status:empty { display: none; } diff --git a/test_server.py b/test_server.py index 536036d..fabe600 100755 --- a/test_server.py +++ b/test_server.py @@ -225,6 +225,132 @@ class PersistenceTests(unittest.TestCase): 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 achievement_game(self): + self.game.state = self.sample_state() + self.game.state.update(duration_ms=120000, started_at=1000, presses=[]) + + def achievement_map(self, pid="alice"): + return {a["id"]: a for a in self.game.player_achievements(pid)} + + def test_achievements_start_locked_without_presses(self): + self.achievement_game() + achievements = self.achievement_map() + self.assertEqual(set(achievements), {"close_call", "full_spectrum", "keeping_us_alive", "red_alert", "into_the_dark", "white_knuckle", "photo_finish", "last_second"}) + for achievement in achievements.values(): + self.assertFalse(achievement["unlocked"]) + self.assertIsNone(achievement["unlocked_at"]) + self.assertEqual(achievement["progress"], 0) + self.assertEqual(achievements["full_spectrum"]["target"], 6) + self.assertEqual(achievements["keeping_us_alive"]["target"], 60000) + + def test_close_call_strict_boundary_and_first_unlock(self): + self.achievement_game() + for remaining in (60000, 0, -1): + self.game.state["presses"].append({"player_id": "alice", "at": 2000, "remaining_ms": remaining, "zone": "white", "points": 32}) + self.game.state["presses"].append({"player_id": "bob", "at": 3000, "remaining_ms": 1, "zone": "white", "points": 32}) + self.assertFalse(self.achievement_map()["close_call"]["unlocked"]) + self.game.state["presses"].append({"player_id": "alice", "at": 4000, "remaining_ms": 59999, "zone": "white", "points": 32}) + self.game.state["presses"].append({"player_id": "alice", "at": 5000, "remaining_ms": 1, "zone": "white", "points": 32}) + close = self.achievement_map()["close_call"] + self.assertTrue(close["unlocked"]) + self.assertEqual(close["unlocked_at"], 4000) + self.assertEqual(close["progress"], 1) + + def test_full_spectrum_counts_distinct_personal_zones_only(self): + self.achievement_game() + for index, zone in enumerate(self.game.ZONES[:-1]): + self.game.state["presses"].append({"player_id": "alice", "at": 2000 + index, "remaining_ms": 1000, "zone": zone["name"], "points": zone["points"]}) + repeated = dict(self.game.state["presses"][0], at=3000) + self.game.state["presses"].extend([repeated] * 10) + self.game.state["presses"].append({"player_id": "bob", "at": 4000, "remaining_ms": 1000, "zone": "white", "points": 32}) + spectrum = self.achievement_map()["full_spectrum"] + self.assertEqual(spectrum["progress"], 5) + self.assertFalse(spectrum["unlocked"]) + self.game.state["presses"].append({"player_id": "alice", "at": 5000, "remaining_ms": 1000, "zone": "white", "points": 32}) + spectrum = self.achievement_map()["full_spectrum"] + self.assertTrue(spectrum["unlocked"]) + self.assertEqual(spectrum["unlocked_at"], 5000) + self.assertEqual(spectrum["progress"], 6) + + def test_quiet_achievement_uses_global_gaps_and_inclusive_boundary(self): + self.achievement_game() + self.game.state["presses"] = [ + {"player_id": "bob", "at": 31000, "remaining_ms": 90000, "zone": "green", "points": 0}, + {"player_id": "alice", "at": 61000, "remaining_ms": 90000, "zone": "green", "points": 0}, + ] + quiet = self.achievement_map()["keeping_us_alive"] + self.assertFalse(quiet["unlocked"]) + self.assertEqual(quiet["progress"], 30000) + self.game.state["presses"].append({"player_id": "alice", "at": 120999, "remaining_ms": 60001, "zone": "green", "points": 0}) + self.assertFalse(self.achievement_map()["keeping_us_alive"]["unlocked"]) + self.game.state["presses"].append({"player_id": "alice", "at": 180999, "remaining_ms": 60000, "zone": "blue", "points": 2}) + quiet = self.achievement_map()["keeping_us_alive"] + self.assertTrue(quiet["unlocked"]) + self.assertEqual(quiet["unlocked_at"], 180999) + self.assertEqual(quiet["progress"], quiet["target"]) + + def test_quiet_achievement_counts_first_press_from_game_start(self): + self.achievement_game() + self.game.state["presses"] = [{"player_id": "alice", "at": 61000, "remaining_ms": 60000, "zone": "blue", "points": 2}] + self.assertEqual(self.achievement_map()["keeping_us_alive"]["unlocked_at"], 61000) + self.game.state["duration_ms"] = 86400000 + self.assertEqual(self.achievement_map()["keeping_us_alive"]["target"], 43200000) + self.assertFalse(self.achievement_map()["keeping_us_alive"]["unlocked"]) + + def test_achievements_credit_legacy_history_and_survive_restart(self): + self.achievement_game() + for index, zone in enumerate(self.game.ZONES): + self.game.state["presses"].append({"player_id": "alice", "at": 61000 + index * 1000, "remaining_ms": 999, "zone": zone["name"], "points": zone["points"]}) + expected = self.game.player_achievements("alice") + self.assertTrue(all(a["unlocked"] for a in expected)) + self.write_legacy(self.game.state) + self.game.state = self.game.load_state() + self.assertEqual(self.game.player_achievements("alice"), expected) + self.game.save_state(self.game.state) + self.game.state = self.game.load_state() + self.assertEqual(self.game.player_achievements("alice"), expected) + self.assertFalse(any(a["unlocked"] for a in self.game.player_achievements("bob"))) + + def test_achievements_are_private_and_do_not_change_scores(self): + request = self.presence_client() + self.assertEqual(request(path="/api/me")[0], 401) + before = copy.deepcopy(self.game.state) + status, summary = request(token="alice-token", path="/api/me") + self.assertEqual(status, 200) + self.assertEqual(len(summary["achievements"]), 8) + self.assertEqual(summary["total_points"], 40) + self.assertEqual(self.game.state, before) + self.assertNotIn("achievements", self.game.load_state()) + for row in self.game.public_leaderboard() + self.game.global_stats()["leaderboard"]: + self.assertNotIn("achievements", row) + + def test_precision_badges_strict_boundaries_and_first_unlock(self): + for achievement_id, threshold in (("photo_finish", 10000), ("last_second", 1000)): + with self.subTest(achievement=achievement_id): + self.achievement_game() + for at, remaining in enumerate((threshold, threshold + 1, 0, -1), start=2000): + self.game.state["presses"].append({"player_id": "alice", "at": at, "remaining_ms": remaining, "zone": "green", "points": 0}) + self.game.state["presses"].append({"player_id": "bob", "at": 3000, "remaining_ms": 1, "zone": "white", "points": 32}) + self.assertFalse(self.achievement_map()[achievement_id]["unlocked"]) + for at in (4000, 5000): + self.game.state["presses"].append({"player_id": "alice", "at": at, "remaining_ms": threshold - 1, "zone": "white", "points": 32}) + badge = self.achievement_map()[achievement_id] + self.assertTrue(badge["unlocked"]) + self.assertEqual(badge["unlocked_at"], 4000) + self.assertEqual((badge["progress"], badge["target"]), (1, 1)) + + def test_zone_badges_require_own_press_in_exact_zone(self): + for achievement_id, zone in (("red_alert", "red"), ("into_the_dark", "black"), ("white_knuckle", "white")): + with self.subTest(achievement=achievement_id): + self.achievement_game() + self.game.state["presses"] = [ + {"player_id": "bob", "at": 2000, "remaining_ms": 1, "zone": zone, "points": 32}, + {"player_id": "alice", "at": 3000, "remaining_ms": 1, "zone": "green", "points": 0}, + ] + self.assertFalse(self.achievement_map()[achievement_id]["unlocked"]) + self.game.state["presses"].append({"player_id": "alice", "at": 4000, "remaining_ms": 10000, "zone": zone, "points": 8}) + self.assertEqual(self.achievement_map()[achievement_id]["unlocked_at"], 4000) + def test_fresh_game_survives_restart_without_creating_json(self): state = self.game.load_state() self.assertEqual(state, self.game.load_state())