diff --git a/README.md b/README.md index d9711cd..4a04b1c 100644 --- a/README.md +++ b/README.md @@ -83,18 +83,22 @@ Each wizard page has a **Reset this page** button that clears only that page's d ### Character sheet (A4) -The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits in the tab bar) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout. +The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout. ### Lobbies The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave). -The lobby page is built for desktop: the main area on the left, the chat as a column at the right edge of the window and a dice hotbar at the bottom centre of the main area. +The lobby page is built for desktop with three columns: the **character sheet** on the left (collapsible with the arrow), the **Player Area** in the middle and the **chat** at the right edge of the window. -- **Chat** (right column): live chat for everyone, plus **whispers** to a single person (pick them in the dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes); it can be hidden with a checkbox. The chat polls the server every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages per minute. -- **Dice** (bottom centre): d4, d6, d8, d10, d12, d20 and d100, with a count (1 to 10 dice). A click rolls on the server (so nobody can cheat), the dice tumble into a tray above the hotbar for everybody in the lobby and the result is posted in the chat. Rolls follow the chat's recipient dropdown: with a person selected it is a private roll only that person (and you) can see. **The DM's rolls are hidden from the players** (the hotbar says "Hidden (only you)"); the DM can still pick a player as recipient to show them a roll. -- **Party** (tab): member cards (name, concept, class, profession). The DM also finds the join code, rename, remove player and delete lobby here. -- **One sheet tab per character** (next to Party): the DM gets a tab for every character in the lobby (read-only, always the current saved version, refreshed every 15 s); a player gets "My character". Hover or tap a value to see how it is made up. "Open in the Character tab" jumps to the full editor (for the DM it is a read-only look, "Save" would create the DM's own copy). +- **Chat** (right): live chat for everyone, plus **whispers** to a single person (dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes, can be hidden), dice rolls and skill checks. Names are shown in the player's colour. The chat polls every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages/rolls per minute. +- **Character bar** (top of the Player Area): one card per player with the character, class and the player's colour. The DM clicks a card to show that character's sheet on the left (always the current saved version, refreshed every 15 s) and can enter a personal **bonus / malus** per character in the small number field. Players see their own sheet only and just a short card of the others. (The bar is also meant for initiative and enemies later.) +- **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*. +- **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. Players only see a short card of the other players, never their sheets. Guessing join codes is rate limited. Deleting a character takes it out of the lobbies (the player can pick another); deleting a lobby does not delete characters. @@ -116,8 +120,11 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l | GET / PUT / DELETE | `/api/lobbies/{id}` | Members and cards (DM sees all ids) / rename / delete (DM only) | | POST | `/api/lobbies/{id}/code` | New join code (DM only) | | 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?}` (d4–d20, d100; up to 10 dice), result goes into the chat | -| PUT / DELETE | `/api/lobbies/{id}/me` | Switch your character / leave | +| 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 | +| PUT | `/api/lobbies/{id}/difficulty` | `{label, mod, hidden}` (DM only) | +| PUT | `/api/lobbies/{id}/members/{username}/mod` | Personal bonus / malus `{mod}` (DM only) | +| PUT / DELETE | `/api/lobbies/{id}/me` | Switch your character and / or `{color}` / leave | | DELETE | `/api/lobbies/{id}/members/{username}` | Remove a player (DM only) | | POST | `/api/presets` | Create a preset | | PUT / DELETE | `/api/presets/{id}` | Change / delete (owner only) | diff --git a/backend/app/main.py b/backend/app/main.py index 1be83f6..35f48dd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from . import auth +from .derive import compute from .export import to_docx, to_pdf BASE = Path(__file__).resolve().parent.parent @@ -26,6 +27,11 @@ MAX_LOBBIES = 20 # per DM MAX_MEMBERS = 20 # per lobby MAX_MESSAGE = 500 # characters per chat message KEEP_MESSAGES = 1000 # per lobby, older ones are dropped +PALETTE = [ # player colours (dice, cards, chat names); every player in a lobby has a different one + ("red", "#e0524d"), ("orange", "#ea8a3b"), ("yellow", "#e6c52e"), ("green", "#6fc24b"), ("teal", "#2fb8a6"), + ("blue", "#4f93e6"), ("purple", "#9d6fda"), ("pink", "#e870b1"), ("white", "#e9e6da"), ("brown", "#a8764f")] +PALETTE_HEX = dict(PALETTE) +DM_COLOR = "#d4af37" CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I # No public API docs: everything about the API stays behind the login @@ -62,6 +68,11 @@ def db() -> sqlite3.Connection: id INTEGER PRIMARY KEY AUTOINCREMENT, lobby_id TEXT NOT NULL, user_id INTEGER, to_user_id INTEGER, kind TEXT NOT NULL, text TEXT NOT NULL, created REAL NOT NULL)""") con.execute("CREATE INDEX IF NOT EXISTS lobby_messages_lobby ON lobby_messages (lobby_id, id)") + for table, col, ddl in (("lobbies", "diff_mod", "INTEGER NOT NULL DEFAULT 0"), ("lobbies", "diff_label", "TEXT NOT NULL DEFAULT 'Challenging'"), + ("lobbies", "diff_hidden", "INTEGER NOT NULL DEFAULT 0"), ("lobby_members", "color", "TEXT"), + ("lobby_members", "char_mod", "INTEGER NOT NULL DEFAULT 0")): + 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}") 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") @@ -426,6 +437,32 @@ def _own_character(con, cid, user: dict): raise HTTPException(422, "Pick one of your own characters") +def _free_color(con, lid: str): + used = {r["color"] for r in con.execute("SELECT color FROM lobby_members WHERE lobby_id=?", (lid,))} + return next((cid for cid, _ in PALETTE if cid not in used), PALETTE[0][0]) + + +def _ensure_colors(con, lid: str): + """Members that joined before colours existed get the next free one.""" + for r in con.execute("SELECT user_id FROM lobby_members WHERE lobby_id=? AND color IS NULL ORDER BY joined", (lid,)).fetchall(): + con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (_free_color(con, lid), lid, r["user_id"])) + + +def _levels(rules: dict) -> list: + """Difficulty levels from the rules text (section 'difficulty', first table): [{name, mod}].""" + out = [] + for sec in rules.get("sections", []): + if sec.get("id") != "difficulty": + continue + for block in sec.get("blocks", []): + for row in (block.get("table") or {}).get("rows", []): + try: + out.append({"name": row[0], "mod": int(row[1].replace("\u2212", "-").replace("\u00b1", "").replace("+", "").strip() or 0)}) + except (ValueError, IndexError): + pass + return out + + def _event(con, lid: str, text: str, uid=None): """Writes a line into the lobby's event log (shown in the chat feed to everybody).""" con.execute("INSERT INTO lobby_messages (lobby_id, user_id, kind, text, created) VALUES (?,?, 'event', ?, ?)", @@ -494,9 +531,9 @@ def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(cu known = con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lobby["id"], user["id"])).fetchone() if not known and con.execute("SELECT COUNT(*) FROM lobby_members WHERE lobby_id=?", (lobby["id"],)).fetchone()[0] >= MAX_MEMBERS: raise HTTPException(409, "This lobby is full") - con.execute("""INSERT INTO lobby_members (lobby_id, user_id, character_id, joined) VALUES (?,?,?,?) + con.execute("""INSERT INTO lobby_members (lobby_id, user_id, character_id, joined, color) VALUES (?,?,?,?,?) ON CONFLICT(lobby_id, user_id) DO UPDATE SET character_id=excluded.character_id""", - (lobby["id"], user["id"], body["character_id"], time.time())) + (lobby["id"], user["id"], body["character_id"], time.time(), _free_color(con, lobby["id"]))) _event(con, lobby["id"], f"{user['username']} {'switched to' if known else 'joined as'} {_char_name(con, body['character_id'])}", user["id"]) return {"id": lobby["id"]} @@ -507,7 +544,8 @@ def read_lobby(lid: str, user: dict = Depends(current_user)): with db() as con: lobby = _lobby_for(con, lid, user) is_dm = lobby["owner_id"] == user["id"] - rows = con.execute("""SELECT m.user_id, m.character_id, u.username, c.data FROM lobby_members m + _ensure_colors(con, lid) + rows = con.execute("""SELECT m.user_id, m.character_id, m.color, m.char_mod, u.username, c.data FROM lobby_members m JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall() members = [] @@ -515,10 +553,50 @@ def read_lobby(lid: str, user: dict = Depends(current_user)): mine = r["user_id"] == user["id"] card = _card(r["data"], rules) if r["data"] else None # The id is only given out where the viewer may open the full character (the DM sees all, a player only their own) - members.append({"username": r["username"], "me": mine, "character": card, + members.append({"username": r["username"], "me": mine, "character": card, "color": r["color"], + "mod": r["char_mod"] if (is_dm or mine) else None, "character_id": r["character_id"] if (is_dm or mine) and card else None}) + hidden = bool(lobby["diff_hidden"]) + diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden} + if hidden and not is_dm: + 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} + "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} + + +@api.put("/lobbies/{lid}/difficulty") +def set_difficulty(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): + """The DM's current difficulty: a level from the rules table or a custom modifier. It applies to every check.""" + try: + mod = int(body.get("mod", 0)) + except (TypeError, ValueError): + raise HTTPException(422, "The modifier must be a whole number") + if not -100 <= mod <= 100: + raise HTTPException(422, "The modifier must be between -100 and 100") + label = (str(body.get("label") or "").strip() or "Custom")[:24] + with db() as con: + _dm_only(_lobby_for(con, lid, user), user) + con.execute("UPDATE lobbies SET diff_mod=?, diff_label=?, diff_hidden=? WHERE id=?", (mod, label, 1 if body.get("hidden") else 0, lid)) + return {"ok": True} + + +@api.put("/lobbies/{lid}/members/{username}/mod") +def set_member_mod(lid: str, username: str, body: dict = Body(...), user: dict = Depends(current_user)): + """A personal bonus or malus of one character (talent, condition ...), added on top of the lobby difficulty.""" + try: + mod = int(body.get("mod", 0)) + except (TypeError, ValueError): + raise HTTPException(422, "The modifier must be a whole number") + if not -100 <= mod <= 100: + raise HTTPException(422, "The modifier must be between -100 and 100") + with db() as con: + _dm_only(_lobby_for(con, lid, user), user) + cur = con.execute("UPDATE lobby_members SET char_mod=? WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)", + (mod, lid, username)) + if not cur.rowcount: + raise HTTPException(404, "No such player in this lobby") + return {"ok": True} def _recipient(con, lobby, user: dict, to): @@ -536,12 +614,16 @@ def _recipient(con, lobby, user: dict, to): @api.get("/lobbies/{lid}/messages") def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)): - """Chat and event log. Whispers are only returned to the sender and the recipient (not even the DM sees them). + """Chat, event log, dice rolls and checks. Whispers are only returned to the sender and the recipient (not even the DM + sees them). Mystery rolls of the DM come without results, hidden-difficulty checks without the numbers behind them. after=0 returns the latest 100 entries, otherwise everything newer than that id (the client polls).""" with db() as con: - _lobby_for(con, lid, user) - q = """SELECT m.id, m.kind, m.text, m.created, m.user_id, m.to_user_id, f.username AS sender, t.username AS target + lobby = _lobby_for(con, lid, user) + is_dm = lobby["owner_id"] == user["id"] + q = """SELECT m.id, m.kind, m.text, m.created, m.user_id, m.to_user_id, f.username AS sender, t.username AS target, + lm.color AS color FROM lobby_messages m LEFT JOIN users f ON f.id = m.user_id LEFT JOIN users t ON t.id = m.to_user_id + LEFT JOIN lobby_members lm ON lm.lobby_id = m.lobby_id AND lm.user_id = m.user_id WHERE m.lobby_id=? AND (m.to_user_id IS NULL OR m.to_user_id=? OR m.user_id=?)""" if after > 0: rows = con.execute(q + " AND m.id>? ORDER BY m.id LIMIT 200", (lid, user["id"], user["id"], after)).fetchall() @@ -549,10 +631,22 @@ def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)): rows = list(reversed(con.execute(q + " ORDER BY m.id DESC LIMIT 100", (lid, user["id"], user["id"])).fetchall())) out = [] for r in rows: + mine = r["user_id"] == user["id"] item = {"id": r["id"], "kind": r["kind"], "text": r["text"], "time": r["created"], "from": r["sender"] or "", - "to": r["target"] or "", "mine": r["user_id"] == user["id"] and r["kind"] != "event"} + "to": r["target"] or "", "mine": mine and r["kind"] != "event", + "color": DM_COLOR if r["user_id"] == lobby["owner_id"] else PALETTE_HEX.get(r["color"], "")} if r["kind"] == "roll": - item["roll"], item["text"] = json.loads(r["text"]), "" + roll = json.loads(r["text"]) + roll["n"] = len(roll["results"]) + if roll.get("mystery") and not mine: + roll["results"] = None # players see that the DM rolled, not what + item["roll"], item["text"] = roll, "" + elif r["kind"] == "check": + chk = json.loads(r["text"]) + if chk.get("hidden") and not is_dm: + for k in ("base", "mod", "cmod", "diff", "target", "margin"): + chk.pop(k, None) + item["check"], item["text"] = chk, "" out.append(item) return out @@ -580,10 +674,32 @@ DICE = (4, 6, 8, 10, 12, 20, 100) MAX_DICE = 10 +def _manual_results(raw, sides: int, count: int): + """Results typed in by a player who rolled real dice. None when nothing was typed.""" + if raw in (None, [], ""): + return None + try: + vals = [int(v) for v in raw] + except (TypeError, ValueError): + raise HTTPException(422, "Enter whole numbers") + if len(vals) != count or any(not 1 <= v <= sides for v in vals): + raise HTTPException(422, f"Enter {count} number{'s' if count > 1 else ''} from 1 to {sides}") + return vals + + +def _post(con, lid: str, uid: int, to_id, kind: str, payload: dict) -> int: + cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)", + (lid, uid, to_id, kind, json.dumps(payload), time.time())) + con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE( + (SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES)) + return cur.lastrowid + + @api.post("/lobbies/{lid}/rolls") def roll_dice(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): """Rolls on the server (so nobody can cheat) and posts the result in the chat; with `to` it is a private roll. - Rolls by the DM are only visible to the DM, or to the person picked with `to`.""" + Players with real dice can send their own results in `manual` (marked as such in the chat). + The DM picks `mode`: hidden (only the DM sees it), mystery (players see that dice were rolled, not the result) or public.""" try: sides, count = int(body.get("sides")), int(body.get("count", 1)) except (TypeError, ValueError): @@ -592,17 +708,62 @@ def roll_dice(lid: str, body: dict = Body(...), user: dict = Depends(current_use raise HTTPException(422, f"Roll 1 to {MAX_DICE} dice of: " + ", ".join(f"d{d}" for d in DICE)) throttle(f"chat:{user['id']}", 30, 60) limiter.fail(f"chat:{user['id']}") - results = [secrets.randbelow(sides) + 1 for _ in range(count)] + manual = _manual_results(body.get("manual"), sides, count) + results = manual or [secrets.randbelow(sides) + 1 for _ in range(count)] with db() as con: lobby = _lobby_for(con, lid, user) + is_dm = lobby["owner_id"] == user["id"] to_id = _recipient(con, lobby, user, body.get("to")) - if to_id is None and lobby["owner_id"] == user["id"]: - to_id = user["id"] # the DM's rolls are hidden from the players (unless the DM picks someone to show them to) - cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)", - (lid, user["id"], to_id, "roll", json.dumps({"sides": sides, "results": results}), time.time())) - con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE( - (SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES)) - return {"id": cur.lastrowid, "sides": sides, "results": results} + mystery = False + if is_dm and to_id is None: + mode = body.get("mode") or "hidden" + if mode not in ("hidden", "mystery", "public"): + raise HTTPException(422, "Unknown roll mode") + if mode == "hidden": + to_id = user["id"] # only the DM sees it + mystery = mode == "mystery" + mid = _post(con, lid, user["id"], to_id, "roll", {"sides": sides, "results": results, "mystery": mystery, "manual": bool(manual)}) + return {"id": mid, "sides": sides, "results": results} + + +@api.post("/lobbies/{lid}/checks") +def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): + """A d100 roll-under check for a skill or attribute of the player's current lobby character. + Target = attribute + skill + the DM's difficulty + the character's personal modifier. 01-05 always succeeds, 96-100 always fails.""" + rules = load_rules() + kind, key = str(body.get("kind", "")), str(body.get("id", "")) + throttle(f"chat:{user['id']}", 30, 60) + limiter.fail(f"chat:{user['id']}") + with db() as con: + lobby = _lobby_for(con, lid, user) + member = con.execute("""SELECT m.char_mod, c.data FROM lobby_members m LEFT JOIN characters c ON c.id = m.character_id + WHERE m.lobby_id=? AND m.user_id=?""", (lid, user["id"])).fetchone() + if not member or not member["data"]: + raise HTTPException(422, "Only players with a character in this lobby can roll checks") + c = compute(json.loads(member["data"]), rules) + if kind == "skill": + s = next((x for x in c["skills"] if x["id"] == key), None) + if not s: + raise HTTPException(404, "Unknown skill") + if not s["usable"]: + raise HTTPException(422, "This skill is still locked") + what, base = s["name"], s["total"] + elif kind == "attr": + a = next((x for x in rules["attributes"] if x["id"] == key), None) + if not a: + raise HTTPException(404, "Unknown attribute") + what, base = a["name"], c["attrs"][key] + else: + raise HTTPException(422, "Roll a skill or an attribute") + manual = _manual_results(None if body.get("manual") in (None, "") else [body.get("manual")], 100, 1) + roll = manual[0] if manual else secrets.randbelow(100) + 1 + target = base + lobby["diff_mod"] + member["char_mod"] + outcome = "crit_success" if roll <= 5 else "crit_failure" if roll >= 96 else "success" if roll <= target else "failure" + 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) + return {"id": mid, "roll": roll, "outcome": outcome} @api.put("/lobbies/{lid}") @@ -639,15 +800,25 @@ def new_lobby_code(lid: str, user: dict = Depends(current_user)): @api.put("/lobbies/{lid}/me") -def switch_lobby_character(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): +def update_my_membership(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): + """Switch your character and/or pick your colour (no two players share one).""" with db() as con: _lobby_for(con, lid, user) - _own_character(con, body.get("character_id"), user) - cur = con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?", - (body["character_id"], lid, user["id"])) - if not cur.rowcount: + if not con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone(): raise HTTPException(422, "You are the DM of this lobby") - _event(con, lid, f"{user['username']} switched to {_char_name(con, body['character_id'])}", user["id"]) + if "character_id" not in body and "color" not in body: + raise HTTPException(422, "Nothing to change") + if "color" in body: + color = body["color"] + if color not in PALETTE_HEX: + raise HTTPException(422, "Unknown colour") + if con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND color=? AND user_id!=?", (lid, color, user["id"])).fetchone(): + raise HTTPException(409, "Another player already uses this colour") + con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (color, lid, user["id"])) + if "character_id" in body: + _own_character(con, body.get("character_id"), user) + con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?", (body["character_id"], lid, user["id"])) + _event(con, lid, f"{user['username']} switched to {_char_name(con, body['character_id'])}", user["id"]) return {"ok": True} diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index a245563..aec83c4 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -102,35 +102,14 @@ function LobbyList() { ) } -function Card({ m, isDm, onOpen, onKick }) { - const c = m.character - return ( -
  • -
    -
    - {c ? c.name : 'No character'} -
    {m.username}{m.me ? ' (you)' : ''}
    -
    -
    - {m.character_id && } - {isDm && } -
    -
    - {c && ( -
    - {[c.class && (c.subclass ? `${c.class} · ${c.subclass}` : c.class), c.profession].filter(Boolean).map((t) => {t})} -
    - )} - {c?.concept &&

    {c.concept}

    } -
  • - ) -} - 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!' } -// Chat, whispers, event log and dice rolls arrive through one feed that is polled while the page is open. +// 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) { const [msgs, setMsgs] = useState([]) const last = useRef(0) @@ -148,7 +127,7 @@ function useFeed(id, onEvent, onRoll) { 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').forEach((m) => cb.current.onRoll(m)) + if (wasLive) fresh.filter((m) => m.kind === 'roll' || m.kind === 'check').forEach((m) => cb.current.onRoll(m)) }, [id]) useEffect(() => { poll() @@ -159,8 +138,17 @@ function useFeed(id, onEvent, onRoll) { } const rollText = (m) => { - const n = m.roll.results.length, sum = m.roll.results.reduce((a, b) => a + b, 0) - return { dice: `${n > 1 ? n : ''}d${m.roll.sides}`, list: m.roll.results.join(' + '), sum, n } + 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 }) { @@ -185,7 +173,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) { const shown = msgs.filter((m) => events || m.kind !== 'event') const who = (m) => ( <> - {m.mine ? 'You' : m.from || '?'} + {m.mine ? 'You' : m.from || '?'} {m.to && {m.to === m.from ? '(only visible to you, the DM)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}} ) @@ -197,12 +185,21 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) { {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.n > 1 ? `${t.list} = ` : ''}{t.sum} + {t.list === null ? {Array(t.n).fill('?').join(' ')} + : {t.n > 1 ? `${t.list} = ` : ''}{t.sum}} + {m.roll.manual && own dice}
    ) } @@ -228,7 +225,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) { ) } -// ---- dice: hotbar at the bottom, results fly into a tray above it and are posted in the chat +// ---- 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: '' } @@ -241,18 +238,9 @@ function DieIcon({ sides }) { ) } -function DiceBar({ id, to, poll, isDm }) { - const [count, setCount] = useState(1) - const [busy, setBusy] = useState(false) - const [err, setErr] = useState('') - const roll = async (sides) => { - if (busy) return - setBusy(true); setErr('') - const r = await send(`/api/lobbies/${id}/rolls`, 'POST', { sides, count, to }).catch(() => null) - if (!r || !r.ok) setErr(msg(r ? await r.json().catch(() => ({})) : {}, 'Roll failed.')) - else poll() - setTimeout(() => setBusy(false), 350) - } +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 }) { return (
    {DICE.map((d) => ( - ))} - {to ? `Private: ${to}` : isDm ? 'Hidden (only you)' : 'Public'} - {err && {err}} + + {isDm && !to && ( + + {MODES.map(([k, label, tip]) => )} + + )} + {to && Private: {to}}
    ) } -function Die({ sides, value, delay }) { +// 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 ( +
    { e.preventDefault(); if (ok) onSubmit(vals.map(Number)) }}> +
    {pending.type === 'check' ? <>Your real d100 for {pending.name} : <>Your real result{n > 1 ? 's' : ''} for {n > 1 ? n : ''}d{sides}} (1–{sides})
    +
    + {vals.map((v, i) => ( + setVals(vals.map((x, j) => (j === i ? e.target.value.replace(/\D/g, '').slice(0, 3) : x)))} /> + ))} + + +
    + {err && {err}} +
    + ) +} + +function Die({ sides, value, delay, mystery }) { const [shown, setShown] = useState(reduced() ? value : null) useEffect(() => { if (reduced()) return @@ -283,24 +300,39 @@ function Die({ sides, value, delay }) { }, [sides, value, delay]) const rolling = shown !== value return ( - - {shown ?? '?'} + + {mystery && !rolling ? '?' : shown ?? '?'} ) } -function DiceTray({ rolls, me }) { +// the tray is the middle of the table: dice in the colour of whoever threw them +function DiceTray({ rolls }) { if (!rolls.length) return null return (