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:
@@ -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-For $remote_addr; # overwrite, so clients cannot fake their address
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
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
|
```bash
|
||||||
sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw
|
sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
|||||||
@@ -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.
|
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.)
|
- **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.
|
- **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.
|
- **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}` |
|
| 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) |
|
||||||
|
| 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 |
|
| 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}/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 |
|
| POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character |
|
||||||
|
|||||||
+78
-2
@@ -7,13 +7,15 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
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.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import auth
|
from . import auth
|
||||||
from .derive import compute
|
from .derive import compute
|
||||||
from . import rules_edit
|
from . import rules_edit
|
||||||
|
from .realtime import hub
|
||||||
from .export import to_docx, to_pdf
|
from .export import to_docx, to_pdf
|
||||||
|
|
||||||
BASE = Path(__file__).resolve().parent.parent
|
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
|
# 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)
|
||||||
limiter = auth.Limiter()
|
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
|
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-Content-Type-Options"] = "nosniff"
|
||||||
resp.headers["X-Frame-Options"] = "DENY"
|
resp.headers["X-Frame-Options"] = "DENY"
|
||||||
resp.headers["Referrer-Policy"] = "same-origin"
|
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'; "
|
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/"):
|
if request.url.path.startswith("/api/"):
|
||||||
resp.headers["Cache-Control"] = "no-store"
|
resp.headers["Cache-Control"] = "no-store"
|
||||||
return resp
|
return resp
|
||||||
@@ -428,6 +437,9 @@ def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(curren
|
|||||||
with db() as con:
|
with db() as con:
|
||||||
_owned(con, "characters", cid, user)
|
_owned(con, "characters", cid, user)
|
||||||
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
|
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}
|
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)."""
|
"""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', ?, ?)",
|
con.execute("INSERT INTO lobby_messages (lobby_id, user_id, kind, text, created) VALUES (?,?, 'event', ?, ?)",
|
||||||
(lid, uid, text, time.time()))
|
(lid, uid, text, time.time()))
|
||||||
|
hub.notify(lid, "messages")
|
||||||
|
|
||||||
|
|
||||||
def _char_name(con, cid) -> str:
|
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:
|
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 diff_mod=?, diff_label=?, diff_hidden=? WHERE id=?", (mod, label, 1 if body.get("hidden") else 0, lid))
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -630,6 +644,7 @@ def set_member_mod(lid: str, username: str, body: dict = Body(...), user: dict =
|
|||||||
(mod, lid, username))
|
(mod, lid, username))
|
||||||
if not cur.rowcount:
|
if not cur.rowcount:
|
||||||
raise HTTPException(404, "No such player in this lobby")
|
raise HTTPException(404, "No such player in this lobby")
|
||||||
|
hub.notify(lid, "lobby")
|
||||||
return {"ok": True}
|
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()))
|
(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(
|
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))
|
(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}
|
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()))
|
(lid, uid, to_id, kind, json.dumps(payload), time.time()))
|
||||||
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
|
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))
|
(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
|
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_members WHERE lobby_id=?", (lid,))
|
||||||
con.execute("DELETE FROM lobby_messages 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,))
|
||||||
|
hub.close_lobby(lid)
|
||||||
return {"ok": True}
|
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():
|
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")
|
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"]))
|
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:
|
if "character_id" in body:
|
||||||
_own_character(con, body.get("character_id"), user)
|
_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"]))
|
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
|
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).rowcount
|
||||||
if gone:
|
if gone:
|
||||||
_event(con, lid, f"{user['username']} left the lobby", user["id"])
|
_event(con, lid, f"{user['username']} left the lobby", user["id"])
|
||||||
|
if gone:
|
||||||
|
hub.close_user(lid, user["id"])
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -875,6 +896,9 @@ def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
|
|||||||
(lid, username)).rowcount
|
(lid, username)).rowcount
|
||||||
if gone:
|
if gone:
|
||||||
_event(con, lid, f"{username} was removed by the DM", user["id"])
|
_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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -987,6 +1011,58 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin
|
|||||||
return {"ok": True}
|
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)
|
app.include_router(api)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 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!' }
|
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.
|
// 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 [msgs, setMsgs] = useState([])
|
||||||
const last = useRef(0)
|
const last = useRef(0)
|
||||||
const live = useRef(false) // false until the history is loaded: old rolls are not animated again
|
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])
|
}, [id])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
poll()
|
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)
|
return () => clearInterval(t)
|
||||||
}, [poll])
|
}, [poll, socketUp])
|
||||||
return { msgs, poll }
|
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
|
// ---- 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 [data, setData] = useState(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -421,6 +453,9 @@ function CharTab({ cid, rules, onRoll }) {
|
|||||||
const t = setInterval(() => !document.hidden && load(), 15000)
|
const t = setInterval(() => !document.hidden && load(), 15000)
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [cid])
|
}, [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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{err && <p className="warn">{err}</p>}
|
{err && <p className="warn">{err}</p>}
|
||||||
@@ -514,18 +549,27 @@ function LobbyView({ id, rules, user }) {
|
|||||||
if (!r.ok) throw new Error(r.status)
|
if (!r.ok) throw new Error(r.status)
|
||||||
setLobby(await r.json())
|
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])
|
}).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(() => {
|
useEffect(() => {
|
||||||
load()
|
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)
|
return () => clearInterval(t)
|
||||||
}, [load])
|
}, [load, up])
|
||||||
|
|
||||||
const onRoll = useCallback((m) => {
|
const onRoll = useCallback((m) => {
|
||||||
const key = `${m.id}`
|
const key = `${m.id}`
|
||||||
setTray((t) => [...t.slice(-2), { ...m, key }])
|
setTray((t) => [...t.slice(-2), { ...m, key }])
|
||||||
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
|
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) => {
|
const act = async (url, method, body, done) => {
|
||||||
if (!url) return load()
|
if (!url) return load()
|
||||||
@@ -596,7 +640,7 @@ function LobbyView({ id, rules, user }) {
|
|||||||
</div>
|
</div>
|
||||||
{lobby.is_dm && leftTab === 'lobby'
|
{lobby.is_dm && leftTab === 'lobby'
|
||||||
? <Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} />
|
? <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>}
|
: <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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user