Chat
This commit is contained in:
@@ -27,6 +27,8 @@ Every press records the zone, the points, and the exact remaining time in millis
|
||||
|
||||
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.
|
||||
|
||||
## Running
|
||||
|
||||
### Docker (recommended)
|
||||
@@ -90,6 +92,8 @@ Tip: `BUTTON_DURATION_SECONDS=60` makes for quick testing.
|
||||
| POST | `/api/press` | ✓ | Press the button |
|
||||
| GET | `/api/me` | ✓ | Your score, zone counts, and press history |
|
||||
| GET | `/api/leaderboard` | | Public player names, total points, and press counts |
|
||||
| GET | `/api/chat` | | Latest 100 public chat messages |
|
||||
| POST | `/api/chat` | ✓ | `{message}` → saved message; 200-character limit |
|
||||
| GET | `/api/stats` | | Global stats (403 until the game ends) |
|
||||
| POST | `/api/subscribe` | ✓ | `{first_name, email, last_name?, phone?}` |
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ PBKDF2_ITERATIONS = 600_000
|
||||
MAX_SESSIONS_PER_PLAYER = 10
|
||||
LOGIN_MAX_FAILURES = 5
|
||||
LOGIN_LOCKOUT_MS = 15 * 60 * 1000
|
||||
MAX_CHAT_LENGTH = 200
|
||||
CHAT_HISTORY_LIMIT = 100
|
||||
CHAT_COOLDOWN_MS = 2000
|
||||
|
||||
# Ordered from a freshly reset countdown to an almost-expired one.
|
||||
# "share" is each zone's relative slice of the timer, so high-value zones are brief.
|
||||
@@ -186,6 +189,14 @@ def load_state():
|
||||
zone TEXT NOT NULL,
|
||||
points INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS 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 CHECK (length(message) BETWEEN 1 AND 200)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS chat_player_messages ON chat_messages (player_id, id);
|
||||
""")
|
||||
# Serialize initialization. A committed game row is the migration marker,
|
||||
# not merely the existence of a possibly empty or interrupted database.
|
||||
@@ -235,6 +246,15 @@ token_index = {th: pid for pid, p in state["players"].items() for th in p["token
|
||||
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 ?",
|
||||
(CHAT_HISTORY_LIMIT,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in reversed(rows)]
|
||||
|
||||
|
||||
def find_player_by_name(name):
|
||||
for pid, p in state["players"].items():
|
||||
if p["name"].lower() == name.lower():
|
||||
@@ -413,6 +433,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path == "/api/leaderboard":
|
||||
with lock:
|
||||
return self.send_json(HTTPStatus.OK, {"leaderboard": public_leaderboard()})
|
||||
if path == "/api/chat":
|
||||
return self.send_json(HTTPStatus.OK, {"messages": chat_messages()})
|
||||
if path == "/api/me":
|
||||
with lock:
|
||||
pid = self.authed_player()
|
||||
@@ -444,6 +466,33 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid JSON body"})
|
||||
|
||||
if path == "/api/chat":
|
||||
with lock:
|
||||
pid = self.authed_player()
|
||||
if pid is None:
|
||||
return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Log in to send messages"})
|
||||
message = data.get("message")
|
||||
if not isinstance(message, str) or not (1 <= len(message.strip()) <= MAX_CHAT_LENGTH):
|
||||
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": f"Message must be 1-{MAX_CHAT_LENGTH} characters"})
|
||||
message = message.strip()
|
||||
if not message.isprintable():
|
||||
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Message contains invalid characters"})
|
||||
with database() as connection:
|
||||
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,),
|
||||
).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"})
|
||||
name = state["players"][pid]["name"]
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)",
|
||||
(pid, name, t, message),
|
||||
)
|
||||
posted = {"id": cursor.lastrowid, "name": name, "at": t, "message": message}
|
||||
return self.send_json(HTTPStatus.CREATED, posted)
|
||||
|
||||
if path == "/api/register":
|
||||
name, password, error = validate_credentials(data)
|
||||
if error:
|
||||
|
||||
@@ -10,11 +10,14 @@ let statsLoaded = false;
|
||||
let subscribed = false;
|
||||
let currentShareText = "";
|
||||
let selectedSection = null;
|
||||
let chatSignature = null;
|
||||
let chatLoading = false;
|
||||
|
||||
function renderSections() {
|
||||
const available = {
|
||||
"account-section": true,
|
||||
"leaderboard-section": true,
|
||||
"chat-section": true,
|
||||
"stats-section": statsLoaded,
|
||||
rules: true,
|
||||
"share-controls": !!currentShareText,
|
||||
@@ -32,6 +35,8 @@ function renderSections() {
|
||||
panel.hidden = panel.id !== selectedSection;
|
||||
});
|
||||
$("mailing-list").hidden = !token || subscribed;
|
||||
$("chat-form").hidden = !token;
|
||||
$("chat-login").hidden = !!token;
|
||||
$("section-hint").hidden = selectedSection !== null;
|
||||
}
|
||||
|
||||
@@ -42,6 +47,7 @@ $("section-nav").addEventListener("click", (event) => {
|
||||
renderSections();
|
||||
if (selectedSection === "account-section") loadMe();
|
||||
if (selectedSection === "leaderboard-section") loadLeaderboard();
|
||||
if (selectedSection === "chat-section") loadChat();
|
||||
});
|
||||
|
||||
async function api(method, path, body) {
|
||||
@@ -185,6 +191,7 @@ async function refreshState() {
|
||||
if (game.ended && !statsLoaded) loadStats();
|
||||
updateButton();
|
||||
await loadLeaderboard();
|
||||
if (selectedSection === "chat-section") await loadChat();
|
||||
}
|
||||
|
||||
function updateButton() {
|
||||
@@ -254,6 +261,65 @@ async function loadLeaderboard() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChat() {
|
||||
if (chatLoading) return;
|
||||
chatLoading = true;
|
||||
try {
|
||||
const { messages } = await api("GET", "/api/chat");
|
||||
const signature = messages.map((message) => message.id).join(",");
|
||||
const list = $("chat-messages");
|
||||
if (signature !== chatSignature) {
|
||||
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 40;
|
||||
const firstLoad = chatSignature === null;
|
||||
const previousScroll = list.scrollTop;
|
||||
list.replaceChildren(...messages.map((message) => {
|
||||
const item = el("li");
|
||||
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");
|
||||
const heading = el("div", undefined, "chat-heading");
|
||||
const date = new Date(message.at);
|
||||
const timestamp = el("time", date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
|
||||
timestamp.dateTime = new Date(message.at).toISOString();
|
||||
timestamp.title = date.toLocaleString();
|
||||
heading.append(el("strong", message.name), timestamp);
|
||||
// Names and messages are untrusted text, never HTML.
|
||||
content.append(heading, el("p", message.message));
|
||||
item.append(avatar, content);
|
||||
return item;
|
||||
}));
|
||||
chatSignature = signature;
|
||||
list.scrollTop = firstLoad || atBottom ? list.scrollHeight : previousScroll;
|
||||
}
|
||||
$("chat-status").textContent = messages.length ? "" : "No messages yet. Start the conversation!";
|
||||
} catch {
|
||||
$("chat-status").textContent = "Messages could not be refreshed. Retrying automatically...";
|
||||
} finally {
|
||||
chatLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$("chat-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const input = $("chat-input");
|
||||
$("chat-send").disabled = true;
|
||||
$("chat-error").textContent = "";
|
||||
try {
|
||||
await api("POST", "/api/chat", { message: input.value });
|
||||
input.value = "";
|
||||
await loadChat();
|
||||
} catch (error) {
|
||||
$("chat-error").textContent = error.message;
|
||||
if (error.status === 401) {
|
||||
token = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
updateButton();
|
||||
}
|
||||
} finally {
|
||||
$("chat-send").disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await api("GET", "/api/stats");
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<nav id="section-nav" aria-label="Game sections">
|
||||
<button type="button" data-section="account-section" aria-controls="account-section" aria-expanded="false">Log in / Join</button>
|
||||
<button type="button" data-section="leaderboard-section" aria-controls="leaderboard-section" aria-expanded="false">Leaderboard</button>
|
||||
<button type="button" data-section="chat-section" aria-controls="chat-section" aria-expanded="false">Chat</button>
|
||||
<button type="button" data-section="stats-section" aria-controls="stats-section" aria-expanded="false" hidden>Final stats</button>
|
||||
<button type="button" data-section="rules" aria-controls="rules" aria-expanded="false">Rules</button>
|
||||
<button type="button" data-section="share-controls" aria-controls="share-controls" aria-expanded="false" hidden>Share</button>
|
||||
@@ -94,6 +95,34 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="chat-section" data-panel hidden aria-labelledby="chat-heading">
|
||||
<header class="chat-header">
|
||||
<div>
|
||||
<p class="chat-eyebrow">THE PLAYER LOUNGE</p>
|
||||
<h2 id="chat-heading">A little company<br>while you wait.</h2>
|
||||
<p class="chat-description">Talk strategy. Share a close call. Say hello.</p>
|
||||
</div>
|
||||
<span class="chat-badge">Public room</span>
|
||||
</header>
|
||||
<div class="chat-feed">
|
||||
<div class="chat-feed-label"><span>Conversation</span><span>Latest 100 messages</span></div>
|
||||
<ol id="chat-messages" aria-label="Chat messages" tabindex="0"></ol>
|
||||
<p id="chat-status" role="status">Loading messages…</p>
|
||||
</div>
|
||||
<div class="chat-composer">
|
||||
<p id="chat-login">Pull up a seat. Log in or join the game to say hello.</p>
|
||||
<form id="chat-form" hidden>
|
||||
<label for="chat-input">Add to the conversation</label>
|
||||
<div class="chat-input-row">
|
||||
<input id="chat-input" maxlength="200" placeholder="What's on your mind?" required autocomplete="off" aria-describedby="chat-help">
|
||||
<button id="chat-send" type="submit">Send <span aria-hidden="true">↑</span></button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="chat-help">Be kind. 200 characters max · 2 seconds between messages</p>
|
||||
<p id="chat-error" class="error" role="status"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="stats-section" data-panel hidden>
|
||||
<h2>Game over — final global stats</h2>
|
||||
<div id="stats-summary"></div>
|
||||
|
||||
@@ -220,6 +220,67 @@ button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visibl
|
||||
|
||||
.error { color: var(--red); }
|
||||
|
||||
#chat-section {
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
background: var(--mantle);
|
||||
border: 1px solid var(--surface);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 12px 32px #11111b40;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1.75rem;
|
||||
background: linear-gradient(120deg, #cba6f710, transparent 75%);
|
||||
border-bottom: 1px solid var(--surface);
|
||||
}
|
||||
.chat-eyebrow { margin: 0 0 0.65rem; font-size: 0.65rem; letter-spacing: 0.18em; color: var(--mauve); font-weight: 700; }
|
||||
#chat-heading { margin: 0; font-size: clamp(1.4rem, 2.5vw, 1.9rem); line-height: 1.2; letter-spacing: -0.03em; }
|
||||
.chat-description { margin: 0.7rem 0 0; font-size: 0.85rem; color: var(--subtext); line-height: 1.6; }
|
||||
.chat-badge { flex-shrink: 0; padding: 0.35rem 0.6rem; border: 1px solid #a6e3a130; border-radius: 20px; color: var(--green); background: #a6e3a109; font-size: 0.65rem; }
|
||||
.chat-feed { padding: 1rem 1.25rem; min-height: 280px; }
|
||||
.chat-feed-label { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.5rem; color: var(--subtext); font-size: 0.65rem; padding: 0.25rem 0.25rem 0.9rem; }
|
||||
.chat-feed-label span:first-child { text-transform: uppercase; letter-spacing: 0.12em; }
|
||||
#chat-messages {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0.25rem;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--surface) transparent;
|
||||
}
|
||||
#chat-messages:empty { display: none; }
|
||||
#chat-messages li { display: flex; align-items: flex-start; gap: 0.65rem; padding: 0.7rem 0; }
|
||||
.chat-avatar { display: grid; place-items: center; flex: 0 0 32px; width: 32px; height: 32px; border-radius: 11px; background: #cba6f71a; color: var(--mauve); font-weight: 700; font-size: 0.8rem; }
|
||||
#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-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; }
|
||||
#chat-messages p { margin: 0; padding: 0.7rem 0.85rem; border: 1px solid #45475a60; border-radius: 0 12px 12px 12px; background: var(--base); font-size: 0.9rem; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.chat-composer { border-top: 1px solid var(--surface); padding: 1.25rem 1.5rem; background: #11111b35; }
|
||||
#chat-form { display: block; }
|
||||
#chat-form label { display: block; margin-bottom: 0.6rem; font-size: 0.75rem; font-weight: 600; color: var(--subtext); }
|
||||
.chat-input-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem; background: var(--base); border: 1px solid var(--surface); border-radius: 12px; }
|
||||
.chat-input-row:focus-within { border-color: var(--mauve); }
|
||||
#chat-input { flex: 1; min-width: 0; width: 100%; border: none; background: transparent; padding: 0.5rem; font-size: 0.85rem; }
|
||||
#chat-input:focus-visible { outline-offset: -2px; border-radius: 6px; }
|
||||
#chat-input::placeholder { color: var(--subtext); }
|
||||
#chat-form #chat-send { display: flex; align-items: center; gap: 0.65rem; min-height: 40px; border: none; border-radius: 8px; background: var(--mauve); color: var(--crust); font-weight: 700; font-size: 0.8rem; }
|
||||
#chat-send span { font-size: 1.2rem; }
|
||||
#chat-form #chat-send:hover { background: var(--lavender); }
|
||||
#chat-send:disabled { opacity: 0.5; cursor: wait; }
|
||||
#chat-help { margin: 0.7rem 0 0; color: var(--subtext); font-size: 0.65rem; line-height: 1.6; }
|
||||
#chat-status { margin: 0; padding: 4rem 1rem; text-align: center; color: var(--subtext); font-size: 0.85rem; line-height: 1.6; }
|
||||
#chat-login { margin: 0; color: var(--lavender); font-size: 0.85rem; line-height: 1.6; }
|
||||
#chat-status:empty, #chat-error:empty { display: none; }
|
||||
|
||||
#rules { text-align: left; font-size: 0.9rem; color: var(--subtext); }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -244,4 +305,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visibl
|
||||
#press-result { max-width: 110px; margin: 0.4rem 0 0; font-size: 0.7rem; }
|
||||
#press-result:empty { display: none; }
|
||||
.section-browser { margin-top: 1rem; }
|
||||
.chat-header { padding: 1.25rem; flex-wrap: wrap; }
|
||||
.chat-feed { padding: 0.9rem; min-height: 230px; }
|
||||
.chat-composer { padding: 1rem; }
|
||||
}
|
||||
|
||||
@@ -202,6 +202,69 @@ class PersistenceTests(unittest.TestCase):
|
||||
self.game.state = self.game.new_game()
|
||||
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.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)
|
||||
thread.start()
|
||||
self.addCleanup(httpd.server_close)
|
||||
self.addCleanup(thread.join)
|
||||
self.addCleanup(httpd.shutdown)
|
||||
|
||||
def request(method, data=None, authenticated=False):
|
||||
connection = HTTPConnection(*httpd.server_address, timeout=5)
|
||||
try:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if authenticated:
|
||||
headers["Authorization"] = "Bearer test-chat-token"
|
||||
connection.request(method, "/api/chat", json.dumps(data) if data is not None else None, headers)
|
||||
response = connection.getresponse()
|
||||
return response.status, json.loads(response.read())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(request("GET"), (200, {"messages": []}))
|
||||
self.assertEqual(request("POST", {"message": "Hello"})[0], 401)
|
||||
for message in (None, 42, "", " ", "x" * 201, "hi\nthere", "bad\x00text"):
|
||||
self.assertEqual(request("POST", {"message": message}, True)[0], 400)
|
||||
with patch.object(self.game, "now_ms", return_value=10000):
|
||||
status, posted = request("POST", {"message": " <b>Hello</b> '); -- ", "name": "Imposter"}, True)
|
||||
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(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)
|
||||
self.game.state = self.game.load_state()
|
||||
self.assertEqual(request("POST", {"message": "Still too soon"}, True)[0], 429)
|
||||
self.assertEqual(request("GET")[1], {"messages": [posted]})
|
||||
self.game.state["ended"] = True
|
||||
with patch.object(self.game, "now_ms", return_value=12000):
|
||||
self.assertEqual(request("POST", {"message": "x" * 200}, True)[0], 201)
|
||||
self.game.token_index = {}
|
||||
self.assertEqual(request("POST", {"message": "Revoked"}, True)[0], 401)
|
||||
with self.game.database() as connection:
|
||||
self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])
|
||||
|
||||
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)
|
||||
with self.game.database() as connection:
|
||||
connection.executemany(
|
||||
"INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)",
|
||||
[("alice", "Alice", index, str(index)) for index in range(105)],
|
||||
)
|
||||
messages = self.game.chat_messages()
|
||||
self.assertEqual(len(messages), 100)
|
||||
self.assertEqual([message["message"] for message in messages], [str(i) for i in range(5, 105)])
|
||||
self.game.load_state()
|
||||
self.assertEqual(messages, self.game.chat_messages())
|
||||
|
||||
def test_http_registration_login_press_logout_and_restart(self):
|
||||
self.game.state = self.game.load_state()
|
||||
self.game.token_index = {}
|
||||
|
||||
Reference in New Issue
Block a user