Lobby: tabs, live chat with whispers, event log, embedded read-only character sheets

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-21 16:38:28 +00:00
parent aad8e0515b
commit 94d3505b82
7 changed files with 343 additions and 63 deletions
+74 -3
View File
@@ -24,6 +24,8 @@ MAX_CHARACTERS = 300 # per user
MAX_SHARES = 30 # per character
MAX_LOBBIES = 20 # per DM
MAX_MEMBERS = 20 # per lobby
MAX_MESSAGE = 500 # characters per chat message
KEEP_MESSAGES = 1000 # per lobby, older ones are dropped
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I
# No public API docs: everything about the API stays behind the login
@@ -56,6 +58,10 @@ def db() -> sqlite3.Connection:
con.execute("""CREATE TABLE IF NOT EXISTS lobby_members (
lobby_id TEXT NOT NULL, user_id INTEGER NOT NULL, character_id TEXT, joined REAL NOT NULL,
PRIMARY KEY (lobby_id, user_id))""")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, lobby_id TEXT NOT NULL, user_id INTEGER, to_user_id INTEGER,
kind TEXT NOT NULL, text TEXT NOT NULL, created REAL NOT NULL)""")
con.execute("CREATE INDEX IF NOT EXISTS lobby_messages_lobby ON lobby_messages (lobby_id, id)")
for table in ("characters", "presets"): # older databases: add the owner column
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
@@ -420,6 +426,17 @@ def _own_character(con, cid, user: dict):
raise HTTPException(422, "Pick one of your own characters")
def _event(con, lid: str, text: str, uid=None):
"""Writes a line into the lobby's event log (shown in the chat feed to everybody)."""
con.execute("INSERT INTO lobby_messages (lobby_id, user_id, kind, text, created) VALUES (?,?, 'event', ?, ?)",
(lid, uid, text, time.time()))
def _char_name(con, cid) -> str:
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
return _name(row["data"]) if row else "a character"
def _card(data: str, rules: dict) -> dict:
"""The superficial look at a character that fellow players get: name, concept and class/profession names only."""
ch = json.loads(data)
@@ -456,6 +473,7 @@ def create_lobby(body: dict = Body(...), user: dict = Depends(current_user)):
lid, code = uuid.uuid4().hex[:12], _new_code(con)
con.execute("INSERT INTO lobbies (id, name, owner_id, code, created) VALUES (?,?,?,?,?)",
(lid, name, user["id"], code, time.time()))
_event(con, lid, f"{user['username']} created the lobby", user["id"])
return {"id": lid, "code": code}
@@ -479,6 +497,7 @@ def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(cu
con.execute("""INSERT INTO lobby_members (lobby_id, user_id, character_id, joined) VALUES (?,?,?,?)
ON CONFLICT(lobby_id, user_id) DO UPDATE SET character_id=excluded.character_id""",
(lobby["id"], user["id"], body["character_id"], time.time()))
_event(con, lobby["id"], f"{user['username']} {'switched to' if known else 'joined as'} {_char_name(con, body['character_id'])}", user["id"])
return {"id": lobby["id"]}
@@ -502,6 +521,50 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
"code": lobby["code"] if is_dm else None, "members": members}
@api.get("/lobbies/{lid}/messages")
def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)):
"""Chat and event log. Whispers are only returned to the sender and the recipient (not even the DM sees them).
after=0 returns the latest 100 entries, otherwise everything newer than that id (the client polls)."""
with db() as con:
_lobby_for(con, lid, user)
q = """SELECT m.id, m.kind, m.text, m.created, m.user_id, m.to_user_id, f.username AS sender, t.username AS target
FROM lobby_messages m LEFT JOIN users f ON f.id = m.user_id LEFT JOIN users t ON t.id = m.to_user_id
WHERE m.lobby_id=? AND (m.to_user_id IS NULL OR m.to_user_id=? OR m.user_id=?)"""
if after > 0:
rows = con.execute(q + " AND m.id>? ORDER BY m.id LIMIT 200", (lid, user["id"], user["id"], after)).fetchall()
else:
rows = list(reversed(con.execute(q + " ORDER BY m.id DESC LIMIT 100", (lid, user["id"], user["id"])).fetchall()))
return [{"id": r["id"], "kind": r["kind"], "text": r["text"], "time": r["created"], "from": r["sender"] or "",
"to": r["target"] or "", "mine": r["user_id"] == user["id"] and r["kind"] != "event"} for r in rows]
@api.post("/lobbies/{lid}/messages")
def send_message(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
text = str(body.get("text", "")).strip()
if not text:
raise HTTPException(422, "Write something first")
if len(text) > MAX_MESSAGE:
raise HTTPException(422, f"Messages can have at most {MAX_MESSAGE} characters")
throttle(f"chat:{user['id']}", 30, 60) # 30 messages per minute
limiter.fail(f"chat:{user['id']}")
with db() as con:
lobby = _lobby_for(con, lid, user)
to = str(body.get("to") or "").strip()
to_id = None
if to:
target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS
(SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""",
(to, lobby["owner_id"], lid)).fetchone()
if not target or target["id"] == user["id"]:
raise HTTPException(422, "You can only whisper to other people in this lobby")
to_id = target["id"]
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)",
(lid, user["id"], to_id, "whisper" if to_id else "chat", text, time.time()))
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
return {"id": cur.lastrowid}
@api.put("/lobbies/{lid}")
def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
name = str(body.get("name", "")).strip()[:60]
@@ -510,6 +573,7 @@ def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
con.execute("UPDATE lobbies SET name=? WHERE id=?", (name, lid))
_event(con, lid, f"The lobby was renamed to \"{name}\"", user["id"])
return {"ok": True}
@@ -518,6 +582,7 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
return {"ok": True}
@@ -529,6 +594,7 @@ def new_lobby_code(lid: str, user: dict = Depends(current_user)):
_dm_only(_lobby_for(con, lid, user), user)
code = _new_code(con)
con.execute("UPDATE lobbies SET code=? WHERE id=?", (code, lid))
_event(con, lid, "The DM created a new join code", user["id"])
return {"code": code}
@@ -541,6 +607,7 @@ def switch_lobby_character(lid: str, body: dict = Body(...), user: dict = Depend
(body["character_id"], lid, user["id"]))
if not cur.rowcount:
raise HTTPException(422, "You are the DM of this lobby")
_event(con, lid, f"{user['username']} switched to {_char_name(con, body['character_id'])}", user["id"])
return {"ok": True}
@@ -548,7 +615,9 @@ def switch_lobby_character(lid: str, body: dict = Body(...), user: dict = Depend
def leave_lobby(lid: str, user: dict = Depends(current_user)):
with db() as con:
_lobby_for(con, lid, user)
con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"]))
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).rowcount
if gone:
_event(con, lid, f"{user['username']} left the lobby", user["id"])
return {"ok": True}
@@ -556,8 +625,10 @@ def leave_lobby(lid: str, user: dict = Depends(current_user)):
def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)",
(lid, username))
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)",
(lid, username)).rowcount
if gone:
_event(con, lid, f"{username} was removed by the DM", user["id"])
return {"ok": True}