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, NpcPicker, 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 (
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!' }
// 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}
)
})}
{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, 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
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
{ sound.setEnabled(!snd); setSnd(!snd); if (!snd) playRoll(20, 1, undefined, sfx) }}>{snd ? '🔊' : '🔇'}
{snd && { setVol(+e.target.value); sound.setVolume(+e.target.value) }} onPointerUp={() => playRoll(20, 1, undefined, sfx)} />}
{isDm && !to && (
{MODES.map(([k, label, tip]) => setMode(k)}>{label} )}
)}
{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 (
)
}
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 (
{rolls.map((m) =>
)}
{rolls.map((m) => {
const who =
{m.mine ? 'You' : m.from}
if (m.kind === 'check') {
const c = m.check
return
{who} – {c.what}{c.manual ? ' · own dice' : ''}{m.to ? ' · only you' : ''}: {OUTCOME[c.outcome]} {c.target !== undefined && ({c.roll} vs {c.target}) }
}
const t = rollText(m)
return
{who} rolled {t.dice} {m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}
{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?.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 (
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, 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) => (
)
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)}
flag('result', e.target.checked)} /> Short tones for success / failure of a check
{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 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 [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 [npcPick, setNpcPick] = useState(false)
const addNpcToken = (n) => {
const m = mapState.map
setNpcPick(false)
send(`/api/lobbies/${id}/tokens`, 'POST', { name: n.name, npc_id: n.id, x: m.w / 2, y: m.h / 2, size: n.size || 1, color: '#c0392b' }).then((r) => r.json()).then((d) => d.id && setMapSel(d.id)).catch(() => {})
}
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
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 npcTok = lobby.is_dm && !viewAs ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc' && t.npc_id) : null
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, npcToken) => {
if (busy) return
if (own) return setPending({ type: 'check', kind, id: cid, name, npcToken })
post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '', ...(npcToken ? { npc_token: npcToken } : {}) })
}
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 : '', ...(pending.npcToken ? { npc_token: pending.npcToken } : {}) }, () => 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)' : ''}
(lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ Lobby
{err && {err}
}
{info && {info}
}
{sheetOpen && (
{lobby.is_dm ? (
setLeftTab('sheet')}>Sheet{npcTok ? `: ${npcTok.name}` : cur ? `: ${cur.character.name}` : ''}
setLeftTab('lobby')}>Lobby actions
) : {cur ? cur.character.name : 'Character sheet'} }
{(!lobby.is_dm || leftTab === 'sheet') && } { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide this column">◀
{lobby.is_dm && leftTab === 'lobby'
? <>
>
: npcTok ?
check(k, i, n, npcTok.id)} />
: 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 }
{ setSel(u); setMapSel(null); 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 ? (t.kind === 'npc' ? `/api/lobbies/${id}/token-portrait/${t.id}?v=${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 && (
{ setViewAs(e.target.value); setMapSel(null) }} aria-label="View the table as" title="Look at the table through a player's eyes">
Full view
{lobby.members.map((m) => View as {m.character?.name || m.username} )}
)}
{lobby.is_dm && mapState.walls?.length > 0 && act(`/api/lobbies/${id}/sight`, 'PUT', { on: !mapState.los_on }, () => loadMap())} title="Line of sight: players only see what their character can see">Sight {mapState.los_on ? 'on' : 'off'} }
{full && mapState.walls?.length > 0 && setShowWalls(!showWalls)} title="Show walls and doors (click a door to open or close it)">Walls }
setShowGrid(!showGrid)}>Grid
{full && + Token }
{full && setNpcPick(!npcPick)}>+ NPC }
{npcPick && setNpcPick(false)} />
}
{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 ? :
}