diff --git a/README.md b/README.md index 38436c4..1df7833 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,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 — 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. +4. **Community milestones — implemented.** Eight one-time shared goals post gold announcements in chat. Unlocks persist across restarts, and historical milestones are silently credited on upgrade. 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. @@ -67,7 +67,28 @@ Your score includes eight private achievements, derived from the game's existing 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. +Historical JSON imports and existing SQLite history are credited automatically. No new table or manual migration is required for personal achievements; 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 + +There are **eight community milestones**, matching the eight personal achievements. Each posts once to the shared chat, with distinct gold styling: + +| Milestone | Community goal | +|-----------|----------------| +| One day together | Survive 24 hours. | +| A week of nerve | Survive 7 days. | +| Thirty days strong | Survive 30 days. | +| A growing crowd | Reach 10 registered players. | +| Fifty strong | Reach 50 registered players. | +| A hundred second chances | Reach 100 presses. | +| A thousand moments | Reach 1,000 presses. | +| Our first photo finish | First successful press with more than zero but less than 10 seconds left. | + +Survival milestones use real elapsed time since game start, not scaled countdown durations, and never count time after the game deadline/end. They are checked by the server watcher and normal state requests, even without presses. The photo finish is a single community first, not an announcement for every improved record. No milestone gives bonus points. + +Milestone unlocks are stored in SQLite separately from player chat, and share the latest-100-message feed. They have no player dependency and do not affect chat cooldowns. Registration/press milestone unlocks commit with the corresponding game update. Repeated requests, watchers, and restarts cannot announce a goal twice. The first upgrade/import silently records already-reached goals to avoid flooding chat; future newly-reached goals announce normally. On later restarts, newly crossed survival milestones are announced once. Missed goals are never replayed repeatedly. + +Optional zone alerts are the next roadmap step. ### Live presence diff --git a/server.py b/server.py index 2018b60..94781d6 100644 --- a/server.py +++ b/server.py @@ -156,6 +156,40 @@ def write_state(connection, state): ) +def milestone_status(game_state, at): + """Eight fixed community goals; elapsed survival stops at the deadline.""" + until = min(at, game_state["ended_at"] if game_state["ended_at"] is not None else game_state["deadline"]) + elapsed = max(0, until - game_state["started_at"]) + players = len(game_state["players"]) + presses = len(game_state["presses"]) + goals = [ + ("day_one", "One day together", "The community kept the game alive for one day!", elapsed, 86400000), + ("one_week", "A week of nerve", "Seven days together. The clock is still part of our story!", elapsed, 7 * 86400000), + ("one_month", "Thirty days strong", "The game survived 30 days. What a community!", elapsed, 30 * 86400000), + ("ten_players", "A growing crowd", "Ten players have joined the experiment!", players, 10), + ("fifty_players", "Fifty strong", "Fifty players, one shared clock. Welcome aboard!", players, 50), + ("hundred_presses", "A hundred second chances", "Together we've pressed the button 100 times!", presses, 100), + ("thousand_presses", "A thousand moments", "1,000 presses. A thousand decisions to keep us going!", presses, 1000), + ("first_photo_finish", "Our first photo finish", "A community first: someone pressed with less than ten seconds left!", int(any(0 < p["remaining_ms"] < 10000 for p in game_state["presses"])), 1), + ] + return [{"id": key, "name": name, "message": message, "progress": min(progress, target), "target": target} + for key, name, message, progress, target in goals] + + +def sync_milestones(connection, game_state, at=None): + """Commit unlocks with game changes. Existing goals are silently baselined once.""" + at = now_ms() if at is None else at + initialized = connection.execute("SELECT 1 FROM community_milestones LIMIT 1").fetchone() is not None + for goal in milestone_status(game_state, at): + connection.execute("INSERT OR IGNORE INTO community_milestones (id) VALUES (?)", (goal["id"],)) + if goal["progress"] >= goal["target"]: + connection.execute( + """UPDATE community_milestones SET reached_at = ?, announced_at = ?, message = ? + WHERE id = ? AND reached_at IS NULL""", + (at, at if initialized else None, f"Milestone: {goal['name']}. {goal['message']}", goal["id"]), + ) + + def load_state(): if DATABASE_FILE.resolve() == DATA_FILE.resolve(): raise ValueError("BUTTON_DATABASE_FILE must differ from the legacy BUTTON_DATA_FILE") @@ -199,6 +233,12 @@ def load_state(): message TEXT NOT NULL CHECK (length(message) BETWEEN 1 AND 200) ); CREATE INDEX IF NOT EXISTS chat_player_messages ON chat_messages (player_id, id); + CREATE TABLE IF NOT EXISTS community_milestones ( + id TEXT PRIMARY KEY, + reached_at INTEGER, + announced_at INTEGER, + message TEXT + ); """) columns = {row["name"] for row in connection.execute("PRAGMA table_info(chat_messages)")} if "kind" not in columns: @@ -219,6 +259,7 @@ def load_state(): # Any read/validation/SQL failure aborts startup without consuming the # JSON source or committing a partial game. The next start can retry. write_state(connection, loaded) + sync_milestones(connection, loaded) return loaded loaded = dict(game) @@ -238,12 +279,14 @@ def load_state(): press = dict(row) del press["position"] loaded["presses"].append(press) + sync_milestones(connection, loaded) return loaded def save_state(state): with database() as connection: write_state(connection, state) + sync_milestones(connection, state) state = load_state() @@ -274,7 +317,12 @@ def public_presence(): def chat_messages(): with database() as connection: rows = connection.execute( - "SELECT id, name, at, message, kind FROM chat_messages ORDER BY id DESC LIMIT ?", + """SELECT id, name, at, message, kind FROM ( + SELECT id, name, at, message, kind, 0 AS source, id AS position FROM chat_messages + UNION ALL + SELECT 'milestone:' || id, 'The Button', announced_at, message, 'milestone', 1, rowid + FROM community_milestones WHERE announced_at IS NOT NULL + ) ORDER BY at DESC, source DESC, position DESC LIMIT ?""", (CHAT_HISTORY_LIMIT,), ).fetchall() return [dict(row) for row in reversed(rows)] @@ -319,6 +367,9 @@ def check_ended(): state["ended"] = True state["ended_at"] = state["deadline"] save_state(state) + else: + with database() as connection: + sync_milestones(connection, state) def empty_zone_counts(): @@ -737,6 +788,7 @@ class Handler(BaseHTTPRequestHandler): "INSERT INTO chat_messages (player_id, name, at, message, kind) VALUES (?, ?, ?, ?, 'press')", (pid, "The Button", t, announcement), ) + sync_milestones(connection, state, t) except Exception: # Keep memory consistent with the rolled-back database snapshot. state["presses"].pop() diff --git a/static/app.js b/static/app.js index c4cdaf7..44cd29b 100644 --- a/static/app.js +++ b/static/app.js @@ -408,6 +408,7 @@ async function loadChat() { list.replaceChildren(...messages.map((message) => { const item = el("li"); if (message.kind === "press") item.classList.add("chat-press"); + if (message.kind === "milestone") item.classList.add("chat-milestone"); const avatar = el("span", Array.from(message.name.trim())[0]?.toUpperCase() || "?", "chat-avatar"); avatar.setAttribute("aria-hidden", "true"); const content = el("div", undefined, "chat-message-content"); diff --git a/static/style.css b/static/style.css index ab80f02..8d08f7b 100644 --- a/static/style.css +++ b/static/style.css @@ -367,6 +367,8 @@ stripe-buy-button { display: block; max-width: 100%; margin: 1.5rem 0; } .chat-message-content { min-width: 0; flex: 1; } #chat-messages .chat-press .chat-avatar { background: #a6e3a118; color: var(--green); } #chat-messages .chat-press p { background: #a6e3a108; border-color: #a6e3a130; color: var(--green); font-size: 0.8rem; } +#chat-messages .chat-milestone .chat-avatar { background: #f9e2af18; color: var(--yellow); } +#chat-messages .chat-milestone p { background: #f9e2af08; border-color: #f9e2af40; color: var(--yellow); font-size: 0.85rem; } .chat-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.6rem; padding: 0.1rem 0 0.35rem; } .chat-heading strong { color: var(--text); font-size: 0.8rem; overflow-wrap: anywhere; } .chat-heading time { color: var(--subtext); font-size: 0.65rem; } diff --git a/test_server.py b/test_server.py index fabe600..8d9f828 100755 --- a/test_server.py +++ b/test_server.py @@ -225,6 +225,120 @@ 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 test_eight_milestones_match_achievement_count_and_start_unreached(self): + self.game.state = self.game.load_state() + goals = self.game.milestone_status(self.game.state, self.game.now_ms()) + self.assertEqual(len(goals), 8) + self.assertEqual(len(goals), len(self.game.player_achievements("nobody"))) + self.assertTrue(all(goal["progress"] < goal["target"] for goal in goals)) + self.assertEqual(self.game.chat_messages(), []) + + def test_milestone_thresholds_for_counts_and_precision(self): + game = self.game.new_game() + press = {"player_id": "p", "at": game["started_at"], "remaining_ms": 10000, "zone": "green", "points": 0} + for count in (9, 10, 49, 50): + game["players"] = {str(i): {} for i in range(count)} + goals = {g["id"]: g for g in self.game.milestone_status(game, game["started_at"])} + self.assertEqual(goals["ten_players"]["progress"] == goals["ten_players"]["target"], count >= 10) + self.assertEqual(goals["fifty_players"]["progress"] == goals["fifty_players"]["target"], count >= 50) + for count in (99, 100, 999, 1000): + game["presses"] = [press] * count + goals = {g["id"]: g for g in self.game.milestone_status(game, game["started_at"])} + self.assertEqual(goals["hundred_presses"]["progress"] == 100, count >= 100) + self.assertEqual(goals["thousand_presses"]["progress"] == 1000, count >= 1000) + for remaining, qualifies in ((10000, False), (9999, True), (0, False), (-1, False)): + game["presses"] = [dict(press, remaining_ms=remaining)] + self.assertEqual(self.game.milestone_status(game, game["started_at"])[-1]["progress"], int(qualifies)) + + def test_survival_milestones_boundaries_and_game_end_cap(self): + game = self.game.new_game() + day = 86400000 + start = game["started_at"] + game["deadline"] = start + 40 * day + for days in (1, 7, 30): + before = self.game.milestone_status(game, start + days * day - 1) + after = self.game.milestone_status(game, start + days * day) + index = (1, 7, 30).index(days) + self.assertLess(before[index]["progress"], before[index]["target"]) + self.assertEqual(after[index]["progress"], after[index]["target"]) + game.update(ended=True, ended_at=start + day // 2) + self.assertTrue(all(g["progress"] < g["target"] for g in self.game.milestone_status(game, start + 100 * day)[:3])) + game.update(ended=False, ended_at=None, deadline=start + day // 2) + self.assertTrue(all(g["progress"] < g["target"] for g in self.game.milestone_status(game, start + 100 * day)[:3])) + + def test_historical_milestones_are_silent_on_upgrade(self): + game = self.sample_state() + game["players"].update({str(i): dict(game["players"]["bob"], token_hashes=[]) for i in range(50)}) + game["presses"] *= 334 + game["started_at"] = self.game.now_ms() - 31 * 86400000 + self.write_legacy(game) + self.game.state = self.game.load_state() + self.assertEqual(self.game.chat_messages(), []) + with self.game.database() as connection: + self.assertEqual(connection.execute("SELECT COUNT(*) FROM community_milestones WHERE reached_at IS NOT NULL").fetchone()[0], 8) + self.game.load_state() + self.assertEqual(self.game.chat_messages(), []) + # Existing SQLite database without the new table follows the same baseline. + with self.game.database() as connection: + connection.execute("DROP TABLE community_milestones") + self.game.load_state() + self.assertEqual(self.game.chat_messages(), []) + + def test_new_milestone_announces_once_survives_restart_and_has_no_player_fk(self): + self.game.state = self.game.load_state() + start = self.game.state["started_at"] + self.game.state["deadline"] = start + 2 * 86400000 + with patch.object(self.game, "now_ms", return_value=start + 86400000): + self.game.save_state(self.game.state) + self.game.check_ended() + self.game.state = self.game.load_state() + messages = self.game.chat_messages() + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0]["id"], "milestone:day_one") + self.assertEqual(messages[0]["kind"], "milestone") + self.assertEqual(messages[0]["name"], "The Button") + self.assertEqual(self.game.state["players"], {}) + with self.game.database() as connection: + self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), []) + + def test_milestone_failure_rolls_back_game_and_unlock(self): + self.game.state = self.game.load_state() + before = copy.deepcopy(self.game.state) + changed = copy.deepcopy(before) + changed["started_at"] -= 86400000 + with self.game.database() as connection: + connection.execute("""CREATE TRIGGER reject_milestone BEFORE UPDATE ON community_milestones + BEGIN SELECT RAISE(ABORT, 'test rollback'); END""") + with self.assertRaises(sqlite3.IntegrityError): + self.game.save_state(changed) + with self.game.database() as connection: + connection.execute("DROP TRIGGER reject_milestone") + self.assertEqual(self.game.load_state(), before) + self.assertEqual(self.game.chat_messages(), []) + + def test_milestones_share_chat_limit_and_do_not_affect_player_cooldown(self): + self.write_legacy(self.sample_state()) + self.game.state = self.game.load_state() + at = self.game.now_ms() + with self.game.database() as connection: + connection.executemany( + "INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)", + [("alice", "Alice", at - 1000 + i, str(i)) for i in range(105)], + ) + connection.execute( + "UPDATE community_milestones SET reached_at = ?, announced_at = ?, message = ? WHERE id = 'ten_players'", + (at, at, "Milestone: Ten players!"), + ) + messages = self.game.chat_messages() + self.assertEqual(len(messages), 100) + self.assertEqual(messages[-1]["id"], "milestone:ten_players") + self.assertEqual(messages[0]["message"], "6") + with self.game.database() as connection: + latest_player = connection.execute( + "SELECT at FROM chat_messages WHERE player_id = ? AND kind = 'message' ORDER BY id DESC LIMIT 1", ("alice",), + ).fetchone() + self.assertEqual(latest_player["at"], at - 896) + def achievement_game(self): self.game.state = self.sample_state() self.game.state.update(duration_ms=120000, started_at=1000, presses=[]) @@ -458,6 +572,7 @@ class PersistenceTests(unittest.TestCase): ]) def test_leaderboard_http_is_public_before_and_after_game_ends(self): + self.game.load_state() self.game.state = self.sample_state() httpd = ThreadingHTTPServer(("127.0.0.1", 0), self.game.Handler) thread = threading.Thread(target=httpd.serve_forever, daemon=True) @@ -489,9 +604,8 @@ class PersistenceTests(unittest.TestCase): self.assertEqual(get("/api/leaderboard")[1], {"leaderboard": []}) def test_chat_auth_validation_cooldown_and_persistence(self): - self.game.load_state() - self.game.state = self.sample_state() - self.game.save_state(self.game.state) + self.write_legacy(self.sample_state()) + self.game.state = self.game.load_state() self.game.token_index = {self.game.hash_token("test-chat-token"): "alice"} httpd = ThreadingHTTPServer(("127.0.0.1", 0), self.game.Handler) thread = threading.Thread(target=httpd.serve_forever, daemon=True) @@ -537,9 +651,8 @@ class PersistenceTests(unittest.TestCase): self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), []) def test_existing_chat_schema_upgrades_without_losing_messages(self): - self.game.load_state() - self.game.state = self.sample_state() - self.game.save_state(self.game.state) + self.write_legacy(self.sample_state()) + self.game.state = self.game.load_state() with self.game.database() as connection: connection.execute("DROP TABLE chat_messages") connection.execute("""CREATE TABLE chat_messages ( @@ -557,9 +670,8 @@ class PersistenceTests(unittest.TestCase): ]) def test_chat_returns_latest_hundred_in_chronological_order(self): - self.game.load_state() - self.game.state = self.sample_state() - self.game.save_state(self.game.state) + self.write_legacy(self.sample_state()) + self.game.state = self.game.load_state() with self.game.database() as connection: connection.executemany( "INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)", @@ -603,7 +715,9 @@ class PersistenceTests(unittest.TestCase): with patch.object(self.game, "now_ms", return_value=t): self.assertEqual(request("/api/press", {}, token)[0], 200) announcements = self.game.chat_messages() - self.assertEqual(len(announcements), 1) + self.assertEqual(len(announcements), 2) + self.assertEqual(announcements[1]["kind"], "milestone") + self.assertIn("Our first photo finish", announcements[1]["message"]) self.assertEqual(announcements[0]["kind"], "press") self.assertEqual(announcements[0]["name"], "The Button") self.assertEqual(announcements[0]["message"], "Alice pushed the button with 00:00:01.234 remaining and received 32 points.")