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:
+5
-4
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user