Announce presses

This commit is contained in:
2026-10-21 12:26:51 -04:00
parent 65bf6dbc98
commit f4c10c9d7d
5 changed files with 66 additions and 6 deletions
+2
View File
@@ -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)
+25 -4
View File
@@ -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"],
+2
View File
@@ -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(() => {});
+2
View File
@@ -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; }
+35 -2
View File
@@ -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"], "<b>Hello</b> '); --")
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"])