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:
@@ -85,9 +85,13 @@ Each wizard page has a **Reset this page** button that clears only that page's d
|
|||||||
|
|
||||||
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 **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.
|
A lobby has tabs:
|
||||||
- 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.
|
- **Chat**: live chat for everyone, plus **whispers** to a single person (pick them in the dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes); it can be hidden with a checkbox. The chat polls the server every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages per minute.
|
||||||
|
- **Party**: member cards (name, concept, class, profession). The DM also finds the join code, rename, remove player and delete lobby here.
|
||||||
|
- **One sheet tab per character**: the DM gets a tab for every character in the lobby (read-only, always the current saved version, refreshed every 15 s); a player gets "My character". Hover or tap a value to see how it is made up. "Open in the Character tab" jumps to the full editor (for the DM it is a read-only look, "Save" would create the DM's own copy).
|
||||||
|
|
||||||
|
Players only see a short card of the other players, never their sheets. Guessing join codes is rate limited. Deleting a character takes it out of the lobbies (the player can pick another); deleting a lobby does not delete characters.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -106,6 +110,7 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
|
|||||||
| POST | `/api/lobbies/join` | Join with `{code, character_id}` |
|
| 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) |
|
| 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) |
|
| POST | `/api/lobbies/{id}/code` | New join code (DM only) |
|
||||||
|
| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
|
||||||
| PUT / DELETE | `/api/lobbies/{id}/me` | Switch your character / leave |
|
| PUT / DELETE | `/api/lobbies/{id}/me` | Switch your character / leave |
|
||||||
| DELETE | `/api/lobbies/{id}/members/{username}` | Remove a player (DM only) |
|
| DELETE | `/api/lobbies/{id}/members/{username}` | Remove a player (DM only) |
|
||||||
| POST | `/api/presets` | Create a preset |
|
| POST | `/api/presets` | Create a preset |
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ def main(argv):
|
|||||||
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 lobby_members WHERE user_id=? OR lobby_id IN (SELECT id FROM lobbies WHERE owner_id=?)", (row["id"], row["id"]))
|
||||||
|
con.execute("DELETE FROM lobby_messages WHERE lobby_id IN (SELECT id FROM lobbies WHERE owner_id=?)", (row["id"],))
|
||||||
con.execute("DELETE FROM lobbies WHERE owner_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 lobbies are removed; 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.")
|
||||||
|
|||||||
+74
-3
@@ -24,6 +24,8 @@ MAX_CHARACTERS = 300 # per user
|
|||||||
MAX_SHARES = 30 # per character
|
MAX_SHARES = 30 # per character
|
||||||
MAX_LOBBIES = 20 # per DM
|
MAX_LOBBIES = 20 # per DM
|
||||||
MAX_MEMBERS = 20 # per lobby
|
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
|
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
|
||||||
@@ -56,6 +58,10 @@ def db() -> sqlite3.Connection:
|
|||||||
con.execute("""CREATE TABLE IF NOT EXISTS lobby_members (
|
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,
|
lobby_id TEXT NOT NULL, user_id INTEGER NOT NULL, character_id TEXT, joined REAL NOT NULL,
|
||||||
PRIMARY KEY (lobby_id, user_id))""")
|
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
|
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")
|
||||||
@@ -420,6 +426,17 @@ def _own_character(con, cid, user: dict):
|
|||||||
raise HTTPException(422, "Pick one of your own characters")
|
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:
|
def _card(data: str, rules: dict) -> dict:
|
||||||
"""The superficial look at a character that fellow players get: name, concept and class/profession names only."""
|
"""The superficial look at a character that fellow players get: name, concept and class/profession names only."""
|
||||||
ch = json.loads(data)
|
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)
|
lid, code = uuid.uuid4().hex[:12], _new_code(con)
|
||||||
con.execute("INSERT INTO lobbies (id, name, owner_id, code, created) VALUES (?,?,?,?,?)",
|
con.execute("INSERT INTO lobbies (id, name, owner_id, code, created) VALUES (?,?,?,?,?)",
|
||||||
(lid, name, user["id"], code, time.time()))
|
(lid, name, user["id"], code, time.time()))
|
||||||
|
_event(con, lid, f"{user['username']} created the lobby", user["id"])
|
||||||
return {"id": lid, "code": code}
|
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 (?,?,?,?)
|
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""",
|
ON CONFLICT(lobby_id, user_id) DO UPDATE SET character_id=excluded.character_id""",
|
||||||
(lobby["id"], user["id"], body["character_id"], time.time()))
|
(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"]}
|
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}
|
"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}")
|
@api.put("/lobbies/{lid}")
|
||||||
def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
name = str(body.get("name", "")).strip()[:60]
|
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:
|
with db() as con:
|
||||||
_dm_only(_lobby_for(con, lid, user), user)
|
_dm_only(_lobby_for(con, lid, user), user)
|
||||||
con.execute("UPDATE lobbies SET name=? WHERE id=?", (name, lid))
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -518,6 +582,7 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
|
|||||||
with db() as con:
|
with db() as con:
|
||||||
_dm_only(_lobby_for(con, lid, user), user)
|
_dm_only(_lobby_for(con, lid, user), user)
|
||||||
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
|
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,))
|
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
|
||||||
return {"ok": True}
|
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)
|
_dm_only(_lobby_for(con, lid, user), user)
|
||||||
code = _new_code(con)
|
code = _new_code(con)
|
||||||
con.execute("UPDATE lobbies SET code=? WHERE id=?", (code, lid))
|
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}
|
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"]))
|
(body["character_id"], lid, user["id"]))
|
||||||
if not cur.rowcount:
|
if not cur.rowcount:
|
||||||
raise HTTPException(422, "You are the DM of this lobby")
|
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}
|
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)):
|
def leave_lobby(lid: str, user: dict = Depends(current_user)):
|
||||||
with db() as con:
|
with db() as con:
|
||||||
_lobby_for(con, lid, user)
|
_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}
|
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)):
|
def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
|
||||||
with db() as con:
|
with db() as con:
|
||||||
_dm_only(_lobby_for(con, lid, user), user)
|
_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=?)",
|
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)",
|
||||||
(lid, username))
|
(lid, username)).rowcount
|
||||||
|
if gone:
|
||||||
|
_event(con, lid, f"{username} was removed by the DM", user["id"])
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default function App() {
|
|||||||
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
|
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
|
||||||
)}
|
)}
|
||||||
{rules && page === 'lobbies' && (
|
{rules && page === 'lobbies' && (
|
||||||
<Lobbies section={hash.split('/')[1]} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
|
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
|
||||||
)}
|
)}
|
||||||
{page === 'account' && <Account user={username} onLogout={logout} />}
|
{page === 'account' && <Account user={username} onLogout={logout} />}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+180
-56
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import SheetView from './SheetView.jsx'
|
||||||
|
|
||||||
const J = { 'Content-Type': 'application/json' }
|
const J = { 'Content-Type': 'application/json' }
|
||||||
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
|
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
|
||||||
@@ -111,7 +112,7 @@ function Card({ m, isDm, onOpen, onKick }) {
|
|||||||
<div className="muted small">{m.username}{m.me ? ' (you)' : ''}</div>
|
<div className="muted small">{m.username}{m.me ? ' (you)' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{m.character_id && <button onClick={() => onOpen(m)}>{m.me ? 'Open' : 'View'}</button>}
|
{m.character_id && <button onClick={() => onOpen(m)}>{m.me ? 'Sheet' : 'View sheet'}</button>}
|
||||||
{isDm && <button className="ghost danger" onClick={() => onKick(m)}>Remove</button>}
|
{isDm && <button className="ghost danger" onClick={() => onKick(m)}>Remove</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,14 +126,113 @@ function Card({ m, isDm, onOpen, onKick }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- one lobby (DM view or player view)
|
const clock = (t) => new Date(t * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||||
function LobbyView({ id, onOpen }) {
|
|
||||||
|
// ---- chat, whispers and event log (polls the server every few seconds while the tab is open)
|
||||||
|
function Chat({ id, me, people, onEvent }) {
|
||||||
|
const [msgs, setMsgs] = useState([])
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const [events, setEvents] = useState(true)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const last = useRef(0)
|
||||||
|
const box = useRef(null)
|
||||||
|
const stick = useRef(true)
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (document.hidden) return
|
||||||
|
const r = await fetch(`/api/lobbies/${id}/messages?after=${last.current}`).catch(() => null)
|
||||||
|
if (!r || !r.ok) return
|
||||||
|
const fresh = await r.json()
|
||||||
|
if (!fresh.length) return
|
||||||
|
last.current = fresh[fresh.length - 1].id
|
||||||
|
setMsgs((old) => [...old, ...fresh].slice(-300))
|
||||||
|
if (fresh.some((m) => m.kind === 'event')) onEvent()
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
poll()
|
||||||
|
const t = setInterval(poll, 2500)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [id]) // eslint-disable-line
|
||||||
|
useEffect(() => { // keep the newest line in view unless the user scrolled up
|
||||||
|
const el = box.current
|
||||||
|
if (el && stick.current) el.scrollTop = el.scrollHeight
|
||||||
|
}, [msgs, events])
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault(); setErr('')
|
||||||
|
if (!text.trim()) return
|
||||||
|
const r = await send_(id, text, to)
|
||||||
|
if (!r.ok) return setErr(msg(await r.json().catch(() => ({})), 'Could not send.'))
|
||||||
|
setText(''); stick.current = true; poll()
|
||||||
|
}
|
||||||
|
const shown = msgs.filter((m) => events || m.kind !== 'event')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chatwrap">
|
||||||
|
<div className="chatbox" ref={box} onScroll={(e) => { const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40 }}
|
||||||
|
role="log" aria-live="polite">
|
||||||
|
{shown.length === 0 && <p className="muted small">Nothing here yet.</p>}
|
||||||
|
{shown.map((m) => m.kind === 'event' ? (
|
||||||
|
<div key={m.id} className="msg event"><span className="time">{clock(m.time)}</span> {m.text}</div>
|
||||||
|
) : (
|
||||||
|
<div key={m.id} className={`msg ${m.kind} ${m.mine ? 'mine' : ''}`}>
|
||||||
|
<span className="time">{clock(m.time)}</span>{' '}
|
||||||
|
<strong>{m.mine ? 'You' : m.from || '?'}</strong>
|
||||||
|
{m.kind === 'whisper' && <em className="small"> {m.mine ? `whisper to ${m.to}` : 'whispers to you'}</em>}
|
||||||
|
<span>: {m.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form className="row chatform" onSubmit={submit}>
|
||||||
|
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
|
||||||
|
<option value="">Everyone</option>
|
||||||
|
{people.filter((p) => p !== me).map((p) => <option key={p} value={p}>Whisper to {p}</option>)}
|
||||||
|
</select>
|
||||||
|
<input value={text} onChange={(e) => setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" />
|
||||||
|
<button type="submit" disabled={!text.trim()}>Send</button>
|
||||||
|
</form>
|
||||||
|
{err && <p className="warn small">{err}</p>}
|
||||||
|
<label className="small muted"><input type="checkbox" checked={events} onChange={(e) => setEvents(e.target.checked)} style={{ width: 'auto' }} /> Show events (joins, character switches …)</label>
|
||||||
|
<p className="small muted">Whispers are only seen by you and the person you send them to, the DM does not see other people's whispers.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const send_ = (id, text, to) => send(`/api/lobbies/${id}/messages`, 'POST', { text, to })
|
||||||
|
|
||||||
|
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
|
||||||
|
function CharTab({ cid, owner, rules, onOpen, name }) {
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
setData(null); setErr('')
|
||||||
|
const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then(setData).catch(() => setErr('Could not load this character.'))
|
||||||
|
load()
|
||||||
|
const t = setInterval(() => !document.hidden && load(), 15000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [cid])
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="row">
|
||||||
|
<h2>{data?.name || name || 'Nameless'}</h2>
|
||||||
|
<button className="ghost" onClick={onOpen}>{owner ? 'Open in the Character tab' : 'Edit in the Character tab'}</button>
|
||||||
|
</div>
|
||||||
|
<p className="muted small">{owner ? `Played by ${owner}. ` : ''}Read-only, hover or tap a value to see how it is made up.</p>
|
||||||
|
{err && <p className="warn">{err}</p>}
|
||||||
|
{data && <SheetView data={data} baseRules={rules} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- one lobby: Chat, Party (members, settings) and a sheet tab per visible character
|
||||||
|
function LobbyView({ id, rules, user, onOpen }) {
|
||||||
const mine = useMine()
|
const mine = useMine()
|
||||||
const [lobby, setLobby] = useState(null)
|
const [lobby, setLobby] = useState(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [info, setInfo] = useState('')
|
const [info, setInfo] = useState('')
|
||||||
const [pick, setPick] = useState('')
|
const [pick, setPick] = useState('')
|
||||||
const [rename, setRename] = useState('')
|
const [rename, setRename] = useState('')
|
||||||
|
const [tab, setTab] = useState('chat')
|
||||||
|
|
||||||
const load = () => fetch(`/api/lobbies/${id}`).then(async (r) => {
|
const load = () => fetch(`/api/lobbies/${id}`).then(async (r) => {
|
||||||
if (!r.ok) throw new Error(r.status)
|
if (!r.ok) throw new Error(r.status)
|
||||||
@@ -140,7 +240,7 @@ function LobbyView({ id, onOpen }) {
|
|||||||
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') })
|
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') })
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
const t = setInterval(load, 15000) // pick up joins and character changes of the others
|
const t = setInterval(() => !document.hidden && load(), 15000) // picks up joins and character changes of the others
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [id]) // eslint-disable-line
|
}, [id]) // eslint-disable-line
|
||||||
|
|
||||||
@@ -152,69 +252,93 @@ function LobbyView({ id, onOpen }) {
|
|||||||
done ? done(d) : load()
|
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 (err && !lobby) return <section className="page lobbypage"><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>
|
if (!lobby) return <section className="page lobbypage"><p className="muted">Loading …</p></section>
|
||||||
|
|
||||||
const me = lobby.members.find((m) => m.me)
|
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 }) })
|
const open = (m) => onOpen({ id: m.character_id, name: m.character?.name, ...(m.me ? {} : { owner: m.username }) })
|
||||||
|
const people = [lobby.dm, ...lobby.members.map((m) => m.username)]
|
||||||
|
const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open
|
||||||
|
const active = tab.startsWith('sheet:') && !sheets.find((m) => m.username === tab.slice(6)) ? 'party' : tab
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page narrow">
|
<section className="page lobbypage">
|
||||||
<p className="small"><a href="#lobbies">← All lobbies</a></p>
|
<p className="small"><a href="#lobbies">← All lobbies</a></p>
|
||||||
<h1>{lobby.name}</h1>
|
<div className="row">
|
||||||
<p className="muted">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</p>
|
<h1>{lobby.name}</h1>
|
||||||
|
<span className="muted">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
|
||||||
|
</div>
|
||||||
{err && <p className="warn">{err}</p>}
|
{err && <p className="warn">{err}</p>}
|
||||||
{info && <p className="small">{info}</p>}
|
{info && <p className="small">{info}</p>}
|
||||||
|
|
||||||
{lobby.is_dm && (
|
<div className="tabs" role="tablist">
|
||||||
<div className="card">
|
<button role="tab" aria-selected={active === 'chat'} className={active === 'chat' ? 'on' : ''} onClick={() => setTab('chat')}>Chat</button>
|
||||||
<div className="row">
|
<button role="tab" aria-selected={active === 'party'} className={active === 'party' ? 'on' : ''} onClick={() => setTab('party')}>Party ({lobby.members.length})</button>
|
||||||
<div>
|
{sheets.map((m) => (
|
||||||
<div className="muted small">Join code – give it to your players</div>
|
<button key={m.username} role="tab" aria-selected={active === `sheet:${m.username}`} className={active === `sheet:${m.username}` ? 'on' : ''}
|
||||||
<strong className="joincode">{lobby.code}</strong>
|
onClick={() => setTab(`sheet:${m.username}`)} title={m.username}>{m.me && !lobby.is_dm ? 'My character' : m.character.name}</button>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
|
{active === 'chat' && <Chat key={id} id={id} me={user} people={people} onEvent={load} />}
|
||||||
|
|
||||||
|
{active.startsWith('sheet:') && (() => {
|
||||||
|
const m = sheets.find((x) => x.username === active.slice(6))
|
||||||
|
return <CharTab key={m.character_id} cid={m.character_id} owner={m.me ? '' : m.username} name={m.character.name} rules={rules} onOpen={() => open(m)} />
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{active === 'party' && (
|
||||||
|
<>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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={(x) => setTab(`sheet:${x.username}`)}
|
||||||
|
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">You see your own character in full. Of the others you only see this short card.</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 saved version of your character.</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Lobbies({ section, onOpen }) {
|
export default function Lobbies({ section, rules, user, onOpen }) {
|
||||||
return section ? <LobbyView key={section} id={section} onOpen={onOpen} /> : <LobbyList />
|
return section ? <LobbyView key={section} id={section} rules={rules} user={user} onOpen={onOpen} /> : <LobbyList />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import Tip from './Tip.jsx'
|
||||||
|
import { normalize } from './calc.js'
|
||||||
|
import { sheetModel } from './sheetModel.js'
|
||||||
|
|
||||||
|
function Rows({ rows, valueLabel }) {
|
||||||
|
if (!rows.length) return <p className="muted small">–</p>
|
||||||
|
return (
|
||||||
|
<table className="difftable"><tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.k} className={r.dim ? 'dim' : ''}>
|
||||||
|
<td><Tip text={r.tip}>{r.label}</Tip>{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
|
||||||
|
<td>{valueLabel ? valueLabel(r) : r.val}</td>
|
||||||
|
{r.extra !== undefined && <td className="muted">{r.extra}</td>}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody></table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only character sheet (used in lobbies). Hover or tap a value to see how it is made up.
|
||||||
|
export default function SheetView({ data, baseRules }) {
|
||||||
|
const m = useMemo(() => sheetModel(normalize(data, baseRules), baseRules), [data, baseRules])
|
||||||
|
const c = m.c
|
||||||
|
const trained = m.skills.filter((s) => c.skills.find((x) => x.id === s.k && (x.points > 0 || x.pips > 0 || (x.type === 'advanced' && x.usable))))
|
||||||
|
const rest = m.skills.filter((s) => !trained.includes(s))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sheetview">
|
||||||
|
<Rows rows={m.head.filter((h) => h.k !== 'name')} />
|
||||||
|
<div className="sheetcols">
|
||||||
|
<div>
|
||||||
|
<h4>Attributes</h4>
|
||||||
|
<Rows rows={m.attrs} />
|
||||||
|
<h4>Derived</h4>
|
||||||
|
<Rows rows={m.derived} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>Skills</h4>
|
||||||
|
<Rows rows={trained} />
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<details>
|
||||||
|
<summary className="small muted">All other skills ({rest.length})</summary>
|
||||||
|
<Rows rows={rest} />
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h4>Talents</h4>
|
||||||
|
<Rows rows={m.talents} />
|
||||||
|
<h4><Tip text={m.tips.capacity}>Mutations / implants <span className="muted small">{c.capacityUsed}/{c.capacityMax}</span></Tip></h4>
|
||||||
|
<Rows rows={m.augments} />
|
||||||
|
<h4><Tip text={m.tips.carry}>Equipment <span className="muted small">{c.weight}/{c.carryMax} kg</span></Tip></h4>
|
||||||
|
<Rows rows={m.gear} valueLabel={(r) => (r.val > 1 ? `x${r.val}` : '')} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -182,3 +182,25 @@ a.button:hover { filter: brightness(1.1); }
|
|||||||
span.chip { border: 1px solid var(--line); font-size: .85rem; padding: .1rem .6rem; }
|
span.chip { border: 1px solid var(--line); font-size: .85rem; padding: .1rem .6rem; }
|
||||||
.lobbycard .chips { gap: .4rem; }
|
.lobbycard .chips { gap: .4rem; }
|
||||||
h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
|
h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
|
||||||
|
|
||||||
|
/* lobby page: tabs, chat, sheet */
|
||||||
|
.lobbypage { max-width: 980px; }
|
||||||
|
.tabs { display: flex; flex-wrap: wrap; gap: .3rem; border-bottom: 1px solid var(--line); margin: .8rem 0 1rem; }
|
||||||
|
.tabs button { background: transparent; color: var(--text); border: 1px solid transparent; border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; font-weight: 500; padding: .45rem .9rem; margin-bottom: -1px; }
|
||||||
|
.tabs button.on { background: var(--panel); border-color: var(--line); border-bottom: 1px solid var(--panel); color: var(--text); font-weight: 700; }
|
||||||
|
.chatwrap { display: grid; gap: .6rem; }
|
||||||
|
.chatbox { height: min(55vh, 520px); overflow-y: auto; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: .6rem .8rem; display: flex; flex-direction: column; gap: .25rem; }
|
||||||
|
.msg { overflow-wrap: anywhere; line-height: 1.35; }
|
||||||
|
.msg .time { color: var(--muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.msg.event { color: var(--muted); font-size: .85rem; font-style: italic; }
|
||||||
|
.msg.mine strong { color: var(--noxium); }
|
||||||
|
.msg.whisper { background: var(--panel2); border-left: 3px solid #8a63c7; padding: .15rem .5rem; border-radius: 4px; }
|
||||||
|
.msg.whisper em { color: #8a63c7; }
|
||||||
|
.chatform { flex-wrap: nowrap; gap: .5rem; }
|
||||||
|
.chatform select { width: 12rem; flex: 0 0 auto; }
|
||||||
|
.chatform select.whisper { border-color: #8a63c7; }
|
||||||
|
.chatform input { flex: 1 1 auto; width: auto; min-width: 0; }
|
||||||
|
.sheetcols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; }
|
||||||
|
.sheetview h4 { margin: 1rem 0 .3rem; }
|
||||||
|
.sheetview table { width: 100%; }
|
||||||
|
@media (max-width: 760px) { .sheetcols { grid-template-columns: 1fr; } .chatform { flex-wrap: wrap; } .chatform select { width: 100%; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user