diff --git a/README.md b/README.md
index cc8fd75..7a254fa 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,14 @@ While creating, the full character sheet stays visible next to the wizard. Edits
Each wizard page has a **Reset this page** button that clears only that page's data, plus **Start over** for the whole character.
+### Lobbies
+
+The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave).
+
+- The DM can open **every** character in the lobby (read-only, always the current saved version) and can remove players, rename or delete the lobby and generate a new code.
+- Players see their own character in full, but only a short card of the others (name, concept, class, profession).
+- Guessing join codes is rate limited. Deleting a character takes it out of the lobbies; deleting a lobby does not delete characters.
+
## API
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
@@ -91,9 +99,15 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
| GET | `/api/rules` | Rule data including presets |
| GET / POST | `/api/characters` | Own and shared-with-me characters / create one |
-| GET | `/api/characters/{id}` | Read (owner or shared users) |
+| GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) |
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) |
+| GET / POST | `/api/lobbies` | Own and joined lobbies / create one (you are the DM) |
+| POST | `/api/lobbies/join` | Join with `{code, character_id}` |
+| GET / PUT / DELETE | `/api/lobbies/{id}` | Members and cards (DM sees all ids) / rename / delete (DM only) |
+| POST | `/api/lobbies/{id}/code` | New join code (DM only) |
+| PUT / DELETE | `/api/lobbies/{id}/me` | Switch your character / leave |
+| DELETE | `/api/lobbies/{id}/members/{username}` | Remove a player (DM only) |
| POST | `/api/presets` | Create a preset |
| PUT / DELETE | `/api/presets/{id}` | Change / delete (owner only) |
| POST | `/api/export/pdf` or `/docx` | Character JSON in the body, file in the response |
diff --git a/backend/app/admin.py b/backend/app/admin.py
index 545841b..96a22c0 100644
--- a/backend/app/admin.py
+++ b/backend/app/admin.py
@@ -33,8 +33,10 @@ def main(argv):
return 1
con.execute("DELETE FROM sessions WHERE user_id=?", (row["id"],))
con.execute("DELETE FROM shares WHERE user_id=?", (row["id"],))
+ con.execute("DELETE FROM lobby_members WHERE user_id=? OR lobby_id IN (SELECT id FROM lobbies WHERE owner_id=?)", (row["id"], row["id"]))
+ con.execute("DELETE FROM lobbies WHERE owner_id=?", (row["id"],))
con.execute("DELETE FROM users WHERE id=?", (row["id"],))
- print("User deleted. Their characters and presets stay on the server without an owner.")
+ print("User deleted. Their lobbies are removed; their characters and presets stay on the server without an owner.")
else:
print(__doc__)
return 1
diff --git a/backend/app/main.py b/backend/app/main.py
index 65dcb7e..dd2c655 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -22,6 +22,9 @@ MAX_BODY = 200_000 # bytes per character
MAX_PRESETS = 200
MAX_CHARACTERS = 300 # per user
MAX_SHARES = 30 # per character
+MAX_LOBBIES = 20 # per DM
+MAX_MEMBERS = 20 # per lobby
+CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I
# No public API docs: everything about the API stays behind the login
app = FastAPI(title="Arcane Wasteland Character Creator", docs_url=None, redoc_url=None, openapi_url=None)
@@ -48,6 +51,11 @@ def db() -> sqlite3.Connection:
auth.init_tables(con)
con.execute("""CREATE TABLE IF NOT EXISTS shares (
character_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (character_id, user_id))""")
+ con.execute("""CREATE TABLE IF NOT EXISTS lobbies (
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, owner_id INTEGER NOT NULL, code TEXT NOT NULL UNIQUE, created REAL NOT NULL)""")
+ 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))""")
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")
@@ -326,11 +334,13 @@ def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
@api.get("/characters/{cid}")
def read_character(cid: str, user: dict = Depends(current_user)):
- """Only the owner and the users the character is shared with may read it."""
+ """Readable by the owner, by users the character is shared with, and by the DM of a lobby it plays in."""
with db() as con:
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
allowed = bool(row) and (row["owner_id"] == user["id"] or con.execute(
- "SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone())
+ "SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone() or con.execute(
+ """SELECT 1 FROM lobby_members m JOIN lobbies l ON l.id = m.lobby_id
+ WHERE m.character_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone())
if not allowed:
raise HTTPException(404, "Character not found")
return json.loads(row["data"])
@@ -376,6 +386,178 @@ def delete_character(cid: str, user: dict = Depends(current_user)):
_owned(con, "characters", cid, user)
con.execute("DELETE FROM characters WHERE id=?", (cid,))
con.execute("DELETE FROM shares WHERE character_id=?", (cid,))
+ con.execute("UPDATE lobby_members SET character_id=NULL WHERE character_id=?", (cid,))
+ return {"ok": True}
+
+
+# ---- lobbies: the creator is the DM and sees every member's character; players only see a short card of the others
+
+def _lobby_for(con, lid: str, user: dict):
+ """Returns the lobby row if the user is its DM or a member, otherwise 404 (hides that it exists)."""
+ row = con.execute("""SELECT l.*, u.username AS dm FROM lobbies l JOIN users u ON u.id = l.owner_id WHERE l.id=?""",
+ (lid,)).fetchone()
+ if not row or (row["owner_id"] != user["id"] and not con.execute(
+ "SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone()):
+ raise HTTPException(404, "Lobby not found")
+ return row
+
+
+def _dm_only(lobby, user: dict):
+ if lobby["owner_id"] != user["id"]:
+ raise HTTPException(403, "Only the DM can do this")
+
+
+def _new_code(con) -> str:
+ while True:
+ code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(8))
+ if not con.execute("SELECT 1 FROM lobbies WHERE code=?", (code,)).fetchone():
+ return code
+
+
+def _own_character(con, cid, user: dict):
+ row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=?", (cid, user["id"])).fetchone()
+ if not row:
+ raise HTTPException(422, "Pick one of your own characters")
+
+
+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)
+ cls = next((c for c in rules["classes"] if c["id"] == ch.get("class_id")), None)
+ sub = next((x for x in (cls or {}).get("subclasses", []) if x["id"] == ch.get("subclass_id")), None)
+ prof = next((p for p in rules["professions"] if p["id"] == ch.get("profession")), None)
+ return {"name": ch.get("name") or "Nameless", "concept": str(ch.get("concept") or "")[:120],
+ "class": (cls or {}).get("name", ""), "subclass": (sub or {}).get("name", ""),
+ "profession": (prof or {}).get("name", "")}
+
+
+@api.get("/lobbies")
+def my_lobbies(user: dict = Depends(current_user)):
+ with db() as con:
+ mine = con.execute("""SELECT l.id, l.name, (SELECT COUNT(*) FROM lobby_members m WHERE m.lobby_id = l.id) AS n
+ FROM lobbies l WHERE l.owner_id=? ORDER BY l.created DESC""", (user["id"],)).fetchall()
+ joined = con.execute("""SELECT l.id, l.name, u.username AS dm, c.data FROM lobby_members m
+ JOIN lobbies l ON l.id = m.lobby_id JOIN users u ON u.id = l.owner_id
+ LEFT JOIN characters c ON c.id = m.character_id
+ WHERE m.user_id=? ORDER BY m.joined DESC""", (user["id"],)).fetchall()
+ return {"mine": [{"id": r["id"], "name": r["name"], "members": r["n"]} for r in mine],
+ "joined": [{"id": r["id"], "name": r["name"], "dm": r["dm"], "character": _name(r["data"]) if r["data"] else ""}
+ for r in joined]}
+
+
+@api.post("/lobbies")
+def create_lobby(body: dict = Body(...), user: dict = Depends(current_user)):
+ name = str(body.get("name", "")).strip()[:60]
+ if not name:
+ raise HTTPException(422, "Give the lobby a name")
+ with db() as con:
+ if con.execute("SELECT COUNT(*) FROM lobbies WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_LOBBIES:
+ raise HTTPException(409, "Lobby limit reached")
+ 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()))
+ return {"id": lid, "code": code}
+
+
+@api.post("/lobbies/join")
+def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(current_user)):
+ ip = client_ip(request)
+ throttle(f"join:{ip}", 10, 600) # only wrong codes count, so guessing is slow
+ throttle(f"join:u{user['id']}", 10, 600)
+ code = re.sub(r"[^A-Za-z0-9]", "", str(body.get("code", ""))).upper()
+ with db() as con:
+ lobby = con.execute("SELECT * FROM lobbies WHERE code=?", (code,)).fetchone()
+ if not lobby:
+ limiter.fail(f"join:{ip}"); limiter.fail(f"join:u{user['id']}")
+ raise HTTPException(404, "No lobby with this code")
+ if lobby["owner_id"] == user["id"]:
+ raise HTTPException(422, "You are the DM of this lobby")
+ _own_character(con, body.get("character_id"), user)
+ known = con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lobby["id"], user["id"])).fetchone()
+ if not known and con.execute("SELECT COUNT(*) FROM lobby_members WHERE lobby_id=?", (lobby["id"],)).fetchone()[0] >= MAX_MEMBERS:
+ raise HTTPException(409, "This lobby is full")
+ 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()))
+ return {"id": lobby["id"]}
+
+
+@api.get("/lobbies/{lid}")
+def read_lobby(lid: str, user: dict = Depends(current_user)):
+ rules = load_rules()
+ with db() as con:
+ lobby = _lobby_for(con, lid, user)
+ is_dm = lobby["owner_id"] == user["id"]
+ rows = con.execute("""SELECT m.user_id, m.character_id, u.username, c.data FROM lobby_members m
+ JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id
+ WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
+ members = []
+ for r in rows:
+ mine = r["user_id"] == user["id"]
+ card = _card(r["data"], rules) if r["data"] else None
+ # The id is only given out where the viewer may open the full character (the DM sees all, a player only their own)
+ members.append({"username": r["username"], "me": mine, "character": card,
+ "character_id": r["character_id"] if (is_dm or mine) and card else None})
+ return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
+ "code": lobby["code"] if is_dm else None, "members": members}
+
+
+@api.put("/lobbies/{lid}")
+def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
+ name = str(body.get("name", "")).strip()[:60]
+ if not name:
+ raise HTTPException(422, "Give the lobby a name")
+ with db() as con:
+ _dm_only(_lobby_for(con, lid, user), user)
+ con.execute("UPDATE lobbies SET name=? WHERE id=?", (name, lid))
+ return {"ok": True}
+
+
+@api.delete("/lobbies/{lid}")
+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 lobbies WHERE id=?", (lid,))
+ return {"ok": True}
+
+
+@api.post("/lobbies/{lid}/code")
+def new_lobby_code(lid: str, user: dict = Depends(current_user)):
+ """New join code, the old one stops working (members stay)."""
+ with db() as con:
+ _dm_only(_lobby_for(con, lid, user), user)
+ code = _new_code(con)
+ con.execute("UPDATE lobbies SET code=? WHERE id=?", (code, lid))
+ return {"code": code}
+
+
+@api.put("/lobbies/{lid}/me")
+def switch_lobby_character(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
+ with db() as con:
+ _lobby_for(con, lid, user)
+ _own_character(con, body.get("character_id"), user)
+ cur = con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?",
+ (body["character_id"], lid, user["id"]))
+ if not cur.rowcount:
+ raise HTTPException(422, "You are the DM of this lobby")
+ return {"ok": True}
+
+
+@api.delete("/lobbies/{lid}/me")
+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"]))
+ return {"ok": True}
+
+
+@api.delete("/lobbies/{lid}/members/{username}")
+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))
return {"ok": True}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index fe1648d..464b5b3 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -3,6 +3,7 @@ import Creator from './Creator.jsx'
import Rules from './Rules.jsx'
import Characters from './Characters.jsx'
import Presets from './Presets.jsx'
+import Lobbies from './Lobbies.jsx'
import Login from './Login.jsx'
import Account from './Account.jsx'
import { claimLegacy } from './legacy.js'
@@ -52,7 +53,7 @@ export default function App() {
if (!auth.user) return setAuth({ ...auth, user: u })} />
const page = hash.split('/')[0]
- const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']]
+ const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies']]
return (
<>
@@ -77,6 +78,9 @@ export default function App() {
{ setLoadRequest(entry); window.location.hash = 'creator' }}
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
)}
+ {rules && page === 'lobbies' && (
+ { setLoadRequest(entry); window.location.hash = 'creator' }} />
+ )}
{page === 'account' && }
>
diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx
new file mode 100644
index 0000000..a3fb83d
--- /dev/null
+++ b/frontend/src/Lobbies.jsx
@@ -0,0 +1,220 @@
+import { useEffect, useState } from 'react'
+
+const J = { 'Content-Type': 'application/json' }
+const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
+const msg = (d, fallback) => (typeof d?.detail === 'string' ? d.detail : fallback)
+
+function useMine() {
+ const [mine, setMine] = useState([])
+ useEffect(() => { fetch('/api/characters').then((r) => r.json()).then((d) => setMine(d.mine || [])).catch(() => {}) }, [])
+ return mine
+}
+
+function CharSelect({ mine, value, onChange, label }) {
+ return (
+
+ )
+}
+
+// ---- list of lobbies: create one (you become the DM) or join one with a code
+function LobbyList() {
+ const mine = useMine()
+ const [data, setData] = useState(null)
+ const [name, setName] = useState('')
+ const [code, setCode] = useState('')
+ const [cid, setCid] = useState('')
+ const [err, setErr] = useState('')
+
+ const load = () => fetch('/api/lobbies').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your lobbies.'))
+ useEffect(() => { load() }, [])
+
+ const create = async (e) => {
+ e.preventDefault(); setErr('')
+ const r = await send('/api/lobbies', 'POST', { name })
+ const d = await r.json().catch(() => ({}))
+ if (!r.ok) return setErr(msg(d, 'Could not create the lobby.'))
+ window.location.hash = `lobbies/${d.id}`
+ }
+ const join = async (e) => {
+ e.preventDefault(); setErr('')
+ if (!cid) return setErr('Choose which of your characters joins.')
+ const r = await send('/api/lobbies/join', 'POST', { code, character_id: cid })
+ const d = await r.json().catch(() => ({}))
+ if (!r.ok) return setErr(msg(d, 'Could not join.'))
+ window.location.hash = `lobbies/${d.id}`
+ }
+
+ return (
+
+
Lobbies
+
+ A lobby is a group for one campaign. Whoever creates it is the DM and can look at every character in it.
+ Players only see a short card of each other (name, concept, class and profession).
+
+ {err &&
{err}
}
+
+
+
Join a lobby
+
+ {mine.length === 0 &&
You need a saved character first (Character → Summary → Save).
}
+
+
+
Lobbies you joined
+ {data && data.joined.length === 0 &&
None yet.
}
+
+ {(data?.joined || []).map((l) => (
+
+
+ {l.name}
+
DM {l.dm}{l.character ? ` · playing ${l.character}` : ' · no character selected'}