Announce presses
This commit is contained in:
@@ -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.
|
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
|
## Running
|
||||||
|
|
||||||
### Docker (recommended)
|
### Docker (recommended)
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ def load_state():
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS chat_player_messages ON chat_messages (player_id, id);
|
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,
|
# Serialize initialization. A committed game row is the migration marker,
|
||||||
# not merely the existence of a possibly empty or interrupted database.
|
# not merely the existence of a possibly empty or interrupted database.
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
@@ -249,7 +252,7 @@ failed_logins = {} # lowercase name -> (failure count, first failure ms)
|
|||||||
def chat_messages():
|
def chat_messages():
|
||||||
with database() as connection:
|
with database() as connection:
|
||||||
rows = connection.execute(
|
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,),
|
(CHAT_HISTORY_LIMIT,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in reversed(rows)]
|
return [dict(row) for row in reversed(rows)]
|
||||||
@@ -481,7 +484,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
t = now_ms()
|
t = now_ms()
|
||||||
last = connection.execute(
|
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()
|
).fetchone()
|
||||||
if last is not None and t - last["at"] < CHAT_COOLDOWN_MS:
|
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"})
|
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 (?, ?, ?, ?)",
|
"INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)",
|
||||||
(pid, name, t, message),
|
(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)
|
return self.send_json(HTTPStatus.CREATED, posted)
|
||||||
|
|
||||||
if path == "/api/register":
|
if path == "/api/register":
|
||||||
@@ -589,7 +592,25 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
}
|
}
|
||||||
state["presses"].append(press)
|
state["presses"].append(press)
|
||||||
state["deadline"] = t + DURATION_MS
|
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, {
|
return self.send_json(HTTPStatus.OK, {
|
||||||
"press": {k: press[k] for k in ("at", "remaining_ms", "zone", "points")},
|
"press": {k: press[k] for k in ("at", "remaining_ms", "zone", "points")},
|
||||||
"deadline": state["deadline"],
|
"deadline": state["deadline"],
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ async function loadChat() {
|
|||||||
const previousScroll = list.scrollTop;
|
const previousScroll = list.scrollTop;
|
||||||
list.replaceChildren(...messages.map((message) => {
|
list.replaceChildren(...messages.map((message) => {
|
||||||
const item = el("li");
|
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");
|
const avatar = el("span", Array.from(message.name.trim())[0]?.toUpperCase() || "?", "chat-avatar");
|
||||||
avatar.setAttribute("aria-hidden", "true");
|
avatar.setAttribute("aria-hidden", "true");
|
||||||
const content = el("div", undefined, "chat-message-content");
|
const content = el("div", undefined, "chat-message-content");
|
||||||
@@ -371,6 +372,7 @@ $("the-button").addEventListener("click", async () => {
|
|||||||
updateShareLinks();
|
updateShareLinks();
|
||||||
await loadMe();
|
await loadMe();
|
||||||
await loadLeaderboard();
|
await loadLeaderboard();
|
||||||
|
if (selectedSection === "chat-section") await loadChat();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("press-result").textContent = e.message;
|
$("press-result").textContent = e.message;
|
||||||
await refreshState().catch(() => {});
|
await refreshState().catch(() => {});
|
||||||
|
|||||||
@@ -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 + 2) .chat-avatar { background: #94e2d51a; color: var(--teal); }
|
||||||
#chat-messages li:nth-child(3n) .chat-avatar { background: #fab3871a; color: var(--peach); }
|
#chat-messages li:nth-child(3n) .chat-avatar { background: #fab3871a; color: var(--peach); }
|
||||||
.chat-message-content { min-width: 0; flex: 1; }
|
.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 { 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 strong { color: var(--text); font-size: 0.8rem; overflow-wrap: anywhere; }
|
||||||
.chat-heading time { color: var(--subtext); font-size: 0.65rem; }
|
.chat-heading time { color: var(--subtext); font-size: 0.65rem; }
|
||||||
|
|||||||
+35
-2
@@ -235,7 +235,7 @@ class PersistenceTests(unittest.TestCase):
|
|||||||
self.assertEqual(status, 201)
|
self.assertEqual(status, 201)
|
||||||
self.assertEqual(posted["name"], self.game.state["players"]["alice"]["name"])
|
self.assertEqual(posted["name"], self.game.state["players"]["alice"]["name"])
|
||||||
self.assertEqual(posted["message"], "<b>Hello</b> '); --")
|
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)
|
self.assertEqual(request("POST", {"message": "Too soon"}, True)[0], 429)
|
||||||
# Snapshot writes delete/reinsert players; deferred chat FKs must survive.
|
# Snapshot writes delete/reinsert players; deferred chat FKs must survive.
|
||||||
self.game.save_state(self.game.state)
|
self.game.save_state(self.game.state)
|
||||||
@@ -250,6 +250,26 @@ class PersistenceTests(unittest.TestCase):
|
|||||||
with self.game.database() as connection:
|
with self.game.database() as connection:
|
||||||
self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])
|
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):
|
def test_chat_returns_latest_hundred_in_chronological_order(self):
|
||||||
self.game.load_state()
|
self.game.load_state()
|
||||||
self.game.state = self.sample_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"})
|
status, registered = request("/api/register", {"name": "Alice", "password": "test-password"})
|
||||||
self.assertEqual(status, 201)
|
self.assertEqual(status, 201)
|
||||||
token = registered["token"]
|
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.assertEqual(request("/api/subscribe", {"first_name": "Alice", "email": "alice@example.com"}, token)[0], 200)
|
||||||
self.game.state = self.game.load_state()
|
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.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)
|
self.assertEqual(request("/api/press", {}, token)[0], 200)
|
||||||
status, logged_in = request("/api/login", {"name": "Alice", "password": "test-password"})
|
status, logged_in = request("/api/login", {"name": "Alice", "password": "test-password"})
|
||||||
self.assertEqual(status, 200)
|
self.assertEqual(status, 200)
|
||||||
self.assertEqual(request("/api/logout", {}, token)[0], 200)
|
self.assertEqual(request("/api/logout", {}, token)[0], 200)
|
||||||
self.assertEqual(request("/api/press", {}, token)[0], 401)
|
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()
|
loaded = self.game.load_state()
|
||||||
player = next(iter(loaded["players"].values()))
|
player = next(iter(loaded["players"].values()))
|
||||||
self.assertTrue(player["subscribed"])
|
self.assertTrue(player["subscribed"])
|
||||||
|
|||||||
Reference in New Issue
Block a user