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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user