Files
AWCharacterCreator/backend/app/realtime.py
T
Claude 162d8c9224 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
2026-10-23 13:41:02 +00:00

80 lines
2.8 KiB
Python

"""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()