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):
"""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()
if not to:
return None
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))""",
(to, lobby["owner_id"], lobby["id"])).fetchone()
if not target or target["id"] == user["id"]:
raise HTTPException(422, "You can only whisper to other people in this lobby")
if not target:
raise HTTPException(422, "You can only whisper to people in this lobby")
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"],
"target": target, "roll": roll, "margin": target - roll, "outcome": outcome, "hidden": bool(lobby["diff_hidden"]),
"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}
+25 -16
View File
@@ -177,7 +177,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
const who = (m) => (
<>
<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}>
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
<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>)}
</select>
<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']]
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 (
<div className="dicebar" role="toolbar" aria-label="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>)}
</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>
)
}
@@ -346,7 +347,7 @@ function DiceTray({ rolls }) {
const who = <b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b>
if (m.kind === '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)
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
function Settings({ lobby, id, mine, act, setInfo, onClose }) {
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 (
<div className="settings card">
<div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>
<div className={inline ? 'settings inline' : 'settings card'}>
{!inline && <div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>}
{lobby.is_dm && (
<>
<div className="row">
@@ -505,6 +506,7 @@ function LobbyView({ id, rules, user }) {
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') }
@@ -560,10 +562,10 @@ function LobbyView({ id, rules, user }) {
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 })
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] }, () => 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))
}
@@ -574,7 +576,7 @@ function LobbyView({ id, rules, user }) {
<h1>{lobby.name}</h1>
<span className="muted small">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
<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>
</div>
{err && <p className="warn">{err}</p>}
@@ -584,26 +586,33 @@ function LobbyView({ id, rules, user }) {
{sheetOpen && (
<div className="sheetcol">
<div className="sheetbar">
<b>{cur ? cur.character.name : 'Character sheet'}</b>
<span className="tabright"><ThemeSwitch /> <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide the sheet">◀</button></span>
{lobby.is_dm ? (
<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>
{cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} onRoll={cur.me && canCheck ? check : undefined} />
{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} />
: <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 className="playarea">
{!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)} />
<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} />
{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 && mErr && <p className="warn small tableerr">{mErr}</p>}
</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>
<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 tr.sel { background: var(--panel2); }
.editor .tabs { margin-top: .6rem; }
.settings.inline { position: static; max-width: none; margin: 0; border: 0; background: transparent; padding: 0; }