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:
+74
-3
@@ -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}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user