import { useCallback, useEffect, useRef, useState } from 'react' import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx' import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js' import { MapView, MapLibrary, TokenForm, WallLayer } from './MapView.jsx' const J = { 'Content-Type': 'application/json' } const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) }) const msg = (d, fallback) => (typeof d?.detail === 'string' ? d.detail : fallback) function useMine() { const [mine, setMine] = useState([]) useEffect(() => { fetch('/api/characters').then((r) => r.json()).then((d) => setMine(d.mine || [])).catch(() => {}) }, []) return mine } function CharSelect({ mine, value, onChange, label }) { return ( ) } // ---- list of lobbies: create one (you become the DM) or join one with a code function LobbyList() { const mine = useMine() const [data, setData] = useState(null) const [name, setName] = useState('') const [code, setCode] = useState('') const [cid, setCid] = useState('') const [err, setErr] = useState('') const load = () => fetch('/api/lobbies').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your lobbies.')) useEffect(() => { load() }, []) const create = async (e) => { e.preventDefault(); setErr('') const r = await send('/api/lobbies', 'POST', { name }) const d = await r.json().catch(() => ({})) if (!r.ok) return setErr(msg(d, 'Could not create the lobby.')) window.location.hash = `lobbies/${d.id}` } const join = async (e) => { e.preventDefault(); setErr('') if (!cid) return setErr('Choose which of your characters joins.') const r = await send('/api/lobbies/join', 'POST', { code, character_id: cid }) const d = await r.json().catch(() => ({})) if (!r.ok) return setErr(msg(d, 'Could not join.')) window.location.hash = `lobbies/${d.id}` } return (

Lobbies

A lobby is a group for one campaign. Whoever creates it is the DM and can look at every character in it. Players only see a short card of each other (name, concept, class and profession).

{err &&

{err}

}

Join a lobby

setCode(e.target.value)} placeholder="Join code from your DM" aria-label="Join code" autoComplete="off" /> {mine.length === 0 &&

You need a saved character first (Character → Summary → Save).

}

Lobbies you joined

{data && data.joined.length === 0 &&

None yet.

}

Lobbies you run (as DM)

