Lobby: WebSockets for real-time sync

Server pushes small change notes (messages / lobby / sheet) per lobby;
clients fetch data through the normal API so visibility rules stay in one
place. Polling stays as slow fallback. nginx WebSocket headers documented.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-23 13:41:02 +00:00
parent 1c672f0ab8
commit 162d8c9224
5 changed files with 218 additions and 11 deletions
+7
View File
@@ -70,10 +70,17 @@ server {
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, so clients cannot fake their address
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# the lobby uses a WebSocket for live updates (without these three lines it falls back to polling)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
```
Already running an older config? Add the last four lines inside `location /`, then `sudo nginx -t && sudo systemctl reload nginx`. Nothing else changes; the lobby still works without them, just with a few seconds of delay.
```bash
sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw
sudo nginx -t && sudo systemctl reload nginx
+2 -1
View File
@@ -101,7 +101,7 @@ The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and get
The lobby page is built for desktop with three columns: the **character sheet** on the left (collapsible with the arrow), the **Player Area** in the middle and the **chat** at the right edge of the window.
- **Chat** (right): live chat for everyone, plus **whispers** to a single person (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, can be hidden), dice rolls and skill checks. Names are shown in the player's colour. The chat polls every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages/rolls per minute.
- **Chat** (right): live chat for everyone, plus **whispers** to a single person (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, can be hidden), dice rolls and skill checks. Names are shown in the player's colour. Everything in the lobby (chat, dice, difficulty, colours, sheets) updates live over a **WebSocket** (`/api/lobbies/{id}/ws`); it only sends a short "something changed" note and the data is fetched through the normal API, so the visibility rules (whispers, hidden numbers) stay in one place. If the socket is blocked or drops, the page reconnects and meanwhile polls every few seconds. The chat keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages/rolls per minute.
- **Character bar** (top of the Player Area): one card per player with the character, class and the player's colour. The DM clicks a card to show that character's sheet on the left (always the current saved version, refreshed every 15 s) and can enter a personal **bonus / malus** per character in the small number field. Players see their own sheet only and just a short card of the others. (The bar is also meant for initiative and enemies later.)
- **Skill and attribute checks**: players click an attribute or an unlocked skill on their own sheet. The server rolls a d100 (roll under): target = attribute or skill total + the DM's difficulty + the character's personal modifier. 01-05 is always a critical success, 96-100 always a critical failure. The chat and the table show the roll, the target and by how much it succeeded or failed. Combat is not handled specially: roll the normal skill and let the DM adjust with the modifiers.
- **Difficulty** (bar under the characters): the DM picks a level from the rules table (Trivial +60 ... Hellish -60) or a custom number. It applies to all checks until changed. With **Hide numbers from players** the players only see their roll and success / failure, not the target, difficulty or margin.
@@ -129,6 +129,7 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
| 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) |
| WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet}` for lobby members (cookie login, same origin) |
| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper |
| POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?, manual?, mode?}` (d4-d20, d100; up to 10 dice). `manual` = results of real dice, `mode` (DM only) = `hidden`/`mystery`/`public` |
| POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character |
+78 -2
View File
@@ -7,13 +7,15 @@ import time
import uuid
from pathlib import Path
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response
import asyncio
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import auth
from .derive import compute
from . import rules_edit
from .realtime import hub
from .export import to_docx, to_pdf
BASE = Path(__file__).resolve().parent.parent
@@ -38,6 +40,11 @@ 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)
limiter = auth.Limiter()
@app.on_event("startup")
async def _attach_hub():
hub.attach(asyncio.get_running_loop())
TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse proxy that sets X-Forwarded-For / -Proto
@@ -167,8 +174,10 @@ async def security(request: Request, call_next):
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "same-origin"
host = request.headers.get("x-forwarded-host", "") if TRUST_PROXY else ""
host = re.sub(r"[^A-Za-z0-9.:\-\[\]]", "", host or request.headers.get("host", ""))
resp.headers["Content-Security-Policy"] = ("default-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; frame-ancestors 'none'")
f"img-src 'self' data: blob:; connect-src 'self' ws://{host} wss://{host}; frame-ancestors 'none'")
if request.url.path.startswith("/api/"):
resp.headers["Cache-Control"] = "no-store"
return resp
@@ -428,6 +437,9 @@ def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(curren
with db() as con:
_owned(con, "characters", cid, user)
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
for lid in lobbies:
hub.notify(lid, "sheet", cid=cid)
return {"ok": True}
@@ -501,6 +513,7 @@ 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()))
hub.notify(lid, "messages")
def _char_name(con, cid) -> str:
@@ -612,6 +625,7 @@ def set_difficulty(lid: str, body: dict = Body(...), user: dict = Depends(curren
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
con.execute("UPDATE lobbies SET diff_mod=?, diff_label=?, diff_hidden=? WHERE id=?", (mod, label, 1 if body.get("hidden") else 0, lid))
hub.notify(lid, "lobby")
return {"ok": True}
@@ -630,6 +644,7 @@ def set_member_mod(lid: str, username: str, body: dict = Body(...), user: dict =
(mod, lid, username))
if not cur.rowcount:
raise HTTPException(404, "No such player in this lobby")
hub.notify(lid, "lobby")
return {"ok": True}
@@ -701,6 +716,7 @@ def send_message(lid: str, body: dict = Body(...), user: dict = Depends(current_
(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))
hub.notify(lid, "messages", None if to_id is None else {user["id"], to_id})
return {"id": cur.lastrowid}
@@ -726,6 +742,7 @@ def _post(con, lid: str, uid: int, to_id, kind: str, payload: dict) -> int:
(lid, uid, to_id, kind, json.dumps(payload), 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))
hub.notify(lid, "messages", None if to_id is None else {uid, to_id})
return cur.lastrowid
@@ -820,6 +837,7 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
hub.close_lobby(lid)
return {"ok": True}
@@ -850,6 +868,7 @@ def update_my_membership(lid: str, body: dict = Body(...), user: dict = Depends(
if con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND color=? AND user_id!=?", (lid, color, user["id"])).fetchone():
raise HTTPException(409, "Another player already uses this colour")
con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (color, lid, user["id"]))
hub.notify(lid, "lobby")
if "character_id" in body:
_own_character(con, body.get("character_id"), user)
con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?", (body["character_id"], lid, user["id"]))
@@ -864,6 +883,8 @@ def leave_lobby(lid: str, user: dict = Depends(current_user)):
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"])
if gone:
hub.close_user(lid, user["id"])
return {"ok": True}
@@ -875,6 +896,9 @@ def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
(lid, username)).rowcount
if gone:
_event(con, lid, f"{username} was removed by the DM", user["id"])
gone_id = con.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"]
if gone:
hub.close_user(lid, gone_id)
return {"ok": True}
@@ -987,6 +1011,58 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin
return {"ok": True}
@app.websocket("/api/lobbies/{lid}/ws")
async def lobby_socket(websocket: WebSocket, lid: str):
"""One socket per open lobby page. Only notes go out ("messages", "lobby", "sheet"), the data is fetched through the API."""
origin = websocket.headers.get("origin")
hosts = {websocket.headers.get("host", "")}
if TRUST_PROXY:
hosts.add(websocket.headers.get("x-forwarded-host", ""))
if origin and origin.split("://", 1)[-1] not in hosts:
await websocket.close(code=4403)
return
def check():
with db() as con:
user = auth.user_for_token(con, websocket.cookies.get(auth.SESSION_COOKIE))
if not user:
return None
lobby = con.execute("SELECT owner_id FROM lobbies WHERE id=?", (lid,)).fetchone()
ok = lobby and (lobby["owner_id"] == user["id"] or con.execute(
"SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone())
return user if ok else None
user = await asyncio.to_thread(check)
if not user:
await websocket.close(code=4401)
return
await websocket.accept()
if not hub.add(lid, websocket, user["id"]):
await websocket.close(code=4429)
return
try:
last_check = time.time()
while True:
try:
text = await asyncio.wait_for(websocket.receive_text(), timeout=70)
except asyncio.TimeoutError:
break # the client pings every 25 seconds, silence means it is gone
if text == "ping":
await websocket.send_text("pong")
if time.time() - last_check > 300: # session or membership may have ended
last_check = time.time()
if not await asyncio.to_thread(check):
break
except WebSocketDisconnect:
pass
finally:
hub.remove(lid, websocket)
try:
await websocket.close()
except Exception:
pass
app.include_router(api)
+79
View File
@@ -0,0 +1,79 @@
"""Real-time pushes for lobbies over WebSockets.
The sockets only carry short "something changed" notes ({"t": "messages"} or {"t": "lobby"}). The browser then loads the
data through the normal API, so all visibility rules (whispers, hidden rolls, who sees which character) stay in one place.
Without a working socket the client keeps polling, so nothing breaks when a proxy blocks WebSockets."""
import asyncio
import threading
MAX_PER_USER = 6 # open sockets per user (several tabs)
MAX_PER_LOBBY = 80
class Hub:
def __init__(self):
self.rooms: dict = {} # lobby id -> {websocket: user id}
self.loop = None
self.lock = threading.Lock()
def attach(self, loop):
self.loop = loop
def add(self, lid: str, ws, uid: int) -> bool:
with self.lock:
room = self.rooms.setdefault(lid, {})
if len(room) >= MAX_PER_LOBBY or sum(1 for u in room.values() if u == uid) >= MAX_PER_USER:
return False
room[ws] = uid
return True
def remove(self, lid: str, ws):
with self.lock:
room = self.rooms.get(lid)
if room:
room.pop(ws, None)
if not room:
del self.rooms[lid]
def online(self, lid: str) -> set:
with self.lock:
return set(self.rooms.get(lid, {}).values())
async def _send(self, lid: str, note: dict, users):
with self.lock:
targets = [ws for ws, uid in self.rooms.get(lid, {}).items() if users is None or uid in users]
for ws in targets:
try:
await ws.send_json(note)
except Exception:
self.remove(lid, ws)
def notify(self, lid: str, kind: str, users=None, delay: float = 0.08, **extra):
"""Callable from normal (threadpool) endpoints. The short delay lets the database transaction finish first."""
if not self.loop or lid not in self.rooms:
return
async def go():
await asyncio.sleep(delay)
await self._send(lid, {"t": kind, **extra}, users)
asyncio.run_coroutine_threadsafe(go(), self.loop)
def close_user(self, lid: str, uid: int):
"""Drops the sockets of someone who left or was removed."""
if not self.loop:
return
with self.lock:
targets = [ws for ws, u in self.rooms.get(lid, {}).items() if u == uid]
for ws in targets:
asyncio.run_coroutine_threadsafe(ws.close(code=4403), self.loop)
def close_lobby(self, lid: str):
if not self.loop:
return
with self.lock:
targets = list(self.rooms.get(lid, {}))
for ws in targets:
asyncio.run_coroutine_threadsafe(ws.close(code=4404), self.loop)
hub = Hub()
+52 -8
View File
@@ -109,8 +109,40 @@ const sgn = (n) => (n > 0 ? `+${n}` : n < 0 ? `−${-n}` : '±0')
const store = (k, v) => { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v) } catch { /* storage may be blocked */ } return null }
const OUTCOME = { crit_success: 'Critical success!', success: 'Success', failure: 'Failure', crit_failure: 'Critical failure!' }
// One WebSocket per open lobby. It only says "something changed" ({t: 'messages' | 'lobby' | 'sheet'}), the data is loaded through
// the normal API. Without a working socket the page falls back to polling, so a proxy that blocks WebSockets does not break anything.
function useSocket(id, handlers) {
const [up, setUp] = useState(false)
const h = useRef(handlers)
h.current = handlers
useEffect(() => {
let ws, timer, ping, tries = 0, closed = false
const open = () => {
ws = new WebSocket(`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/api/lobbies/${id}/ws`)
ws.onopen = () => {
tries = 0; setUp(true); h.current.open?.()
ping = setInterval(() => ws.readyState === 1 && ws.send('ping'), 25000)
}
ws.onmessage = (e) => {
if (e.data === 'pong') return
try { const n = JSON.parse(e.data); h.current[n.t]?.(n) } catch { /* ignore malformed notes */ }
}
ws.onclose = (e) => {
clearInterval(ping); setUp(false)
if (closed || (e.code >= 4400 && e.code < 4500)) return // refused or removed: the normal polling shows why
timer = setTimeout(open, Math.min(10000, 1000 * 2 ** tries++))
}
}
try { open() } catch { /* no WebSocket support: polling only */ }
const wake = () => { if (!document.hidden) h.current.open?.() } // catch up after the tab was in the background
document.addEventListener('visibilitychange', wake)
return () => { closed = true; clearTimeout(timer); clearInterval(ping); document.removeEventListener('visibilitychange', wake); ws?.close() }
}, [id])
return up
}
// Chat, whispers, event log, dice rolls and checks arrive through one feed that is polled while the page is open.
function useFeed(id, onEvent, onRoll) {
function useFeed(id, onEvent, onRoll, socketUp) {
const [msgs, setMsgs] = useState([])
const last = useRef(0)
const live = useRef(false) // false until the history is loaded: old rolls are not animated again
@@ -134,9 +166,9 @@ function useFeed(id, onEvent, onRoll) {
}, [id])
useEffect(() => {
poll()
const t = setInterval(poll, 2500)
const t = setInterval(poll, socketUp ? 20000 : 2500) // the socket says when something is new, the timer is only a safety net
return () => clearInterval(t)
}, [poll])
}, [poll, socketUp])
return { msgs, poll }
}
@@ -411,7 +443,7 @@ function DifficultyBar({ lobby, onSet }) {
}
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
function CharTab({ cid, rules, onRoll }) {
function CharTab({ cid, rules, onRoll, tick }) {
const [data, setData] = useState(null)
const [err, setErr] = useState('')
useEffect(() => {
@@ -421,6 +453,9 @@ function CharTab({ cid, rules, onRoll }) {
const t = setInterval(() => !document.hidden && load(), 15000)
return () => clearInterval(t)
}, [cid])
useEffect(() => { // pushed by the server when the player saved the character
if (tick) fetch(`/api/characters/${cid}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setData(d)).catch(() => {})
}, [tick]) // eslint-disable-line
return (
<div>
{err && <p className="warn">{err}</p>}
@@ -514,18 +549,27 @@ function LobbyView({ id, rules, user }) {
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.') }), [id])
const sock = useRef({})
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
const up = useSocket(id, {
open: () => { sock.current.load?.(); sock.current.poll?.() },
messages: () => sock.current.poll?.(),
lobby: () => sock.current.load?.(),
sheet: (n) => setSheetTick((t) => ({ cid: n.cid, n: t.n + 1 })),
})
useEffect(() => {
load()
const t = setInterval(() => !document.hidden && load(), 5000) // picks up joins, colours, difficulty and character changes
const t = setInterval(() => !document.hidden && load(), up ? 30000 : 5000) // joins, colours, difficulty: pushed, with a timer as safety net
return () => clearInterval(t)
}, [load])
}, [load, up])
const onRoll = useCallback((m) => {
const key = `${m.id}`
setTray((t) => [...t.slice(-2), { ...m, key }])
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
}, [])
const feed = useFeed(id, load, onRoll)
const feed = useFeed(id, load, onRoll, up)
sock.current = { load, poll: feed.poll }
const act = async (url, method, body, done) => {
if (!url) return load()
@@ -596,7 +640,7 @@ function LobbyView({ id, rules, user }) {
</div>
{lobby.is_dm && leftTab === 'lobby'
? <Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} />
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} onRoll={cur.me && canCheck ? check : undefined} />
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} />
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
</div>
)}