diff --git a/README.md b/README.md index 4c29744..f68ed3e 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,8 @@ The lobby page is built for desktop with three columns: the **character sheet** - **Skill and attribute checks**: players click an attribute or an unlocked skill on their own sheet. The server rolls a d100 (roll under): target = attribute or skill total + the DM's difficulty + the character's personal modifier. 01-05 is always a critical success, 96-100 always a critical failure. The chat and the table show the roll, the target and by how much it succeeded or failed. Combat is not handled specially: roll the normal skill and let the DM adjust with the modifiers. - **Difficulty** (bar under the characters): the DM picks a level from the rules table (Trivial +60 ... Hellish -60) or a custom number. It applies to all checks until changed. With **Hide numbers from players** the players only see their roll and success / failure, not the target, difficulty or margin. - **Dice** (below the table): d4 ... d100 with a count (1 to 10 dice). The server rolls, the dice tumble into the middle of the table in the colour of whoever threw them, and the result goes into the chat. With a person selected as recipient in the chat dropdown it is a private roll. **DM rolls** have three modes: *Mystery* (default: players see grey dice with "?" but not the result), *Hidden* (only the DM sees it) and *Public*. -- **Sound**: dice clatter when anything is rolled (yours, other players' and DM rolls, mystery rolls too), plus a short cue for the result of a check (rising chime for a critical success, soft ping for success, low tone for failure, dark double tone for a critical failure). The sounds are generated in the browser, there are no sound files. The speaker button next to the dice mutes them, the slider sets the volume; both are remembered per browser. Browsers only allow sound after the page was clicked once, and nothing plays while the tab is in the background. +- **Sound**: rolling plays `frontend/public/dice.mp3` (the default dice sound) with a slight random pitch; every die has its own base pitch, from a little higher and lighter (d4) to a little lower and heavier (d100). Many dice layer the sound up to three times. Checks add a short cue after the dice: built-in tones for success and failure, and for a critical success or failure either a built-in tone or a sound the DM uploaded. The speaker button next to the dice mutes everything for yourself, the slider sets the volume (remembered per browser). Browsers only allow sound after the page was clicked once, and nothing plays while the tab is in the background. +- **DM sound settings** (*Lobby actions* -> *Sounds*): replace the dice sound with an own MP3 (up to 1.5 MB, per lobby) or reset it to the default, upload sounds for *Critical success* and *Critical failure* (reset = built-in tone), and switch the critical sounds and the short success / failure tones on or off for the whole lobby. *Test* plays a sound locally. - **Own dice**: with this switch on, a click on a die or a skill asks for the result of your real dice instead of rolling. The server only checks the range and marks the roll as "own dice" in the chat. - **Colours**: every player has a different colour (lobby menu -> Your colour); the DM is gold. - **Lobby menu** (top right): join code, rename, remove players, colour, switch character, leave or delete the lobby. @@ -148,6 +149,8 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l | PUT | `/api/lobbies/{id}/doors/{wall}` , `/sight` | DM: open / close a door; line of sight on / off | | POST / PUT / DELETE | `/api/lobbies/{id}/tokens[/{tid}]` | Add NPC token (DM) / move (own token or DM) and edit (DM) / remove (DM) | | WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet / map}` for lobby members (cookie login, same origin) | +| PUT | `/api/lobbies/{id}/sounds` | DM: switch result / critical sounds on or off | +| GET / POST / DELETE | `/api/lobbies/{id}/sounds/{roll\|crit_success\|crit_failure}` | Custom lobby sound (MP3): everyone gets it / DM uploads (raw body) / DM resets | | GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=` to poll) / send, optional `to` = whisper | | POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?, manual?, mode?}` (d4-d20, d100; up to 10 dice). `manual` = results of real dice, `mode` (DM only) = `hidden`/`mystery`/`public` | | POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character | diff --git a/backend/app/main.py b/backend/app/main.py index 9e9994f..dafd6c7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from . import auth from .derive import compute from . import rules_edit from . import maps as maps_mod +from . import sounds as sounds_mod from .realtime import hub from .export import to_docx, to_pdf @@ -102,6 +103,7 @@ def db() -> sqlite3.Connection: if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]: con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}") maps_mod.schema(con) + sounds_mod.schema(con) for table in ("characters", "presets"): # older databases: add the owner column if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]: con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER") @@ -612,7 +614,7 @@ def read_lobby(lid: str, user: dict = Depends(current_user)): diff = {"label": "", "mod": None, "hidden": True} return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm, "code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff, - "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR} + "sounds": sounds_mod.settings(lobby["sounds"]), "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR} @api.put("/lobbies/{lid}/difficulty") @@ -841,6 +843,7 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)): con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobbies WHERE id=?", (lid,)) + purge_sounds(lid) hub.close_lobby(lid) return {"ok": True} @@ -1018,6 +1021,7 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps") +purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds") @app.websocket("/api/lobbies/{lid}/ws") diff --git a/backend/app/sounds.py b/backend/app/sounds.py new file mode 100644 index 0000000..0dfa870 --- /dev/null +++ b/backend/app/sounds.py @@ -0,0 +1,104 @@ +"""Lobby sounds: the DM can replace the dice sound and add sounds for critical successes / failures (MP3, per lobby).""" +import json +from pathlib import Path + +from fastapi import Body, Depends, HTTPException, Request +from fastapi.responses import Response + +MAX_SOUND = 1_500_000 # bytes per file +SLOTS = ("roll", "crit_success", "crit_failure") +FLAGS = ("result", "crit_success", "crit_failure") # result = the short tones for success / failure of a check + + +def schema(con): + if "sounds" not in [r["name"] for r in con.execute("PRAGMA table_info(lobbies)")]: + con.execute("ALTER TABLE lobbies ADD COLUMN sounds TEXT NOT NULL DEFAULT '{}'") + + +def settings(raw) -> dict: + try: + s = json.loads(raw or "{}") + except ValueError: + s = {} + return {"result": s.get("result", True), "crit_success": s.get("crit_success", True), "crit_failure": s.get("crit_failure", True), + "custom": {k: bool(s.get("custom", {}).get(k)) for k in SLOTS}, "v": int(s.get("v", 0))} + + +def install(api, db, current_user, lobby_for, dm_only, hub, store_dir: Path): + store_dir.mkdir(parents=True, exist_ok=True) + + def path(lid: str, slot: str) -> Path: + if slot not in SLOTS: + raise HTTPException(404, "Unknown sound") + return store_dir / f"{lid}_{slot}.mp3" + + def save(con, lid, s): + s["v"] += 1 + con.execute("UPDATE lobbies SET sounds=? WHERE id=?", (json.dumps(s), lid)) + + @api.put("/lobbies/{lid}/sounds") + def set_flags(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): + with db() as con: + lobby = lobby_for(con, lid, user) + dm_only(lobby, user) + s = settings(lobby["sounds"]) + for k in FLAGS: + if k in body: + s[k] = bool(body[k]) + save(con, lid, s) + hub.notify(lid, "lobby") + return {"ok": True} + + @api.post("/lobbies/{lid}/sounds/{slot}") + async def upload_sound(lid: str, slot: str, request: Request, user: dict = Depends(current_user)): + """Raw MP3 bytes in the body.""" + p = path(lid, slot) + with db() as con: + dm_only(lobby_for(con, lid, user), user) + if int(request.headers.get("content-length") or 0) > MAX_SOUND: + raise HTTPException(413, f"The file is too large (at most {MAX_SOUND // 1_000_000} MB)") + chunks, size = [], 0 + async for c in request.stream(): + size += len(c) + if size > MAX_SOUND: + raise HTTPException(413, f"The file is too large (at most {MAX_SOUND // 1_000_000} MB)") + chunks.append(c) + data = b"".join(chunks) + if not (data[:3] == b"ID3" or (len(data) > 4 and data[0] == 0xFF and data[1] & 0xE0 == 0xE0)): + raise HTTPException(422, "Please upload an MP3 file") + p.write_bytes(data) + with db() as con: + lobby = lobby_for(con, lid, user) + s = settings(lobby["sounds"]) + s["custom"][slot] = True + save(con, lid, s) + hub.notify(lid, "lobby") + return {"ok": True} + + @api.delete("/lobbies/{lid}/sounds/{slot}") + def reset_sound(lid: str, slot: str, user: dict = Depends(current_user)): + p = path(lid, slot) + with db() as con: + lobby = lobby_for(con, lid, user) + dm_only(lobby, user) + s = settings(lobby["sounds"]) + s["custom"][slot] = False + save(con, lid, s) + p.unlink(missing_ok=True) + hub.notify(lid, "lobby") + return {"ok": True} + + @api.get("/lobbies/{lid}/sounds/{slot}") + def get_sound(lid: str, slot: str, v: int = 0, user: dict = Depends(current_user)): + p = path(lid, slot) + with db() as con: + lobby_for(con, lid, user) + if not p.exists(): + raise HTTPException(404, "No custom sound") + return Response(p.read_bytes(), media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"}) + + def purge(lid: str): + for slot in SLOTS: + path(lid, slot).unlink(missing_ok=True) + + return purge diff --git a/frontend/public/dice.mp3 b/frontend/public/dice.mp3 new file mode 100644 index 0000000..e73d79d Binary files /dev/null and b/frontend/public/dice.mp3 differ diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index 922f6b8..f85e71f 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx' -import { sound, playRoll } from './sound.js' +import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js' import { MapView, MapLibrary, TokenForm, WallLayer } from './MapView.jsx' const J = { 'Content-Type': 'application/json' } @@ -303,7 +303,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, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) { +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 ( @@ -322,8 +322,8 @@ function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, on setOwn(e.target.checked)} style={{ width: 'auto' }} /> Own dice - - {snd && { setVol(+e.target.value); sound.setVolume(+e.target.value) }} onPointerUp={() => playRoll(1)} />} + + {snd && { setVol(+e.target.value); sound.setVolume(+e.target.value) }} onPointerUp={() => playRoll(20, 1, undefined, sfx)} />} {isDm && !to && ( @@ -498,6 +498,49 @@ function CharTab({ cid, rules, onRoll, tick }) { } // 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) => ( +
+
+ {flagKey && } + {!flagKey && {label}} +
+
+ {sn.custom[slot] ? 'Your file' : defaultText} + + + + {sn.custom[slot] && } + +
+
+ ) + 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)} + + {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('') @@ -604,10 +647,17 @@ function LobbyView({ id, rules, user }) { 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(1, m.check.outcome) - else if (m.kind === 'roll') playRoll(m.roll.n) + 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) }, []) @@ -695,7 +745,7 @@ function LobbyView({ id, rules, user }) { {(!lobby.is_dm || leftTab === 'sheet') && } {lobby.is_dm && leftTab === 'lobby' - ? <> + ? <> : cur ? :

{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}

} @@ -737,7 +787,7 @@ function LobbyView({ id, rules, user }) { {pending && { setPending(null); setMErr('') }} err={mErr} />} {!pending && mErr &&

{mErr}

} - +