Add lobbies: DM sees all characters, players only a short card of others
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -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.
|
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
|
## API
|
||||||
|
|
||||||
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
|
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 |
|
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
|
||||||
| GET | `/api/rules` | Rule data including presets |
|
| GET | `/api/rules` | Rule data including presets |
|
||||||
| GET / POST | `/api/characters` | Own and shared-with-me characters / create one |
|
| 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) |
|
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
|
||||||
| PUT / DELETE | `/api/characters/{id}` | Change / delete (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 |
|
| POST | `/api/presets` | Create a preset |
|
||||||
| PUT / DELETE | `/api/presets/{id}` | Change / delete (owner only) |
|
| PUT / DELETE | `/api/presets/{id}` | Change / delete (owner only) |
|
||||||
| POST | `/api/export/pdf` or `/docx` | Character JSON in the body, file in the response |
|
| POST | `/api/export/pdf` or `/docx` | Character JSON in the body, file in the response |
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ def main(argv):
|
|||||||
return 1
|
return 1
|
||||||
con.execute("DELETE FROM sessions WHERE user_id=?", (row["id"],))
|
con.execute("DELETE FROM sessions WHERE user_id=?", (row["id"],))
|
||||||
con.execute("DELETE FROM shares 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"],))
|
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:
|
else:
|
||||||
print(__doc__)
|
print(__doc__)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
+184
-2
@@ -22,6 +22,9 @@ MAX_BODY = 200_000 # bytes per character
|
|||||||
MAX_PRESETS = 200
|
MAX_PRESETS = 200
|
||||||
MAX_CHARACTERS = 300 # per user
|
MAX_CHARACTERS = 300 # per user
|
||||||
MAX_SHARES = 30 # per character
|
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
|
# 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)
|
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)
|
auth.init_tables(con)
|
||||||
con.execute("""CREATE TABLE IF NOT EXISTS shares (
|
con.execute("""CREATE TABLE IF NOT EXISTS shares (
|
||||||
character_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (character_id, user_id))""")
|
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
|
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})")]:
|
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")
|
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}")
|
@api.get("/characters/{cid}")
|
||||||
def read_character(cid: str, user: dict = Depends(current_user)):
|
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:
|
with db() as con:
|
||||||
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
|
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(
|
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:
|
if not allowed:
|
||||||
raise HTTPException(404, "Character not found")
|
raise HTTPException(404, "Character not found")
|
||||||
return json.loads(row["data"])
|
return json.loads(row["data"])
|
||||||
@@ -376,6 +386,178 @@ def delete_character(cid: str, user: dict = Depends(current_user)):
|
|||||||
_owned(con, "characters", cid, user)
|
_owned(con, "characters", cid, user)
|
||||||
con.execute("DELETE FROM characters WHERE id=?", (cid,))
|
con.execute("DELETE FROM characters WHERE id=?", (cid,))
|
||||||
con.execute("DELETE FROM shares WHERE character_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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Creator from './Creator.jsx'
|
|||||||
import Rules from './Rules.jsx'
|
import Rules from './Rules.jsx'
|
||||||
import Characters from './Characters.jsx'
|
import Characters from './Characters.jsx'
|
||||||
import Presets from './Presets.jsx'
|
import Presets from './Presets.jsx'
|
||||||
|
import Lobbies from './Lobbies.jsx'
|
||||||
import Login from './Login.jsx'
|
import Login from './Login.jsx'
|
||||||
import Account from './Account.jsx'
|
import Account from './Account.jsx'
|
||||||
import { claimLegacy } from './legacy.js'
|
import { claimLegacy } from './legacy.js'
|
||||||
@@ -52,7 +53,7 @@ export default function App() {
|
|||||||
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
|
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
|
||||||
|
|
||||||
const page = hash.split('/')[0]
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -77,6 +78,9 @@ export default function App() {
|
|||||||
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
|
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
|
||||||
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
|
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
|
||||||
)}
|
)}
|
||||||
|
{rules && page === 'lobbies' && (
|
||||||
|
<Lobbies section={hash.split('/')[1]} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
|
||||||
|
)}
|
||||||
{page === 'account' && <Account user={username} onLogout={logout} />}
|
{page === 'account' && <Account user={username} onLogout={logout} />}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<select value={value} onChange={(e) => onChange(e.target.value)} aria-label={label}>
|
||||||
|
<option value="">Choose a character …</option>
|
||||||
|
{mine.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 (
|
||||||
|
<section className="page narrow">
|
||||||
|
<h1>Lobbies</h1>
|
||||||
|
<p className="muted">
|
||||||
|
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).
|
||||||
|
</p>
|
||||||
|
{err && <p className="warn">{err}</p>}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Join a lobby</h2>
|
||||||
|
<form className="row wrapform" onSubmit={join}>
|
||||||
|
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Join code from your DM" aria-label="Join code" autoComplete="off" />
|
||||||
|
<CharSelect mine={mine} value={cid} onChange={setCid} label="Character that joins" />
|
||||||
|
<button type="submit" disabled={!code.trim()}>Join</button>
|
||||||
|
</form>
|
||||||
|
{mine.length === 0 && <p className="small muted">You need a saved character first (Character → Summary → Save).</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Lobbies you joined</h2>
|
||||||
|
{data && data.joined.length === 0 && <p className="muted small">None yet.</p>}
|
||||||
|
<ul className="charlist">
|
||||||
|
{(data?.joined || []).map((l) => (
|
||||||
|
<li key={l.id} className="card row">
|
||||||
|
<div>
|
||||||
|
<strong>{l.name}</strong>
|
||||||
|
<div className="muted small">DM {l.dm}{l.character ? ` · playing ${l.character}` : ' · no character selected'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions"><a className="button" href={`#lobbies/${l.id}`}>Open</a></div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Lobbies you run (as DM)</h2>
|
||||||
|
{data && data.mine.length === 0 && <p className="muted small">None yet.</p>}
|
||||||
|
<ul className="charlist">
|
||||||
|
{(data?.mine || []).map((l) => (
|
||||||
|
<li key={l.id} className="card row">
|
||||||
|
<div>
|
||||||
|
<strong>{l.name}</strong>
|
||||||
|
<div className="muted small">{l.members} player{l.members === 1 ? '' : 's'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions"><a className="button" href={`#lobbies/${l.id}`}>Open</a></div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<form className="row wrapform" onSubmit={create}>
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name of a new lobby" aria-label="Lobby name" maxLength={60} />
|
||||||
|
<button type="submit" disabled={!name.trim()}>+ Create lobby</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ m, isDm, onOpen, onKick }) {
|
||||||
|
const c = m.character
|
||||||
|
return (
|
||||||
|
<li className={`card lobbycard ${m.me ? 'me' : ''}`}>
|
||||||
|
<div className="row">
|
||||||
|
<div>
|
||||||
|
<strong>{c ? c.name : 'No character'}</strong>
|
||||||
|
<div className="muted small">{m.username}{m.me ? ' (you)' : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
{m.character_id && <button onClick={() => onOpen(m)}>{m.me ? 'Open' : 'View'}</button>}
|
||||||
|
{isDm && <button className="ghost danger" onClick={() => onKick(m)}>Remove</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{c && (
|
||||||
|
<div className="chips">
|
||||||
|
{[c.class && (c.subclass ? `${c.class} · ${c.subclass}` : c.class), c.profession].filter(Boolean).map((t) => <span key={t} className="chip">{t}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c?.concept && <p className="small muted">{c.concept}</p>}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- one lobby (DM view or player view)
|
||||||
|
function LobbyView({ id, onOpen }) {
|
||||||
|
const mine = useMine()
|
||||||
|
const [lobby, setLobby] = useState(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [info, setInfo] = useState('')
|
||||||
|
const [pick, setPick] = useState('')
|
||||||
|
const [rename, setRename] = useState('')
|
||||||
|
|
||||||
|
const load = () => fetch(`/api/lobbies/${id}`).then(async (r) => {
|
||||||
|
if (!r.ok) throw new Error(r.status)
|
||||||
|
setLobby(await r.json())
|
||||||
|
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') })
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
const t = setInterval(load, 15000) // pick up joins and character changes of the others
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [id]) // eslint-disable-line
|
||||||
|
|
||||||
|
const act = async (url, method, body, done) => {
|
||||||
|
setErr(''); setInfo('')
|
||||||
|
const r = await send(url, method, body).catch(() => null)
|
||||||
|
const d = r ? await r.json().catch(() => ({})) : {}
|
||||||
|
if (!r || !r.ok) return setErr(msg(d, 'That did not work.'))
|
||||||
|
done ? done(d) : load()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err && !lobby) return <section className="page narrow"><p className="warn">{err}</p><a href="#lobbies">Back to all lobbies</a></section>
|
||||||
|
if (!lobby) return <section className="page narrow"><p className="muted">Loading …</p></section>
|
||||||
|
|
||||||
|
const me = lobby.members.find((m) => m.me)
|
||||||
|
const open = (m) => onOpen({ id: m.character_id, name: m.character?.name, ...(m.me ? {} : { owner: m.username }) })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page narrow">
|
||||||
|
<p className="small"><a href="#lobbies">← All lobbies</a></p>
|
||||||
|
<h1>{lobby.name}</h1>
|
||||||
|
<p className="muted">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</p>
|
||||||
|
{err && <p className="warn">{err}</p>}
|
||||||
|
{info && <p className="small">{info}</p>}
|
||||||
|
|
||||||
|
{lobby.is_dm && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="row">
|
||||||
|
<div>
|
||||||
|
<div className="muted small">Join code – give it to your players</div>
|
||||||
|
<strong className="joincode">{lobby.code}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button className="ghost" onClick={() => navigator.clipboard?.writeText(lobby.code).then(() => setInfo('Code copied.')).catch(() => {})}>Copy</button>
|
||||||
|
<button className="ghost" onClick={() => window.confirm('The old code stops working. Current players stay in the lobby.') && act(`/api/lobbies/${id}/code`, 'POST', undefined, (d) => setLobby({ ...lobby, code: d.code }))}>New code</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (rename.trim()) act(`/api/lobbies/${id}`, 'PUT', { name: rename }, () => { setRename(''); load() }) }}>
|
||||||
|
<input value={rename} onChange={(e) => setRename(e.target.value)} placeholder="Rename lobby" aria-label="New lobby name" maxLength={60} />
|
||||||
|
<button type="submit" className="ghost" disabled={!rename.trim()}>Rename</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2>Characters ({lobby.members.length})</h2>
|
||||||
|
{lobby.members.length === 0 && <p className="card">Nobody has joined yet. Share the code above.</p>}
|
||||||
|
<ul className="charlist">
|
||||||
|
{lobby.members.map((m) => (
|
||||||
|
<Card key={m.username} m={m} isDm={lobby.is_dm} onOpen={open}
|
||||||
|
onKick={(x) => window.confirm(`Remove ${x.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(x.username)}`, 'DELETE')} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{lobby.is_dm && <p className="small muted">As DM you can open every character here, but only read it. "Save" in the Character tab would create your own copy.</p>}
|
||||||
|
|
||||||
|
{!lobby.is_dm && me && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Your character</h2>
|
||||||
|
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (pick) act(`/api/lobbies/${id}/me`, 'PUT', { character_id: pick }, () => { setPick(''); load() }) }}>
|
||||||
|
<CharSelect mine={mine} value={pick} onChange={setPick} label="Switch character" />
|
||||||
|
<button type="submit" className="ghost" disabled={!pick}>Switch</button>
|
||||||
|
</form>
|
||||||
|
<p className="small muted">The DM always sees the current version of your character, so changes you save show up for them.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="actions wrap">
|
||||||
|
{lobby.is_dm
|
||||||
|
? <button className="ghost danger" onClick={() => window.confirm(`Delete the lobby "${lobby.name}" for everyone? Characters are not deleted.`) && act(`/api/lobbies/${id}`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Delete lobby</button>
|
||||||
|
: <button className="ghost danger" onClick={() => window.confirm('Leave this lobby?') && act(`/api/lobbies/${id}/me`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Leave lobby</button>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Lobbies({ section, onOpen }) {
|
||||||
|
return section ? <LobbyView key={section} id={section} onOpen={onOpen} /> : <LobbyList />
|
||||||
|
}
|
||||||
@@ -169,3 +169,16 @@ tr.dim td { opacity: .55; }
|
|||||||
.tipwrap:hover .tipicon, .tipwrap:focus .tipicon { color: var(--noxium); border-color: var(--noxium); }
|
.tipwrap:hover .tipicon, .tipwrap:focus .tipicon { color: var(--noxium); border-color: var(--noxium); }
|
||||||
.tippop { position: fixed; z-index: 1000; max-width: 340px; padding: .55rem .75rem; background: var(--panel2); color: var(--text); border: 1px solid var(--diesium); border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.35); font-size: .85rem; line-height: 1.4; pointer-events: none; }
|
.tippop { position: fixed; z-index: 1000; max-width: 340px; padding: .55rem .75rem; background: var(--panel2); color: var(--text); border: 1px solid var(--diesium); border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.35); font-size: .85rem; line-height: 1.4; pointer-events: none; }
|
||||||
.tippop div + div { margin-top: .35rem; }
|
.tippop div + div { margin-top: .35rem; }
|
||||||
|
|
||||||
|
/* lobbies */
|
||||||
|
a.button { display: inline-block; font-weight: 600; text-decoration: none; background: var(--noxium); color: #12150f; border: 1px solid var(--noxium); padding: .5rem 1rem; border-radius: var(--radius); }
|
||||||
|
a.button:hover { filter: brightness(1.1); }
|
||||||
|
.wrapform { flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
|
||||||
|
.wrapform > input, .wrapform > select { flex: 1 1 12rem; width: auto; }
|
||||||
|
.lobbycard { display: grid; gap: .45rem; }
|
||||||
|
.lobbycard.me { border-color: var(--noxium); }
|
||||||
|
.lobbycard p { margin: 0; }
|
||||||
|
.joincode { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1.5rem; letter-spacing: .15em; }
|
||||||
|
span.chip { border: 1px solid var(--line); font-size: .85rem; padding: .1rem .6rem; }
|
||||||
|
.lobbycard .chips { gap: .4rem; }
|
||||||
|
h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user