{data && data.mine.length === 0 &&

None yet.

}
setName(e.target.value)} placeholder="Name of a new lobby" aria-label="Lobby name" maxLength={60} />
) } const clock = (t) => new Date(t * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) const DICE = [4, 6, 8, 10, 12, 20, 100] const reduced = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches 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 } // The map picture for this viewer. With line of sight the server sends only the visible part (position in X-Crop), so the picture // is fetched again whenever the server's `rev` for this viewer changes; the old one stays until the new one has arrived. function useMapImage(id, vision, as) { const [img, setImg] = useState(null) const last = useRef({ t: 0, n: 0, timer: 0 }) const rev = vision?.rev useEffect(() => { if (!rev || vision.empty) { setImg(null); return } const n = ++last.current.n const go = () => fetch(`/api/lobbies/${id}/map/view${as ? `?as=${encodeURIComponent(as)}` : ''}`).then(async (r) => { if (!r.ok) throw new Error(r.status) const [x, y, w, h] = (r.headers.get('X-Crop') || '0,0,0,0').split(',').map(Number) const url = URL.createObjectURL(await r.blob()) if (n !== last.current.n) { URL.revokeObjectURL(url); return } // a newer one was asked for meanwhile setImg((old) => { if (old) URL.revokeObjectURL(old.src); return { src: url, x, y, w, h } }) }).catch(() => {}) const wait = Math.max(0, 200 - (Date.now() - last.current.t)) // at most ~5 pictures per second while a token is dragged clearTimeout(last.current.timer) last.current.timer = setTimeout(() => { last.current.t = Date.now(); go() }, wait) return () => clearTimeout(last.current.timer) }, [id, rev, as]) // eslint-disable-line useEffect(() => () => setImg((old) => { old && URL.revokeObjectURL(old.src); return null }), []) return img } // 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, 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 const cb = useRef({}) cb.current = { onEvent, onRoll } const busy = useRef(false) // two overlapping polls (timer + right after a roll) would both deliver the same entries const poll = useCallback(async () => { if (document.hidden || busy.current) return busy.current = true const r = await fetch(`/api/lobbies/${id}/messages?after=${last.current}`).catch(() => null) busy.current = false if (!r || !r.ok) return const fresh = (await r.json()).filter((m) => m.id > last.current) const wasLive = live.current live.current = true if (!fresh.length) return last.current = fresh[fresh.length - 1].id setMsgs((old) => [...old, ...fresh].slice(-300)) if (fresh.some((m) => m.kind === 'event')) cb.current.onEvent() if (wasLive) fresh.filter((m) => m.kind === 'roll' || m.kind === 'check').forEach((m) => cb.current.onRoll(m)) }, [id]) useEffect(() => { poll() 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, socketUp]) return { msgs, poll } } const rollText = (m) => { const res = m.roll.results, n = m.roll.n return { dice: `${n > 1 ? n : ''}d${m.roll.sides}`, list: res && res.join(' + '), sum: res && res.reduce((a, b) => a + b, 0), n } } // the text of a skill / attribute check; players do not see the numbers when the DM hides the difficulty function checkLine(c) { const out = OUTCOME[c.outcome] if (c.target === undefined) return <>rolled {c.roll} → {out} const parts = `${c.base}${c.mod ? ` ${sgn(c.mod)} ${c.diff}` : ''}${c.cmod ? ` ${sgn(c.cmod)} bonus` : ''}` return <>rolled {c.roll} vs {c.target}{parts !== String(c.target) && ({parts})} →{' '} {out} {c.margin >= 0 ? `by ${c.margin}` : `by ${-c.margin}`} } function Chat({ me, people, msgs, poll, id, to, setTo }) { const [text, setText] = useState('') const [events, setEvents] = useState(true) const [err, setErr] = useState('') const box = useRef(null) const stick = useRef(true) useEffect(() => { // keep the newest line in view unless the user scrolled up const el = box.current if (el && stick.current) el.scrollTop = el.scrollHeight }, [msgs, events]) const submit = async (e) => { e.preventDefault(); setErr('') if (!text.trim()) return const r = await send(`/api/lobbies/${id}/messages`, 'POST', { text, to }) if (!r.ok) return setErr(msg(await r.json().catch(() => ({})), 'Could not send.')) setText(''); stick.current = true; poll() } const shown = msgs.filter((m) => events || m.kind !== 'event') const who = (m) => ( <> {m.mine ? 'You' : m.from || '?'} {m.to && {m.to === m.from ? '(private, only you)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}} ) return (
{ const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40 }} role="log" aria-live="polite"> {shown.length === 0 &&

Nothing here yet.

} {shown.map((m) => { if (m.kind === 'event') return
{clock(m.time)} {m.text}
if (m.kind === 'check') { return (
{clock(m.time)} {who(m)} – {m.check.what}: {checkLine(m.check)}{m.check.manual && own dice}
) } if (m.kind === 'roll') { const t = rollText(m) return (
{clock(m.time)} {who(m)} rolled {t.dice}:{' '} {t.list === null ? {Array(t.n).fill('?').join(' ')} : {t.n > 1 ? `${t.list} = ` : ''}{t.sum}} {m.roll.manual && own dice}
) } return (
{clock(m.time)} {who(m)}: {m.text}
) })}
setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" />
{err &&

{err}

}

