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
+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>
)}