Lobby: private rolls and checks for players, DM tab with lobby actions

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:32:57 +00:00
parent 78f0de5333
commit 1c672f0ab8
3 changed files with 33 additions and 21 deletions
+5 -4
View File
@@ -634,15 +634,15 @@ def set_member_mod(lid: str, username: str, body: dict = Body(...), user: dict =
def _recipient(con, lobby, user: dict, to): def _recipient(con, lobby, user: dict, to):
"""User id to whisper to (the DM or a member, not yourself), None for everybody.""" """User id to whisper to (the DM, a member or yourself for private rolls and notes), None for everybody."""
to = str(to or "").strip() to = str(to or "").strip()
if not to: if not to:
return None return None
target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS
(SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""", (SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""",
(to, lobby["owner_id"], lobby["id"])).fetchone() (to, lobby["owner_id"], lobby["id"])).fetchone()
if not target or target["id"] == user["id"]: if not target:
raise HTTPException(422, "You can only whisper to other people in this lobby") raise HTTPException(422, "You can only whisper to people in this lobby")
return target["id"] return target["id"]
@@ -796,7 +796,8 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us
payload = {"kind": kind, "what": what, "base": base, "mod": lobby["diff_mod"], "cmod": member["char_mod"], "diff": lobby["diff_label"], payload = {"kind": kind, "what": what, "base": base, "mod": lobby["diff_mod"], "cmod": member["char_mod"], "diff": lobby["diff_label"],
"target": target, "roll": roll, "margin": target - roll, "outcome": outcome, "hidden": bool(lobby["diff_hidden"]), "target": target, "roll": roll, "margin": target - roll, "outcome": outcome, "hidden": bool(lobby["diff_hidden"]),
"manual": bool(manual)} "manual": bool(manual)}
mid = _post(con, lid, user["id"], None, "check", payload) private = str(body.get("to") or "") == user["username"] # a private check is only seen by the roller
mid = _post(con, lid, user["id"], user["id"] if private else None, "check", payload)
return {"id": mid, "roll": roll, "outcome": outcome} return {"id": mid, "roll": roll, "outcome": outcome}
+26 -17
View File
@@ -177,7 +177,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
const who = (m) => ( const who = (m) => (
<> <>
<strong style={m.color ? { color: m.color } : undefined}>{m.mine ? 'You' : m.from || '?'}</strong> <strong style={m.color ? { color: m.color } : undefined}>{m.mine ? 'You' : m.from || '?'}</strong>
{m.to && <em className="small whispertag"> {m.to === m.from ? '(only visible to you, the DM)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>} {m.to && <em className="small whispertag"> {m.to === m.from ? '(private, only you)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
</> </>
) )
@@ -216,6 +216,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
<form className="row chatform" onSubmit={submit}> <form className="row chatform" onSubmit={submit}>
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}> <select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
<option value="">Everyone</option> <option value="">Everyone</option>
<option value={me}>Only me (private)</option>
{people.filter((p) => p !== me).map((p) => <option key={p} value={p}>Only {p} (whisper)</option>)} {people.filter((p) => p !== me).map((p) => <option key={p} value={p}>Only {p} (whisper)</option>)}
</select> </select>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" /> <input value={text} onChange={(e) => setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" />
@@ -243,7 +244,7 @@ function DieIcon({ sides }) {
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']] 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 }) { function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
return ( return (
<div className="dicebar" role="toolbar" aria-label="Dice"> <div className="dicebar" role="toolbar" aria-label="Dice">
<label className="small muted dicecount">Dice <label className="small muted dicecount">Dice
@@ -264,7 +265,7 @@ function DiceBar({ to, isDm, count, setCount, mode, setMode, own, setOwn, onRoll
{MODES.map(([k, label, tip]) => <button key={k} className={mode === k ? 'on' : 'ghost'} title={tip} onClick={() => setMode(k)}>{label}</button>)} {MODES.map(([k, label, tip]) => <button key={k} className={mode === k ? 'on' : 'ghost'} title={tip} onClick={() => setMode(k)}>{label}</button>)}
</span> </span>
)} )}
{to && <span className="small dicedest priv">Private: {to}</span>} {to && <span className="small dicedest priv">{to === me ? 'Private: only you' : `Private: ${to}`}</span>}
</div> </div>
) )
} }
@@ -346,7 +347,7 @@ function DiceTray({ rolls }) {
const who = <b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b> const who = <b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b>
if (m.kind === 'check') { if (m.kind === 'check') {
const c = m.check const c = m.check
return <div key={m.key} className="trayroll">{who} – {c.what}{c.manual ? ' · own dice' : ''}: <span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>{c.target !== undefined && <small> ({c.roll} vs {c.target})</small>}</div> return <div key={m.key} className="trayroll">{who} – {c.what}{c.manual ? ' · own dice' : ''}{m.to ? ' · only you' : ''}: <span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>{c.target !== undefined && <small> ({c.roll} vs {c.target})</small>}</div>
} }
const t = rollText(m) const t = rollText(m)
return <div key={m.key} className="trayroll">{who} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''} return <div key={m.key} className="trayroll">{who} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}
@@ -429,14 +430,14 @@ function CharTab({ cid, rules, onRoll }) {
} }
// lobby settings: join code, rename, colour, character switch, members, leave / delete // lobby settings: join code, rename, colour, character switch, members, leave / delete
function Settings({ lobby, id, mine, act, setInfo, onClose }) { function Settings({ lobby, id, mine, act, setInfo, onClose, inline }) {
const [rename, setRename] = useState('') const [rename, setRename] = useState('')
const [pick, setPick] = useState('') const [pick, setPick] = useState('')
const me = lobby.members.find((m) => m.me) const me = lobby.members.find((m) => m.me)
const taken = new Set(lobby.members.filter((m) => !m.me).map((m) => m.color)) const taken = new Set(lobby.members.filter((m) => !m.me).map((m) => m.color))
return ( return (
<div className="settings card"> <div className={inline ? 'settings inline' : 'settings card'}>
<div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div> {!inline && <div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>}
{lobby.is_dm && ( {lobby.is_dm && (
<> <>
<div className="row"> <div className="row">
@@ -505,6 +506,7 @@ function LobbyView({ id, rules, user }) {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [mErr, setMErr] = useState('') const [mErr, setMErr] = useState('')
const [showSettings, setShowSettings] = useState(false) 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 [sheetOpen, setSheetOpen] = useState(() => store('aw_sheetopen') !== '0')
const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '0') } const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '0') }
@@ -560,10 +562,10 @@ function LobbyView({ id, rules, user }) {
const check = (kind, cid, name) => { const check = (kind, cid, name) => {
if (busy) return if (busy) return
if (own) return setPending({ type: 'check', kind, id: cid, name }) if (own) return setPending({ type: 'check', kind, id: cid, name })
post(`/api/lobbies/${id}/checks`, { kind, id: cid }) post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '' })
} }
const submitManual = (vals) => { const submitManual = (vals) => {
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0] }, () => setPending(null)) 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)) else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null))
} }
@@ -574,7 +576,7 @@ function LobbyView({ id, rules, user }) {
<h1>{lobby.name}</h1> <h1>{lobby.name}</h1>
<span className="muted small">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span> <span className="muted small">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
<span className="tabright"> <span className="tabright">
<button className="ghost" onClick={() => setShowSettings((v) => !v)} aria-expanded={showSettings}>⚙ Lobby</button> <button className="ghost" onClick={() => (lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ Lobby</button>
</span> </span>
</div> </div>
{err && <p className="warn">{err}</p>} {err && <p className="warn">{err}</p>}
@@ -584,26 +586,33 @@ function LobbyView({ id, rules, user }) {
{sheetOpen && ( {sheetOpen && (
<div className="sheetcol"> <div className="sheetcol">
<div className="sheetbar"> <div className="sheetbar">
<b>{cur ? cur.character.name : 'Character sheet'}</b> {lobby.is_dm ? (
<span className="tabright"><ThemeSwitch /> <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide the sheet">◀</button></span> <span className="seg" role="tablist">
<button role="tab" className={leftTab === 'sheet' ? 'on' : 'ghost'} aria-selected={leftTab === 'sheet'} onClick={() => setLeftTab('sheet')}>Sheet{cur ? `: ${cur.character.name}` : ''}</button>
<button role="tab" className={leftTab === 'lobby' ? 'on' : 'ghost'} aria-selected={leftTab === 'lobby'} onClick={() => setLeftTab('lobby')}>Lobby actions</button>
</span>
) : <b>{cur ? cur.character.name : 'Character sheet'}</b>}
<span className="tabright">{(!lobby.is_dm || leftTab === 'sheet') && <ThemeSwitch />} <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide this column">◀</button></span>
</div> </div>
{cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} onRoll={cur.me && canCheck ? check : undefined} /> {lobby.is_dm && leftTab === 'lobby'
: <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>} ? <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} />
: <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> </div>
)} )}
<div className="playarea"> <div className="playarea">
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>} {!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
<CharBar lobby={lobby} sel={cur?.username} onSel={setSel} /> <CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setLeftTab('sheet') }} />
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} /> <DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
<div className="table"> <div className="table">
{showSettings && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />} {showSettings && !lobby.is_dm && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
<DiceTray rolls={tray} /> <DiceTray rolls={tray} />
{canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>} {canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />} {pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>} {!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
</div> </div>
<DiceBar to={dest} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} /> <DiceBar to={dest} me={user} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
</div> </div>
<aside className="lobbychat card" aria-label="Chat"> <aside className="lobbychat card" aria-label="Chat">
+2
View File
@@ -394,3 +394,5 @@ body:has(.lobbychat) main { padding: .5rem calc(var(--chatw) + .5rem) 0 .5rem; }
.edtable td.actions { text-align: right; white-space: nowrap; } .edtable td.actions { text-align: right; white-space: nowrap; }
.edtable tr.sel { background: var(--panel2); } .edtable tr.sel { background: var(--panel2); }
.editor .tabs { margin-top: .6rem; } .editor .tabs { margin-top: .6rem; }
.settings.inline { position: static; max-width: none; margin: 0; border: 0; background: transparent; padding: 0; }