Whispers and private rolls are only seen by you and the person you pick, not even the DM sees other people's.

) } // ---- dice: hotbar below the table, results are thrown into the middle of the table and posted in the chat const SHAPE = { 4: '50% 4%, 96% 92%, 4% 92%', 6: '6% 6%, 94% 6%, 94% 94%, 6% 94%', 8: '50% 0, 100% 50%, 50% 100%, 0 50%', 10: '50% 0, 98% 40%, 50% 100%, 2% 40%', 12: '50% 0, 98% 38%, 80% 96%, 20% 96%, 2% 38%', 20: '25% 4%, 75% 4%, 100% 50%, 75% 96%, 25% 96%, 0 50%', 100: '' } function DieIcon({ sides }) { const pts = SHAPE[sides] && SHAPE[sides].split(', ').map((p) => p.split(' ').map((v) => parseFloat(v) * .24).join(',')).join(' ') return ( ) } const MODES = [['mystery', 'Mystery', 'Players see grey dice, but not the result'], ['hidden', 'Hidden', 'Only you see the roll'], ['public', 'Public', 'Everybody sees the roll']] function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy, sfx }) { const [snd, setSnd] = useState(sound.enabled) const [vol, setVol] = useState(sound.volume) return (
{DICE.map((d) => ( ))} {snd && { setVol(+e.target.value); sound.setVolume(+e.target.value) }} onPointerUp={() => playRoll(20, 1, undefined, sfx)} />} {isDm && !to && ( {MODES.map(([k, label, tip]) => )} )} {to && {to === me ? 'Private: only you' : `Private: ${to}`}}
) } // shown instead of rolling when "Own dice" is on: the player types what the real dice showed function ManualEntry({ pending, onSubmit, onCancel, err }) { const n = pending.type === 'check' ? 1 : pending.count const sides = pending.type === 'check' ? 100 : pending.sides const [vals, setVals] = useState(Array(n).fill('')) const ok = vals.every((v) => /^\d+$/.test(v) && +v >= 1 && +v <= sides) return (
{ e.preventDefault(); if (ok) onSubmit(vals.map(Number)) }}>
{pending.type === 'check' ? <>Your real d100 for {pending.name} : <>Your real result{n > 1 ? 's' : ''} for {n > 1 ? n : ''}d{sides}} (1–{sides})
{vals.map((v, i) => ( setVals(vals.map((x, j) => (j === i ? e.target.value.replace(/\D/g, '').slice(0, 3) : x)))} /> ))}
{err && {err}}
) } function Die({ sides, value, delay, mystery }) { const [shown, setShown] = useState(reduced() ? value : null) useEffect(() => { if (reduced()) return const t0 = Date.now(), run = 650 + delay const iv = setInterval(() => { if (Date.now() - t0 >= run) { setShown(value); clearInterval(iv) } else setShown(1 + Math.floor(Math.random() * sides)) }, 70) return () => clearInterval(iv) }, [sides, value, delay]) const rolling = shown !== value return ( {mystery && !rolling ? '?' : shown ?? '?'} ) } // dice are thrown onto the table from a random side and come to rest at a random spot, in the colour of whoever threw them const placed = [] // spots of the dice still lying on the table, so new ones do not land on top of them function spots(n) { const out = [] for (let i = 0; i < n; i++) { let p, tries = 0 do { p = { x: 12 + Math.random() * 76, y: 22 + Math.random() * 58 }; tries++ } while (tries < 40 && [...placed, ...out].some((q) => Math.hypot((q.x - p.x) * 1.6, q.y - p.y) < 14)) placed.push(p); if (placed.length > 12) placed.shift() const a = Math.random() * Math.PI * 2 out.push({ ...p, sx: Math.round(Math.cos(a) * 700), sy: Math.round(Math.sin(a) * 450), sr: Math.round(Math.random() * 720 - 360), er: Math.round(Math.random() * 50 - 25) }) } return out } function ThrownDice({ m }) { const sec = m.kind === 'roll' && m.roll.mystery && !m.mine const n = m.kind === 'check' ? 1 : m.roll.n const [pos] = useState(() => spots(n)) return pos.map((p, i) => ( {m.kind === 'check' ? : } )) } function DiceTray({ rolls }) { if (!rolls.length) return null return ( ) } // ---- character bar: one card per player, later also for initiative and enemies function CharBar({ lobby, sel, onSel }) { return (
{lobby.members.map((m) => { const c = m.character, can = !!m.character_id return (
can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}> {c?.portrait ? : (c?.name || m.username).slice(0, 1).toUpperCase()} {c ? c.name : 'No character'} {lobby.is_dm ? e.stopPropagation()} onBlur={(e) => lobby.setMod(m.username, e.target.value)} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} /> : m.mod ? 0 ? 'up' : 'down'}`} title="Your personal bonus / malus">{sgn(m.mod)} : null}
) })} {lobby.members.length === 0 && Nobody has joined yet – share the join code (⚙ Lobby).}
) } // ---- the DM sets the difficulty for all checks, from the rules table or a custom number function DifficultyBar({ lobby, onSet }) { const d = lobby.difficulty const idx = lobby.levels.findIndex((l) => l.name === d.label && l.mod === d.mod) const [custom, setCustom] = useState('') if (!lobby.is_dm) { return
{d.hidden ? 'The DM sets the difficulty' : <>Difficulty: {d.label} {sgn(d.mod)}}
} return (
{ e.preventDefault(); if (/^[-+−]?\d+$/.test(custom.trim())) { onSet({ label: 'Custom', mod: parseInt(custom.replace('−', '-'), 10), hidden: d.hidden }); setCustom('') } }}> setCustom(e.target.value)} placeholder="custom ±N" aria-label="Custom modifier" style={{ width: '6rem' }} />
) } // ---- sheet of one character, refreshed regularly so the DM sees changes the player saves function CharTab({ cid, rules, onRoll, tick }) { const [data, setData] = useState(null) const [err, setErr] = useState('') useEffect(() => { setData(null); setErr('') const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then(setData).catch(() => setErr('Could not load this character.')) load() 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 (
{err &&

{err}

} {data && }
) } // lobby settings: join code, rename, colour, character switch, members, leave / delete // DM: replace the dice sound, add sounds for critical results, switch cues on or off function SoundSettings({ lobby, id, act, sfx }) { const sn = lobby.sounds const [err, setErr] = useState('') const upload = async (slot, e) => { const f = e.target.files?.[0] e.target.value = '' if (!f) return setErr('') const r = await fetch(`/api/lobbies/${id}/sounds/${slot}`, { method: 'POST', headers: { 'Content-Type': 'audio/mpeg' }, body: f }).catch(() => null) if (!r || !r.ok) return setErr(r ? ((await r.json().catch(() => ({}))).detail || 'Upload failed') : 'Upload failed') act() } const flag = (k, v) => act(`/api/lobbies/${id}/sounds`, 'PUT', { [k]: v }) const row = (slot, label, defaultText, flagKey) => (
{flagKey && } {!flagKey && {label}}
{sn.custom[slot] ? 'Your file' : defaultText} {sn.custom[slot] && }
) return (

Sounds

For everyone in this lobby. MP3 up to 1.5 MB; players can still mute the sounds for themselves.

{row('roll', 'Dice sound', 'Default sound', null)} {row('crit_success', 'Critical success', 'Built-in tone', 'crit_success')} {row('crit_failure', 'Critical failure', 'Built-in tone', 'crit_failure')} {err &&

{err}

}
) } function Settings({ lobby, id, mine, act, setInfo, onClose, inline }) { const [rename, setRename] = useState('') const [pick, setPick] = useState('') const me = lobby.members.find((m) => m.me) const taken = new Set(lobby.members.filter((m) => !m.me).map((m) => m.color)) return (
{!inline &&

Lobby

} {lobby.is_dm && ( <>
Join code – give it to your players
{lobby.code}
{ e.preventDefault(); if (rename.trim()) act(`/api/lobbies/${id}`, 'PUT', { name: rename }, () => { setRename(''); act() }) }}> setRename(e.target.value)} placeholder="Rename lobby" aria-label="New lobby name" maxLength={60} />

Players

{lobby.members.length === 0 &&

Nobody yet.

} {lobby.members.map((m) => (
{m.username} {m.character?.name}
))} )} {!lobby.is_dm && me && ( <>

Your colour

{lobby.palette.map((p) => (

Your character

{ e.preventDefault(); if (pick) act(`/api/lobbies/${id}/me`, 'PUT', { character_id: pick }, () => { setPick(''); act() }) }}>

The DM always sees the current saved version of your character. Of the others you only see a short card.

)}
{lobby.is_dm ? : }
) } // ---- one lobby: sheet on the left, player area (character bar, difficulty, dice table) in the middle, chat on the right function LobbyView({ id, rules, user }) { const mine = useMine() const [lobby, setLobby] = useState(null) const [err, setErr] = useState('') const [info, setInfo] = useState('') const [sel, setSel] = useState('') const [to, setTo] = useState('') // whisper recipient, used for chat messages and dice rolls const [tray, setTray] = useState([]) const [count, setCount] = useState(1) const [mode, setMode] = useState('mystery') const [own, setOwnState] = useState(() => store('aw_owndice') === '1') const [pending, setPending] = useState(null) const [busy, setBusy] = useState(false) const [mErr, setMErr] = useState('') const [showSettings, setShowSettings] = useState(false) const [leftTab, setLeftTab] = useState('sheet') // the DM's left column: character sheet or lobby actions const [sheetOpen, setSheetOpen] = useState(() => store('aw_sheetopen') !== '0') const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '0') } const load = useCallback(() => fetch(`/api/lobbies/${id}`).then(async (r) => { 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 [mapState, setMapState] = useState({ map: null, tokens: [] }) const [mapSel, setMapSel] = useState(null) const [viewAs, setViewAs] = useState('') // the DM can look at the table through one player's eyes const [showGrid, setShowGrid] = useState(false) const [showWalls, setShowWalls] = useState(true) const loadMap = useCallback(() => fetch(`/api/lobbies/${id}/map${viewAs ? `?as=${encodeURIComponent(viewAs)}` : ''}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setMapState(d)).catch(() => {}), [id, viewAs]) const mapImg = useMapImage(id, mapState.vision, viewAs) const sock = useRef({}) const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 }) const up = useSocket(id, { open: () => { sock.current.load?.(); sock.current.poll?.(); sock.current.loadMap?.() }, map: () => sock.current.loadMap?.(), messages: () => sock.current.poll?.(), lobby: () => sock.current.load?.(), sheet: (n) => { setSheetTick((t) => ({ cid: n.cid, n: t.n + 1 })); sock.current.load?.(); sock.current.loadMap?.() }, // a saved character can bring a new name or portrait }) useEffect(() => { load(); loadMap() const t = setInterval(() => { if (!document.hidden) { load(); loadMap() } }, up ? 30000 : 5000) // joins, colours, difficulty: pushed, with a timer as safety net return () => clearInterval(t) }, [load, loadMap, up]) const sfx = useRef({}) const sn = lobby?.sounds sfx.current = sn ? { roll: sn.custom.roll ? `/api/lobbies/${id}/sounds/roll?v=${sn.v}` : DEFAULT_ROLL, crit_success: sn.custom.crit_success ? `/api/lobbies/${id}/sounds/crit_success?v=${sn.v}` : null, crit_failure: sn.custom.crit_failure ? `/api/lobbies/${id}/sounds/crit_failure?v=${sn.v}` : null, result: sn.result, crit_success_on: sn.crit_success, crit_failure_on: sn.crit_failure } : { roll: DEFAULT_ROLL } useEffect(() => { preload(sfx.current) }, [sn?.v]) // eslint-disable-line const onRoll = useCallback((m) => { const key = `${m.id}` if (m.kind === 'check') playRoll(100, 1, m.check.outcome, sfx.current) else if (m.kind === 'roll') playRoll(m.roll.sides, m.roll.n, undefined, sfx.current) setTray((t) => [...t.slice(-2), { ...m, key }]) setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000) }, []) const feed = useFeed(id, load, onRoll, up) sock.current = { load, poll: feed.poll, loadMap } const act = async (url, method, body, done) => { if (!url) return load() setErr(''); setInfo('') const r = await send(url, method, body).catch(() => null) const d = r ? await r.json().catch(() => ({})) : {} if (!r || !r.ok) return setErr(msg(d, 'That did not work.')) done ? done(d) : load() } const moveToken = (tid, x, y) => { setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, x, y } : t)) })) return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', { x, y }).catch(() => null) } const patchToken = (tid, p) => { setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, ...p } : t)) })) return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', p).catch(() => null) } const addToken = () => { const m = mapState.map, n = mapState.tokens.filter((t) => t.kind === 'npc').length + 1 send(`/api/lobbies/${id}/tokens`, 'POST', { name: `Enemy ${n}`, x: m.w / 2, y: m.h / 2, size: 1, color: '#c0392b' }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {}) } if (err && !lobby) return

{err}

Back to all lobbies
if (!lobby) return

Loading …

const me = lobby.members.find((m) => m.me) const people = [lobby.dm, ...lobby.members.map((m) => m.username)] const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open const cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0] const dest = people.includes(to) ? to : '' const canCheck = !!(me && me.character_id) lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 }) const post = async (url, body, after) => { setBusy(true); setMErr('') const r = await send(url, 'POST', body).catch(() => null) if (!r || !r.ok) setMErr(msg(r ? await r.json().catch(() => ({})) : {}, 'That did not work.')) else { after?.(); feed.poll() } setTimeout(() => setBusy(false), 350) } const throwDice = (sides) => { if (busy) return if (own) return setPending({ type: 'dice', sides, count }) post(`/api/lobbies/${id}/rolls`, { sides, count, to: dest, ...(lobby.is_dm ? { mode } : {}) }) } const check = (kind, cid, name) => { if (busy) return if (own) return setPending({ type: 'check', kind, id: cid, name }) post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '' }) } const submitManual = (vals) => { if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0], to: dest === user ? user : '' }, () => setPending(null)) else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null)) } return (
← Lobbies

{lobby.name}

DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}
{err &&

{err}

} {info &&

{info}

}
{sheetOpen && (
{lobby.is_dm ? ( ) : {cur ? cur.character.name : 'Character sheet'}} {(!lobby.is_dm || leftTab === 'sheet') && }
{lobby.is_dm && leftTab === 'lobby' ? <> : cur ? :

{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}

}
)}
{!sheetOpen && } { setSel(u); setLeftTab('sheet') }} /> act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
{showSettings && !lobby.is_dm && setShowSettings(false)} />} {mapState.map && (() => { const full = lobby.is_dm && !viewAs const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null const walls = full && showWalls ? mapState.walls || [] : [] return ( t.portrait ? `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}` : null} tokens={mapState.tokens} grid={showGrid} onMove={moveToken} selected={mapSel} onSelect={setMapSel} overlay={walls.length ? act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}> {mapState.vision?.empty &&
{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}
}
{lobby.is_dm && ( )} {lobby.is_dm && mapState.walls?.length > 0 && } {full && mapState.walls?.length > 0 && } {full && }
{selTok &&
patchToken(selTok.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selTok.id}`, 'DELETE'); setMapSel(null) }} />
}
) })()} {canCheck && !pending &&

Click an attribute or skill on your sheet to roll it.

} {pending && { setPending(null); setMErr('') }} err={mErr} />} {!pending && mErr &&

{mErr}

}
) } export default function Lobbies({ section, rules, user, onOpen }) { return section ? : }