import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.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 (
onChange(e.target.value)} aria-label={label}>
Choose a character …
{mine.map((c) => {c.name} )}
)
}
// ---- 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
{mine.length === 0 &&
You need a saved character first (Character → Summary → Save).
}
Lobbies you joined
{data && data.joined.length === 0 && None yet.
}
{(data?.joined || []).map((l) => (
{l.name}
DM {l.dm}{l.character ? ` · playing ${l.character}` : ' · no character selected'}
))}
Lobbies you run (as DM)
{data && data.mine.length === 0 && None yet.
}
{(data?.mine || []).map((l) => (
{l.name}
{l.members} player{l.members === 1 ? '' : 's'}
))}
)
}
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!' }
// 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) {
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 poll = useCallback(async () => {
if (document.hidden) return
const r = await fetch(`/api/lobbies/${id}/messages?after=${last.current}`).catch(() => null)
if (!r || !r.ok) return
const fresh = await r.json()
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, 2500)
return () => clearInterval(t)
}, [poll])
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 ? '(only visible to you, the DM)' : 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}
)
})}
{err &&
{err}
}
setEvents(e.target.checked)} style={{ width: 'auto' }} /> Show events (joins, character switches …)
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 (
{pts ? : }
)
}
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, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
return (
Dice
setCount(+e.target.value)} aria-label="Number of dice">
{[1, 2, 3, 4, 5, 6, 8, 10].map((n) => {n}× )}
{DICE.map((d) => (
onRoll(d)} disabled={busy} title={`Roll ${count > 1 ? count : ''}d${d}`} aria-label={`Roll ${count} d${d}`}>
d{d}
))}
setOwn(e.target.checked)} style={{ width: 'auto' }} /> Own dice
{isDm && !to && (
{MODES.map(([k, label, tip]) => setMode(k)}>{label} )}
)}
{to && 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 (
)
}
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 ?? '?'}
)
}
// the tray is the middle of the table: dice in the colour of whoever threw them
function DiceTray({ rolls }) {
if (!rolls.length) return null
return (
{rolls.map((m) => {
const style = { '--dc': m.color || 'var(--noxium)' }
if (m.kind === 'check') {
const c = m.check
return (
{m.mine ? 'You' : m.from} – {c.what}{c.manual ? ' · own dice' : ''}
{OUTCOME[c.outcome]}
{c.target !== undefined && {c.roll} vs {c.target} }
)
}
const t = rollText(m)
const sec = m.roll.mystery && !m.mine
return (
{m.mine ? 'You' : m.from} rolled {t.dice}
{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}
{Array.from({ length: t.n }, (_, i) => )}
{t.n > 1 && t.sum !== null && = {t.sum} }
)
})}
)
}
// ---- 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?.name || m.username).slice(0, 1).toUpperCase()}
{c ? c.name : 'No character'}
{m.username}{m.me ? ' (you)' : ''}{c?.class ? ` · ${c.class}` : ''}
{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 (
Difficulty
= 0 ? idx : 'custom'} aria-label="Difficulty"
onChange={(e) => { const l = lobby.levels[e.target.value]; if (l) onSet({ label: l.name, mod: l.mod, hidden: d.hidden }) }}>
{lobby.levels.map((l, i) => {l.name} ({sgn(l.mod)}) )}
{idx < 0 && {d.label} ({sgn(d.mod)}) }
onSet({ label: d.label, mod: d.mod, hidden: e.target.checked })} style={{ width: 'auto' }} /> Hide numbers from players
)
}
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
function CharTab({ cid, rules, onRoll }) {
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])
return (
{err &&
{err}
}
{data &&
}
)
}
// lobby settings: join code, rename, colour, character switch, members, leave / delete
function Settings({ lobby, id, mine, act, setInfo, onClose }) {
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 (
Lobby Close
{lobby.is_dm && (
<>
Join code – give it to your players
{lobby.code}
navigator.clipboard?.writeText(lobby.code).then(() => setInfo('Code copied.')).catch(() => {})}>Copy
window.confirm('The old code stops working. Current players stay in the lobby.') && act(`/api/lobbies/${id}/code`, 'POST')}>New code
Players
{lobby.members.length === 0 &&
Nobody yet.
}
{lobby.members.map((m) => (
{m.username} {m.character?.name}
window.confirm(`Remove ${m.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(m.username)}`, 'DELETE')}>Remove
))}
>
)}
{!lobby.is_dm && me && (
<>
Your colour
{lobby.palette.map((p) => (
act(`/api/lobbies/${id}/me`, 'PUT', { color: p.id })} />
))}
Your character
The DM always sees the current saved version of your character. Of the others you only see a short card.
>
)}
{lobby.is_dm
? window.confirm(`Delete the lobby "${lobby.name}" for everyone? Characters are not deleted.`) && act(`/api/lobbies/${id}`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Delete lobby
: window.confirm('Leave this lobby?') && act(`/api/lobbies/${id}/me`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Leave lobby }
)
}
// ---- 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 [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])
useEffect(() => {
load()
const t = setInterval(() => !document.hidden && load(), 5000) // picks up joins, colours, difficulty and character changes
return () => clearInterval(t)
}, [load])
const onRoll = useCallback((m) => {
const key = `${m.id}`
setTray((t) => [...t.slice(-2), { ...m, key }])
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 9000)
}, [])
const feed = useFeed(id, load, onRoll)
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()
}
if (err && !lobby) return
if (!lobby) return
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 })
}
const submitManual = (vals) => {
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0] }, () => 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)' : ''}
setShowSettings((v) => !v)} aria-expanded={showSettings}>⚙ Lobby
{err && {err}
}
{info && {info}
}
{sheetOpen && (
{cur ? cur.character.name : 'Character sheet'}
{ setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide the sheet">◀
{cur ?
:
{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}
}
)}
{!sheetOpen &&
{ setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet }
act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
{showSettings &&
setShowSettings(false)} />}
{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 ? :
}