diff --git a/README.md b/README.md index 8700c0c..f205c33 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Logged-in players can also join the mailing list. The Chat section is a simple public chatroom. Logged-in players can post plain-text messages (up to 200 characters, one message every two seconds). The latest 100 messages are displayed oldest first and refresh every five seconds while Chat is open. Messages persist in SQLite across restarts, including after the game ends. There are no editing, deletion, or moderation controls yet. +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. + ## Running ### Docker (recommended) diff --git a/server.py b/server.py index ca8875d..87df8f6 100644 --- a/server.py +++ b/server.py @@ -198,6 +198,9 @@ def load_state(): ); CREATE INDEX IF NOT EXISTS chat_player_messages ON chat_messages (player_id, id); """) + columns = {row["name"] for row in connection.execute("PRAGMA table_info(chat_messages)")} + if "kind" not in columns: + connection.execute("ALTER TABLE chat_messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'message'") # Serialize initialization. A committed game row is the migration marker, # not merely the existence of a possibly empty or interrupted database. connection.execute("BEGIN IMMEDIATE") @@ -249,7 +252,7 @@ failed_logins = {} # lowercase name -> (failure count, first failure ms) def chat_messages(): with database() as connection: rows = connection.execute( - "SELECT id, name, at, message FROM chat_messages ORDER BY id DESC LIMIT ?", + "SELECT id, name, at, message, kind FROM chat_messages ORDER BY id DESC LIMIT ?", (CHAT_HISTORY_LIMIT,), ).fetchall() return [dict(row) for row in reversed(rows)] @@ -481,7 +484,7 @@ class Handler(BaseHTTPRequestHandler): connection.execute("BEGIN IMMEDIATE") t = now_ms() last = connection.execute( - "SELECT at FROM chat_messages WHERE player_id = ? ORDER BY id DESC LIMIT 1", (pid,), + "SELECT at FROM chat_messages WHERE player_id = ? AND kind = 'message' ORDER BY id DESC LIMIT 1", (pid,), ).fetchone() if last is not None and t - last["at"] < CHAT_COOLDOWN_MS: return self.send_json(HTTPStatus.TOO_MANY_REQUESTS, {"error": "Please wait two seconds between messages"}) @@ -490,7 +493,7 @@ class Handler(BaseHTTPRequestHandler): "INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)", (pid, name, t, message), ) - posted = {"id": cursor.lastrowid, "name": name, "at": t, "message": message} + posted = {"id": cursor.lastrowid, "name": name, "at": t, "message": message, "kind": "message"} return self.send_json(HTTPStatus.CREATED, posted) if path == "/api/register": @@ -589,7 +592,25 @@ class Handler(BaseHTTPRequestHandler): } state["presses"].append(press) state["deadline"] = t + DURATION_MS - save_state(state) + hours, remainder = divmod(max(0, remaining), 3600000) + minutes, remainder = divmod(remainder, 60000) + seconds, milliseconds = divmod(remainder, 1000) + time_left = f"{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}" + name = state["players"][pid]["name"] + points = press["points"] + announcement = f"{name} pushed the button with {time_left} remaining and received {points} {'point' if points == 1 else 'points'}." + try: + with database() as connection: + write_state(connection, state) + connection.execute( + "INSERT INTO chat_messages (player_id, name, at, message, kind) VALUES (?, ?, ?, ?, 'press')", + (pid, "The Button", t, announcement), + ) + except Exception: + # Keep memory consistent with the rolled-back database snapshot. + state["presses"].pop() + state["deadline"] = t + remaining + raise return self.send_json(HTTPStatus.OK, { "press": {k: press[k] for k in ("at", "remaining_ms", "zone", "points")}, "deadline": state["deadline"], diff --git a/static/app.js b/static/app.js index c03dc04..21e9c5f 100644 --- a/static/app.js +++ b/static/app.js @@ -274,6 +274,7 @@ async function loadChat() { const previousScroll = list.scrollTop; list.replaceChildren(...messages.map((message) => { const item = el("li"); + if (message.kind === "press") item.classList.add("chat-press"); 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"); @@ -371,6 +372,7 @@ $("the-button").addEventListener("click", async () => { updateShareLinks(); await loadMe(); await loadLeaderboard(); + if (selectedSection === "chat-section") await loadChat(); } catch (e) { $("press-result").textContent = e.message; await refreshState().catch(() => {}); diff --git a/static/style.css b/static/style.css index b9695a5..a1ce5f4 100644 --- a/static/style.css +++ b/static/style.css @@ -344,6 +344,8 @@ stripe-buy-button { display: block; max-width: 100%; margin: 1.5rem 0; } #chat-messages li:nth-child(3n + 2) .chat-avatar { background: #94e2d51a; color: var(--teal); } #chat-messages li:nth-child(3n) .chat-avatar { background: #fab3871a; color: var(--peach); } .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-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 032340f..c397353 100755 --- a/test_server.py +++ b/test_server.py @@ -235,7 +235,7 @@ class PersistenceTests(unittest.TestCase): self.assertEqual(status, 201) self.assertEqual(posted["name"], self.game.state["players"]["alice"]["name"]) self.assertEqual(posted["message"], "Hello '); --") - self.assertEqual(set(posted), {"id", "name", "at", "message"}) + self.assertEqual(set(posted), {"id", "name", "at", "message", "kind"}) self.assertEqual(request("POST", {"message": "Too soon"}, True)[0], 429) # Snapshot writes delete/reinsert players; deferred chat FKs must survive. self.game.save_state(self.game.state) @@ -250,6 +250,26 @@ class PersistenceTests(unittest.TestCase): with self.game.database() as connection: 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) + with self.game.database() as connection: + connection.execute("DROP TABLE chat_messages") + connection.execute("""CREATE TABLE chat_messages ( + id INTEGER PRIMARY KEY, + player_id TEXT NOT NULL REFERENCES players(id) DEFERRABLE INITIALLY DEFERRED, + name TEXT NOT NULL, at INTEGER NOT NULL, message TEXT NOT NULL + )""") + connection.execute( + "INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)", + ("alice", "Alice", 123, "Existing message"), + ) + self.game.load_state() + self.assertEqual(self.game.chat_messages(), [ + {"id": 1, "name": "Alice", "at": 123, "message": "Existing message", "kind": "message"}, + ]) + def test_chat_returns_latest_hundred_in_chronological_order(self): self.game.load_state() self.game.state = self.sample_state() @@ -290,15 +310,28 @@ class PersistenceTests(unittest.TestCase): status, registered = request("/api/register", {"name": "Alice", "password": "test-password"}) self.assertEqual(status, 201) token = registered["token"] - self.assertEqual(request("/api/press", {}, token)[0], 200) + self.assertEqual(request("/api/press", {})[0], 401) + self.assertEqual(self.game.chat_messages(), []) + t = self.game.now_ms() + self.game.state["deadline"] = t + 1234 + 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(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.") + self.assertEqual(request("/api/chat", {"message": "That was close!"}, token)[0], 201) self.assertEqual(request("/api/subscribe", {"first_name": "Alice", "email": "alice@example.com"}, token)[0], 200) self.game.state = self.game.load_state() + self.assertEqual(self.game.chat_messages()[0], announcements[0]) self.game.token_index = {th: pid for pid, p in self.game.state["players"].items() for th in p["token_hashes"]} self.assertEqual(request("/api/press", {}, token)[0], 200) status, logged_in = request("/api/login", {"name": "Alice", "password": "test-password"}) self.assertEqual(status, 200) self.assertEqual(request("/api/logout", {}, token)[0], 200) self.assertEqual(request("/api/press", {}, token)[0], 401) + self.assertEqual(len([m for m in self.game.chat_messages() if m["kind"] == "press"]), 2) loaded = self.game.load_state() player = next(iter(loaded["players"].values())) self.assertTrue(player["subscribed"])