Lobby: 3-column layout with player area, skill checks, difficulty, colours, own dice, mystery dice

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-22 11:44:36 +00:00
parent 59df681cfa
commit 890cbfa993
5 changed files with 533 additions and 174 deletions
+15 -8
View File
@@ -83,18 +83,22 @@ Each wizard page has a **Reset this page** button that clears only that page's d
### Character sheet (A4) ### 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 ### 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 **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. - **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.
- **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. - **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.)
- **Party** (tab): member cards (name, concept, class, profession). The DM also finds the join code, rename, remove player and delete lobby here. - **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.
- **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). - **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. 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) | | 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) | | POST | `/api/lobbies/{id}/code` | New join code (DM only) |
| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` to poll) / send, optional `to` = whisper | | GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=<id>` 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 | | 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` |
| PUT / DELETE | `/api/lobbies/{id}/me` | Switch your character / leave | | 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) | | DELETE | `/api/lobbies/{id}/members/{username}` | Remove a player (DM only) |
| POST | `/api/presets` | Create a preset | | POST | `/api/presets` | Create a preset |
| PUT / DELETE | `/api/presets/{id}` | Change / delete (owner only) | | PUT / DELETE | `/api/presets/{id}` | Change / delete (owner only) |
+196 -25
View File
@@ -12,6 +12,7 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import auth from . import auth
from .derive import compute
from .export import to_docx, to_pdf from .export import to_docx, to_pdf
BASE = Path(__file__).resolve().parent.parent BASE = Path(__file__).resolve().parent.parent
@@ -26,6 +27,11 @@ MAX_LOBBIES = 20 # per DM
MAX_MEMBERS = 20 # per lobby MAX_MEMBERS = 20 # per lobby
MAX_MESSAGE = 500 # characters per chat message MAX_MESSAGE = 500 # characters per chat message
KEEP_MESSAGES = 1000 # per lobby, older ones are dropped 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 CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I
# No public API docs: everything about the API stays behind the login # 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, 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)""") 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)") 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 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})")]: 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") 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") 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): 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).""" """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', ?, ?)", 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() 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: 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") 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""", 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"]) _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"]} return {"id": lobby["id"]}
@@ -507,7 +544,8 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
with db() as con: with db() as con:
lobby = _lobby_for(con, lid, user) lobby = _lobby_for(con, lid, user)
is_dm = lobby["owner_id"] == user["id"] 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 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() WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
members = [] members = []
@@ -515,10 +553,50 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
mine = r["user_id"] == user["id"] mine = r["user_id"] == user["id"]
card = _card(r["data"], rules) if r["data"] else None 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) # 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}) "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, 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): def _recipient(con, lobby, user: dict, to):
@@ -536,12 +614,16 @@ def _recipient(con, lobby, user: dict, to):
@api.get("/lobbies/{lid}/messages") @api.get("/lobbies/{lid}/messages")
def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)): 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).""" after=0 returns the latest 100 entries, otherwise everything newer than that id (the client polls)."""
with db() as con: with db() as con:
_lobby_for(con, lid, user) lobby = _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 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 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=?)""" WHERE m.lobby_id=? AND (m.to_user_id IS NULL OR m.to_user_id=? OR m.user_id=?)"""
if after > 0: if after > 0:
rows = con.execute(q + " AND m.id>? ORDER BY m.id LIMIT 200", (lid, user["id"], user["id"], after)).fetchall() 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())) rows = list(reversed(con.execute(q + " ORDER BY m.id DESC LIMIT 100", (lid, user["id"], user["id"])).fetchall()))
out = [] out = []
for r in rows: 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 "", 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": 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) out.append(item)
return out return out
@@ -580,10 +674,32 @@ DICE = (4, 6, 8, 10, 12, 20, 100)
MAX_DICE = 10 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") @api.post("/lobbies/{lid}/rolls")
def roll_dice(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): 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 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: try:
sides, count = int(body.get("sides")), int(body.get("count", 1)) sides, count = int(body.get("sides")), int(body.get("count", 1))
except (TypeError, ValueError): 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)) 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) throttle(f"chat:{user['id']}", 30, 60)
limiter.fail(f"chat:{user['id']}") 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: with db() as con:
lobby = _lobby_for(con, lid, user) lobby = _lobby_for(con, lid, user)
is_dm = lobby["owner_id"] == user["id"]
to_id = _recipient(con, lobby, user, body.get("to")) to_id = _recipient(con, lobby, user, body.get("to"))
if to_id is None and lobby["owner_id"] == user["id"]: mystery = False
to_id = user["id"] # the DM's rolls are hidden from the players (unless the DM picks someone to show them to) if is_dm and to_id is None:
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)", mode = body.get("mode") or "hidden"
(lid, user["id"], to_id, "roll", json.dumps({"sides": sides, "results": results}), time.time())) if mode not in ("hidden", "mystery", "public"):
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE( raise HTTPException(422, "Unknown roll mode")
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES)) if mode == "hidden":
return {"id": cur.lastrowid, "sides": sides, "results": results} 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}") @api.put("/lobbies/{lid}")
@@ -639,15 +800,25 @@ def new_lobby_code(lid: str, user: dict = Depends(current_user)):
@api.put("/lobbies/{lid}/me") @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: with db() as con:
_lobby_for(con, lid, user) _lobby_for(con, lid, user)
_own_character(con, body.get("character_id"), user) if not con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone():
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:
raise HTTPException(422, "You are the DM of this lobby") 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} return {"ok": True}
+268 -132
View File
@@ -102,35 +102,14 @@ function LobbyList() {
) )
} }
function Card({ m, isDm, onOpen, onKick }) {
const c = m.character
return (
<li className={`card lobbycard ${m.me ? 'me' : ''}`}>
<div className="row">
<div>
<strong>{c ? c.name : 'No character'}</strong>
<div className="muted small">{m.username}{m.me ? ' (you)' : ''}</div>
</div>
<div className="actions">
{m.character_id && <button onClick={() => onOpen(m)}>{m.me ? 'Sheet' : 'View sheet'}</button>}
{isDm && <button className="ghost danger" onClick={() => onKick(m)}>Remove</button>}
</div>
</div>
{c && (
<div className="chips">
{[c.class && (c.subclass ? `${c.class} · ${c.subclass}` : c.class), c.profession].filter(Boolean).map((t) => <span key={t} className="chip">{t}</span>)}
</div>
)}
{c?.concept && <p className="small muted">{c.concept}</p>}
</li>
)
}
const clock = (t) => new Date(t * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) 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 DICE = [4, 6, 8, 10, 12, 20, 100]
const reduced = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches 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) { function useFeed(id, onEvent, onRoll) {
const [msgs, setMsgs] = useState([]) const [msgs, setMsgs] = useState([])
const last = useRef(0) const last = useRef(0)
@@ -148,7 +127,7 @@ function useFeed(id, onEvent, onRoll) {
last.current = fresh[fresh.length - 1].id last.current = fresh[fresh.length - 1].id
setMsgs((old) => [...old, ...fresh].slice(-300)) setMsgs((old) => [...old, ...fresh].slice(-300))
if (fresh.some((m) => m.kind === 'event')) cb.current.onEvent() 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]) }, [id])
useEffect(() => { useEffect(() => {
poll() poll()
@@ -159,8 +138,17 @@ function useFeed(id, onEvent, onRoll) {
} }
const rollText = (m) => { const rollText = (m) => {
const n = m.roll.results.length, sum = m.roll.results.reduce((a, b) => a + b, 0) const res = m.roll.results, n = m.roll.n
return { dice: `${n > 1 ? n : ''}d${m.roll.sides}`, list: m.roll.results.join(' + '), sum, 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 <strong>{c.roll}</strong> → <span className={`out ${c.outcome}`}>{out}</span></>
const parts = `${c.base}${c.mod ? ` ${sgn(c.mod)} ${c.diff}` : ''}${c.cmod ? ` ${sgn(c.cmod)} bonus` : ''}`
return <>rolled <strong>{c.roll}</strong> vs {c.target}{parts !== String(c.target) && <span className="muted small"> ({parts})</span>} →{' '}
<span className={`out ${c.outcome}`}>{out}</span> <span className="muted small">{c.margin >= 0 ? `by ${c.margin}` : `by ${-c.margin}`}</span></>
} }
function Chat({ me, people, msgs, poll, id, to, setTo }) { 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 shown = msgs.filter((m) => events || m.kind !== 'event')
const who = (m) => ( const who = (m) => (
<> <>
<strong>{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 ? '(only visible to you, the DM)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
</> </>
) )
@@ -197,12 +185,21 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
{shown.length === 0 && <p className="muted small">Nothing here yet.</p>} {shown.length === 0 && <p className="muted small">Nothing here yet.</p>}
{shown.map((m) => { {shown.map((m) => {
if (m.kind === 'event') return <div key={m.id} className="msg event"><span className="time">{clock(m.time)}</span> {m.text}</div> if (m.kind === 'event') return <div key={m.id} className="msg event"><span className="time">{clock(m.time)}</span> {m.text}</div>
if (m.kind === 'check') {
return (
<div key={m.id} className={`msg rollmsg ${m.mine ? 'mine' : ''}`}>
<span className="time">{clock(m.time)}</span> {who(m)} – <b>{m.check.what}</b>: {checkLine(m.check)}{m.check.manual && <span className="manualtag small"> own dice</span>}
</div>
)
}
if (m.kind === 'roll') { if (m.kind === 'roll') {
const t = rollText(m) const t = rollText(m)
return ( return (
<div key={m.id} className={`msg rollmsg ${m.to ? 'whisper' : ''} ${m.mine ? 'mine' : ''}`}> <div key={m.id} className={`msg rollmsg ${m.to ? 'whisper' : ''} ${m.mine ? 'mine' : ''}`}>
<span className="time">{clock(m.time)}</span> {who(m)} rolled <strong>{t.dice}</strong>:{' '} <span className="time">{clock(m.time)}</span> {who(m)} rolled <strong>{t.dice}</strong>:{' '}
<span className="rollres">{t.n > 1 ? `${t.list} = ` : ''}<b>{t.sum}</b></span> {t.list === null ? <span className="rollres mystery">{Array(t.n).fill('?').join(' ')}</span>
: <span className="rollres">{t.n > 1 ? `${t.list} = ` : ''}<b>{t.sum}</b></span>}
{m.roll.manual && <span className="manualtag small"> own dice</span>}
</div> </div>
) )
} }
@@ -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%', 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: '' } 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 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 [count, setCount] = useState(1)
const [busy, setBusy] = useState(false) function DiceBar({ to, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
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)
}
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
@@ -261,17 +249,46 @@ function DiceBar({ id, to, poll, isDm }) {
</select> </select>
</label> </label>
{DICE.map((d) => ( {DICE.map((d) => (
<button key={d} className="ghost diebtn" onClick={() => roll(d)} disabled={busy} title={`Roll ${count > 1 ? count : ''}d${d}`} aria-label={`Roll ${count} d${d}`}> <button key={d} className="ghost diebtn" onClick={() => onRoll(d)} disabled={busy} title={`Roll ${count > 1 ? count : ''}d${d}`} aria-label={`Roll ${count} d${d}`}>
<DieIcon sides={d} /><span>d{d}</span> <DieIcon sides={d} /><span>d{d}</span>
</button> </button>
))} ))}
<span className={`small dicedest ${to || isDm ? 'priv' : 'muted'}`}>{to ? `Private: ${to}` : isDm ? 'Hidden (only you)' : 'Public'}</span> <label className="small ownswitch" title="You rolled real dice: type in the result instead of rolling here">
{err && <span className="warn small">{err}</span>} <input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} style={{ width: 'auto' }} /> Own dice
</label>
{isDm && !to && (
<span className="seg small" role="group" aria-label="Who sees the DM's rolls">
{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>}
</div> </div>
) )
} }
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 (
<form className="manual card" onSubmit={(e) => { e.preventDefault(); if (ok) onSubmit(vals.map(Number)) }}>
<div className="small">{pending.type === 'check' ? <>Your real d100 for <b>{pending.name}</b></> : <>Your real result{n > 1 ? 's' : ''} for <b>{n > 1 ? n : ''}d{sides}</b></>} (1–{sides})</div>
<div className="row">
{vals.map((v, i) => (
<input key={i} autoFocus={i === 0} inputMode="numeric" value={v} aria-label={`Result ${i + 1}`} style={{ width: '4.2rem' }}
onChange={(e) => setVals(vals.map((x, j) => (j === i ? e.target.value.replace(/\D/g, '').slice(0, 3) : x)))} />
))}
<button type="submit" disabled={!ok}>Send</button>
<button type="button" className="ghost" onClick={onCancel}>Cancel</button>
</div>
{err && <span className="warn small">{err}</span>}
</form>
)
}
function Die({ sides, value, delay, mystery }) {
const [shown, setShown] = useState(reduced() ? value : null) const [shown, setShown] = useState(reduced() ? value : null)
useEffect(() => { useEffect(() => {
if (reduced()) return if (reduced()) return
@@ -283,24 +300,39 @@ function Die({ sides, value, delay }) {
}, [sides, value, delay]) }, [sides, value, delay])
const rolling = shown !== value const rolling = shown !== value
return ( return (
<span className={`die d${sides} ${rolling ? 'rolling' : 'landed'}`} style={{ animationDelay: `${delay % 200}ms` }}> <span className={`die d${sides} ${rolling ? 'rolling' : 'landed'}${mystery ? ' mystery' : ''}`} style={{ animationDelay: `${delay % 200}ms` }}>
<span>{shown ?? '?'}</span> <span>{mystery && !rolling ? '?' : shown ?? '?'}</span>
</span> </span>
) )
} }
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 if (!rolls.length) return null
return ( return (
<div className="dicetray" aria-hidden="true"> <div className="dicetray" aria-hidden="true">
{rolls.map((m) => { {rolls.map((m) => {
const style = { '--dc': m.color || 'var(--noxium)' }
if (m.kind === 'check') {
const c = m.check
return (
<div key={m.key} className="trayroll" style={style}>
<div className="small"><b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b> – {c.what}{c.manual ? ' · own dice' : ''}</div>
<div className="trayrow"><Die sides={100} value={c.roll} delay={0} />
<span className="trayout"><span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>
{c.target !== undefined && <small>{c.roll} vs {c.target}</small>}</span></div>
</div>
)
}
const t = rollText(m) const t = rollText(m)
const sec = m.roll.mystery && !m.mine
return ( return (
<div key={m.key} className="trayroll"> <div key={m.key} className="trayroll" style={sec ? { '--dc': '#8b8f85' } : style}>
<div className="small">{m.mine ? 'You' : m.from} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}</div> <div className="small"><b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b> rolled <strong>{t.dice}</strong>
{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}</div>
<div className="trayrow"> <div className="trayrow">
{m.roll.results.map((v, i) => <Die key={i} sides={m.roll.sides} value={v} delay={i * 120} />)} {Array.from({ length: t.n }, (_, i) => <Die key={i} sides={m.roll.sides} value={m.roll.results ? m.roll.results[i] : 0} delay={i * 120} mystery={!m.roll.results} />)}
{t.n > 1 && <span className="traytotal">= {t.sum}</span>} {t.n > 1 && t.sum !== null && <span className="traytotal">= {t.sum}</span>}
</div> </div>
</div> </div>
) )
@@ -309,8 +341,60 @@ function DiceTray({ rolls, me }) {
) )
} }
// ---- character bar: one card per player, later also for initiative and enemies
function CharBar({ lobby, sel, onSel }) {
return (
<div className="charbar" role="list">
{lobby.members.map((m) => {
const c = m.character, can = !!m.character_id
return (
<div key={m.username} role="listitem" className={`pcard${sel === m.username ? ' sel' : ''}${can ? ' can' : ''}`} style={{ '--pc': m.color || 'var(--line)' }}
onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}>
<span className="pavatar">{(c?.name || m.username).slice(0, 1).toUpperCase()}</span>
<span className="pinfo">
<b>{c ? c.name : 'No character'}</b>
<span className="muted small">{m.username}{m.me ? ' (you)' : ''}{c?.class ? ` · ${c.class}` : ''}</span>
</span>
{lobby.is_dm
? <input type="number" className="modin" key={`${m.username}:${m.mod}`} defaultValue={m.mod || 0} min={-100} max={100} title={`Bonus / malus for ${m.username}`} aria-label={`Modifier for ${m.username}`}
onClick={(e) => e.stopPropagation()} onBlur={(e) => lobby.setMod(m.username, e.target.value)} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} />
: m.mod ? <span className={`modchip ${m.mod > 0 ? 'up' : 'down'}`} title="Your personal bonus / malus">{sgn(m.mod)}</span> : null}
</div>
)
})}
{lobby.members.length === 0 && <span className="muted small">Nobody has joined yet – share the join code (⚙ Lobby).</span>}
</div>
)
}
// ---- the DM sets the difficulty for all checks, from the rules table or a custom number
function DifficultyBar({ lobby, onSet }) {
const d = lobby.difficulty
const idx = lobby.levels.findIndex((l) => l.name === d.label && l.mod === d.mod)
const [custom, setCustom] = useState('')
if (!lobby.is_dm) {
return <div className="diffbar small muted">{d.hidden ? 'The DM sets the difficulty' : <>Difficulty: <b>{d.label}</b> {sgn(d.mod)}</>}</div>
}
return (
<div className="diffbar">
<label className="small">Difficulty
<select value={idx >= 0 ? idx : 'custom'} aria-label="Difficulty"
onChange={(e) => { const l = lobby.levels[e.target.value]; if (l) onSet({ label: l.name, mod: l.mod, hidden: d.hidden }) }}>
{lobby.levels.map((l, i) => <option key={l.name} value={i}>{l.name} ({sgn(l.mod)})</option>)}
{idx < 0 && <option value="custom">{d.label} ({sgn(d.mod)})</option>}
</select>
</label>
<form className="row" onSubmit={(e) => { e.preventDefault(); if (/^[-+−]?\d+$/.test(custom.trim())) { onSet({ label: 'Custom', mod: parseInt(custom.replace('−', '-'), 10), hidden: d.hidden }); setCustom('') } }}>
<input value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="custom ±N" aria-label="Custom modifier" style={{ width: '6rem' }} />
<button type="submit" className="ghost" disabled={!custom.trim()}>Set</button>
</form>
<label className="small"><input type="checkbox" checked={d.hidden} onChange={(e) => onSet({ label: d.label, mod: d.mod, hidden: e.target.checked })} style={{ width: 'auto' }} /> Hide numbers from players</label>
</div>
)
}
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves // ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
function CharTab({ cid, owner, rules, onOpen, name }) { function CharTab({ cid, rules, onRoll }) {
const [data, setData] = useState(null) const [data, setData] = useState(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
useEffect(() => { useEffect(() => {
@@ -323,32 +407,100 @@ function CharTab({ cid, owner, rules, onOpen, name }) {
return ( return (
<div> <div>
{err && <p className="warn">{err}</p>} {err && <p className="warn">{err}</p>}
{data && <PaperSheet data={data} baseRules={rules} bare />} {data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} />}
</div> </div>
) )
} }
// ---- one lobby: member cards and one sheet tab per visible character on the left, chat on the right, dice at the bottom // lobby settings: join code, rename, colour, character switch, members, leave / delete
function LobbyView({ id, rules, user, onOpen }) { function Settings({ lobby, id, mine, act, setInfo, onClose }) {
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>
{lobby.is_dm && (
<>
<div className="row">
<div>
<div className="muted small">Join code – give it to your players</div>
<strong className="joincode">{lobby.code}</strong>
</div>
<div className="actions">
<button className="ghost" onClick={() => navigator.clipboard?.writeText(lobby.code).then(() => setInfo('Code copied.')).catch(() => {})}>Copy</button>
<button className="ghost" onClick={() => window.confirm('The old code stops working. Current players stay in the lobby.') && act(`/api/lobbies/${id}/code`, 'POST')}>New code</button>
</div>
</div>
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (rename.trim()) act(`/api/lobbies/${id}`, 'PUT', { name: rename }, () => { setRename(''); act() }) }}>
<input value={rename} onChange={(e) => setRename(e.target.value)} placeholder="Rename lobby" aria-label="New lobby name" maxLength={60} />
<button type="submit" className="ghost" disabled={!rename.trim()}>Rename</button>
</form>
<h3>Players</h3>
{lobby.members.length === 0 && <p className="muted small">Nobody yet.</p>}
{lobby.members.map((m) => (
<div key={m.username} className="row">
<span><span className="swatch" style={{ background: m.color }} /> {m.username} <span className="muted small">{m.character?.name}</span></span>
<button className="ghost danger" onClick={() => window.confirm(`Remove ${m.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(m.username)}`, 'DELETE')}>Remove</button>
</div>
))}
</>
)}
{!lobby.is_dm && me && (
<>
<h3>Your colour</h3>
<div className="swatches">
{lobby.palette.map((p) => (
<button key={p.id} className={`swatchbtn${me.color === p.id ? ' on' : ''}`} style={{ background: p.hex }} disabled={taken.has(p.id)} title={taken.has(p.id) ? `${p.id} (taken)` : p.id}
aria-label={p.id} onClick={() => act(`/api/lobbies/${id}/me`, 'PUT', { color: p.id })} />
))}
</div>
<h3>Your character</h3>
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (pick) act(`/api/lobbies/${id}/me`, 'PUT', { character_id: pick }, () => { setPick(''); act() }) }}>
<CharSelect mine={mine} value={pick} onChange={setPick} label="Switch character" />
<button type="submit" className="ghost" disabled={!pick}>Switch</button>
</form>
<p className="small muted">The DM always sees the current saved version of your character. Of the others you only see a short card.</p>
</>
)}
<div className="actions wrap">
{lobby.is_dm
? <button className="ghost danger" onClick={() => window.confirm(`Delete the lobby "${lobby.name}" for everyone? Characters are not deleted.`) && act(`/api/lobbies/${id}`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Delete lobby</button>
: <button className="ghost danger" onClick={() => window.confirm('Leave this lobby?') && act(`/api/lobbies/${id}/me`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Leave lobby</button>}
</div>
</div>
)
}
// ---- one lobby: sheet on the left, player area (character bar, difficulty, dice table) in the middle, chat on the right
function LobbyView({ id, rules, user }) {
const mine = useMine() const mine = useMine()
const [lobby, setLobby] = useState(null) const [lobby, setLobby] = useState(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [info, setInfo] = useState('') const [info, setInfo] = useState('')
const [pick, setPick] = useState('') const [sel, setSel] = useState('')
const [rename, setRename] = useState('')
const [tab, setTab] = useState('party')
const [to, setTo] = useState('') // whisper recipient, used for chat messages and dice rolls const [to, setTo] = useState('') // whisper recipient, used for chat messages and dice rolls
const [tray, setTray] = useState([]) const [tray, setTray] = useState([])
const [count, setCount] = useState(1)
const [mode, setMode] = useState('mystery')
const [own, setOwnState] = useState(() => store('aw_owndice') === '1')
const [pending, setPending] = useState(null)
const [busy, setBusy] = useState(false)
const [mErr, setMErr] = useState('')
const [showSettings, setShowSettings] = useState(false)
const [sheetOpen, setSheetOpen] = useState(() => store('aw_sheetopen') !== '0')
const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '0') }
const load = () => fetch(`/api/lobbies/${id}`).then(async (r) => { const load = useCallback(() => fetch(`/api/lobbies/${id}`).then(async (r) => {
if (!r.ok) throw new Error(r.status) if (!r.ok) throw new Error(r.status)
setLobby(await r.json()) setLobby(await r.json())
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') }) }).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') }), [id])
useEffect(() => { useEffect(() => {
load() load()
const t = setInterval(() => !document.hidden && load(), 15000) // picks up joins and character changes of the others const t = setInterval(() => !document.hidden && load(), 5000) // picks up joins, colours, difficulty and character changes
return () => clearInterval(t) return () => clearInterval(t)
}, [id]) // eslint-disable-line }, [load])
const onRoll = useCallback((m) => { const onRoll = useCallback((m) => {
const key = `${m.id}` const key = `${m.id}`
@@ -358,6 +510,7 @@ function LobbyView({ id, rules, user, onOpen }) {
const feed = useFeed(id, load, onRoll) const feed = useFeed(id, load, onRoll)
const act = async (url, method, body, done) => { const act = async (url, method, body, done) => {
if (!url) return load()
setErr(''); setInfo('') setErr(''); setInfo('')
const r = await send(url, method, body).catch(() => null) const r = await send(url, method, body).catch(() => null)
const d = r ? await r.json().catch(() => ({})) : {} const d = r ? await r.json().catch(() => ({})) : {}
@@ -369,11 +522,34 @@ function LobbyView({ id, rules, user, onOpen }) {
if (!lobby) return <section className="page lobbypage"><p className="muted">Loading …</p></section> if (!lobby) return <section className="page lobbypage"><p className="muted">Loading …</p></section>
const me = lobby.members.find((m) => m.me) const me = lobby.members.find((m) => m.me)
const open = (m) => onOpen({ id: m.character_id, name: m.character?.name, ...(m.me ? {} : { owner: m.username }) })
const people = [lobby.dm, ...lobby.members.map((m) => m.username)] const people = [lobby.dm, ...lobby.members.map((m) => m.username)]
const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open
const active = tab.startsWith('sheet:') && !sheets.find((m) => m.username === tab.slice(6)) ? 'party' : tab const cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0]
const dest = people.includes(to) ? to : '' const dest = people.includes(to) ? to : ''
const canCheck = !!(me && me.character_id)
lobby.setMod = (u, v) => act(`/api/lobbies/${id}/members/${encodeURIComponent(u)}/mod`, 'PUT', { mod: parseInt(v, 10) || 0 })
const post = async (url, body, after) => {
setBusy(true); setMErr('')
const r = await send(url, 'POST', body).catch(() => null)
if (!r || !r.ok) setMErr(msg(r ? await r.json().catch(() => ({})) : {}, 'That did not work.'))
else { after?.(); feed.poll() }
setTimeout(() => setBusy(false), 350)
}
const throwDice = (sides) => {
if (busy) return
if (own) return setPending({ type: 'dice', sides, count })
post(`/api/lobbies/${id}/rolls`, { sides, count, to: dest, ...(lobby.is_dm ? { mode } : {}) })
}
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 })
}
const submitManual = (vals) => {
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0] }, () => setPending(null))
else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null))
}
return ( return (
<section className="page lobbypage"> <section className="page lobbypage">
@@ -381,83 +557,43 @@ function LobbyView({ id, rules, user, onOpen }) {
<a className="small" href="#lobbies">← Lobbies</a> <a className="small" href="#lobbies">← Lobbies</a>
<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">
<button className="ghost" onClick={() => setShowSettings((v) => !v)} aria-expanded={showSettings}>⚙ Lobby</button>
</span>
</div> </div>
{err && <p className="warn">{err}</p>} {err && <p className="warn">{err}</p>}
{info && <p className="small">{info}</p>} {info && <p className="small">{info}</p>}
<div className="lobbygrid"> <div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`}>
<div className="lobbymain"> {sheetOpen && (
<div className="tabs" role="tablist"> <div className="sheetcol">
<button role="tab" aria-selected={active === 'party'} className={active === 'party' ? 'on' : ''} onClick={() => setTab('party')}>Party ({lobby.members.length})</button> <div className="sheetbar">
{sheets.map((m) => ( <b>{cur ? cur.character.name : 'Character sheet'}</b>
<button key={m.username} role="tab" aria-selected={active === `sheet:${m.username}`} className={active === `sheet:${m.username}` ? 'on' : ''} <span className="tabright"><ThemeSwitch /> <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide the sheet">◀</button></span>
onClick={() => setTab(`sheet:${m.username}`)} title={m.username}>{m.me && !lobby.is_dm ? 'My character' : m.character.name}</button> </div>
))} {cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} onRoll={cur.me && canCheck ? check : undefined} />
{active.startsWith('sheet:') && <span className="tabright"><ThemeSwitch /></span>} : <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>
)}
{active.startsWith('sheet:') && (() => { <div className="playarea">
const m = sheets.find((x) => x.username === active.slice(6)) {!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
return <CharTab key={m.character_id} cid={m.character_id} owner={m.me ? '' : m.username} name={m.character.name} rules={rules} onOpen={() => open(m)} /> <CharBar lobby={lobby} sel={cur?.username} onSel={setSel} />
})()} <DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
<div className="table">
{active === 'party' && ( {showSettings && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
<> <DiceTray rolls={tray} />
{lobby.is_dm && ( {canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
<div className="card"> {pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
<div className="row"> {!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
<div> </div>
<div className="muted small">Join code – give it to your players</div> <DiceBar to={dest} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
<strong className="joincode">{lobby.code}</strong>
</div>
<div className="actions">
<button className="ghost" onClick={() => navigator.clipboard?.writeText(lobby.code).then(() => setInfo('Code copied.')).catch(() => {})}>Copy</button>
<button className="ghost" onClick={() => window.confirm('The old code stops working. Current players stay in the lobby.') && act(`/api/lobbies/${id}/code`, 'POST', undefined, (d) => setLobby({ ...lobby, code: d.code }))}>New code</button>
</div>
</div>
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (rename.trim()) act(`/api/lobbies/${id}`, 'PUT', { name: rename }, () => { setRename(''); load() }) }}>
<input value={rename} onChange={(e) => setRename(e.target.value)} placeholder="Rename lobby" aria-label="New lobby name" maxLength={60} />
<button type="submit" className="ghost" disabled={!rename.trim()}>Rename</button>
</form>
</div>
)}
{lobby.members.length === 0 && <p className="card">Nobody has joined yet. Share the code above.</p>}
<ul className="charlist partygrid">
{lobby.members.map((m) => (
<Card key={m.username} m={m} isDm={lobby.is_dm} onOpen={(x) => setTab(`sheet:${x.username}`)}
onKick={(x) => window.confirm(`Remove ${x.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(x.username)}`, 'DELETE')} />
))}
</ul>
{!lobby.is_dm && <p className="small muted">You see your own character in full. Of the others you only see this short card.</p>}
{!lobby.is_dm && me && (
<div className="card">
<h2>Your character</h2>
<form className="row wrapform" onSubmit={(e) => { e.preventDefault(); if (pick) act(`/api/lobbies/${id}/me`, 'PUT', { character_id: pick }, () => { setPick(''); load() }) }}>
<CharSelect mine={mine} value={pick} onChange={setPick} label="Switch character" />
<button type="submit" className="ghost" disabled={!pick}>Switch</button>
</form>
<p className="small muted">The DM always sees the current saved version of your character.</p>
</div>
)}
<div className="actions wrap">
{lobby.is_dm
? <button className="ghost danger" onClick={() => window.confirm(`Delete the lobby "${lobby.name}" for everyone? Characters are not deleted.`) && act(`/api/lobbies/${id}`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Delete lobby</button>
: <button className="ghost danger" onClick={() => window.confirm('Leave this lobby?') && act(`/api/lobbies/${id}/me`, 'DELETE', undefined, () => { window.location.hash = 'lobbies' })}>Leave lobby</button>}
</div>
</>
)}
</div> </div>
<aside className="lobbychat card" aria-label="Chat"> <aside className="lobbychat card" aria-label="Chat">
<Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} /> <Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} />
</aside> </aside>
</div> </div>
<DiceTray rolls={tray} me={user} />
<DiceBar id={id} to={dest} poll={feed.poll} isDm={lobby.is_dm} />
</section> </section>
) )
} }
+8 -3
View File
@@ -68,7 +68,7 @@ const Pips = ({ n, locked }) => (
) )
// The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes). // The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes).
export default function PaperSheet({ data, baseRules, tools, bare }) { export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules]) const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch]) const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
const c = useMemo(() => compute(ch, rules), [ch, rules]) const c = useMemo(() => compute(ch, rules), [ch, rules])
@@ -86,6 +86,11 @@ export default function PaperSheet({ data, baseRules, tools, bare }) {
const gear = kitItems(ch, rules) const gear = kitItems(ch, rules)
const name = ch.name || 'Nameless' const name = ch.name || 'Nameless'
const sign = (n) => (n > 0 ? `+${n}` : `${n}`) const sign = (n) => (n > 0 ? `+${n}` : `${n}`)
// With onRoll (own character in a lobby) attributes and usable skills can be clicked to roll a check
const roll = (kind, id, label, ok = true) => (onRoll && ok ? {
className: 'rollable', role: 'button', tabIndex: 0,
onClick: () => onRoll(kind, id, label), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRoll(kind, id, label) } },
} : {})
const Field = ({ label, children }) => <div className="pfield"><span>{label}</span><b>{children || '–'}</b></div> const Field = ({ label, children }) => <div className="pfield"><span>{label}</span><b>{children || '–'}</b></div>
const Foot = ({ n }) => <div className="pfoot"><span>Arcane Wasteland</span><span>{name}</span><span>Page {n} / 2</span></div> const Foot = ({ n }) => <div className="pfoot"><span>Arcane Wasteland</span><span>{name}</span><span>Page {n} / 2</span></div>
@@ -117,7 +122,7 @@ export default function PaperSheet({ data, baseRules, tools, bare }) {
<div className="pattrs"> <div className="pattrs">
{rules.attributes.map((a) => ( {rules.attributes.map((a) => (
<div key={a.id} className="pattr"> <div key={a.id} {...roll('attr', a.id, a.name)} className={`pattr${onRoll ? ' rollable' : ''}`}>
<Tip text={explainAttr(a, x)}><span className="pattr-n">{a.name}</span></Tip> <Tip text={explainAttr(a, x)}><span className="pattr-n">{a.name}</span></Tip>
<b className="pattr-v">{c.attrs[a.id]}</b> <b className="pattr-v">{c.attrs[a.id]}</b>
<span className="pattr-m">Mod {sign(c.mods[a.id])}</span> <span className="pattr-m">Mod {sign(c.mods[a.id])}</span>
@@ -140,7 +145,7 @@ export default function PaperSheet({ data, baseRules, tools, bare }) {
<div key={g.id} className="pgroup"> <div key={g.id} className="pgroup">
<h4>{g.name}</h4> <h4>{g.name}</h4>
{c.skills.filter((s) => s.group === g.id).map((s) => ( {c.skills.filter((s) => s.group === g.id).map((s) => (
<div key={s.id} className={`prow ${s.usable ? '' : 'locked'}`}> <div key={s.id} {...roll('skill', s.id, s.name, s.usable)} className={`prow ${s.usable ? '' : 'locked'}${onRoll && s.usable ? ' rollable' : ''}`}>
<Pips n={s.pips} locked={!s.usable} /> <Pips n={s.pips} locked={!s.usable} />
<Tip text={explainSkill(s, x)}><span className="pn">{s.name}{s.type === 'advanced' && <em> adv</em>}</span></Tip> <Tip text={explainSkill(s, x)}><span className="pn">{s.name}{s.type === 'advanced' && <em> adv</em>}</span></Tip>
<span className="pa">{short(s)}</span> <span className="pa">{short(s)}</span>
+46 -6
View File
@@ -208,7 +208,7 @@ h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
/* lobby desktop layout: main area left, chat column fixed at the right edge of the window, dice bar bottom centre of the main area */ /* lobby desktop layout: main area left, chat column fixed at the right edge of the window, dice bar bottom centre of the main area */
:root { --chatw: 380px; --hdr: 3.7rem; } :root { --chatw: 380px; --hdr: 3.7rem; }
body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(--chatw) + 1.5rem); } body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(--chatw) + 1.5rem); }
.lobbypage { max-width: 1200px; margin: 0; padding-bottom: 8rem; } .lobbypage { max-width: none; margin: 0; padding: 0; }
.lobbychat { position: fixed; right: 0; top: var(--hdr); bottom: 0; width: var(--chatw); z-index: 10; border-radius: 0; border-width: 0 0 0 1px; padding: .8rem; display: flex; flex-direction: column; overflow: hidden; } .lobbychat { position: fixed; right: 0; top: var(--hdr); bottom: 0; width: var(--chatw); z-index: 10; border-radius: 0; border-width: 0 0 0 1px; padding: .8rem; display: flex; flex-direction: column; overflow: hidden; }
.lobbychat .chatwrap { flex: 1; min-height: 0; grid-template-rows: minmax(0, 1fr) auto auto auto auto; } .lobbychat .chatwrap { flex: 1; min-height: 0; grid-template-rows: minmax(0, 1fr) auto auto auto auto; }
.lobbychat .chatbox { height: auto; min-height: 0; } .lobbychat .chatbox { height: auto; min-height: 0; }
@@ -220,19 +220,16 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
.whispertag { color: #8a63c7; } .whispertag { color: #8a63c7; }
.dicebar { position: fixed; bottom: 1rem; left: calc((100vw - var(--chatw)) / 2); transform: translateX(-50%); z-index: 30; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; justify-content: center;
max-width: calc(100vw - var(--chatw) - 1.5rem); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: .4rem .8rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .22); }
.dicecount { display: flex; align-items: center; gap: .3rem; margin-right: .3rem; } .dicecount { display: flex; align-items: center; gap: .3rem; margin-right: .3rem; }
.dicecount select { width: auto; padding: .25rem .4rem; } .dicecount select { width: auto; padding: .25rem .4rem; }
.diebtn { display: flex; flex-direction: column; align-items: center; gap: 0; padding: .2rem .5rem; border-radius: 12px; min-width: 3.1rem; font-size: .75rem; line-height: 1.1; } .diebtn { display: flex; flex-direction: column; align-items: center; gap: 0; padding: .2rem .5rem; border-radius: 12px; min-width: 3.1rem; font-size: .75rem; line-height: 1.1; }
.diebtn:hover:not(:disabled) { border-color: var(--noxium); color: var(--noxium); } .diebtn:hover:not(:disabled) { border-color: var(--noxium); color: var(--noxium); }
.dicedest { margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); } .dicedest { margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
.dicedest.priv { color: #8a63c7; font-weight: 600; } .dicedest.priv { color: #8a63c7; font-weight: 600; }
.dicetray { position: fixed; bottom: 5.4rem; left: calc((100vw - var(--chatw)) / 2); transform: translateX(-50%); z-index: 29; display: grid; gap: .5rem; justify-items: center; pointer-events: none; }
.trayroll { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: .5rem .9rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .25); animation: trayin .25s ease-out; } .trayroll { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: .5rem .9rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .25); animation: trayin .25s ease-out; }
.trayrow { display: flex; align-items: center; gap: .5rem; margin-top: .3rem; flex-wrap: wrap; justify-content: center; max-width: 30rem; } .trayrow { display: flex; align-items: center; gap: .5rem; margin-top: .3rem; flex-wrap: wrap; justify-content: center; max-width: 30rem; }
.traytotal { font-weight: 700; font-size: 1.2rem; margin-left: .3rem; } .traytotal { font-weight: 700; font-size: 1.2rem; margin-left: .3rem; }
.die { display: inline-flex; align-items: center; justify-content: center; width: 2.9rem; height: 2.9rem; background: var(--noxium); color: #12150f; font-weight: 800; font-variant-numeric: tabular-nums; } .die { display: inline-flex; align-items: center; justify-content: center; width: 2.9rem; height: 2.9rem; background: var(--dc, var(--noxium)); color: #12150f; font-weight: 800; font-variant-numeric: tabular-nums; }
.die.d4 { clip-path: polygon(50% 4%, 96% 92%, 4% 92%); padding-top: .55rem; } .die.d4 { clip-path: polygon(50% 4%, 96% 92%, 4% 92%); padding-top: .55rem; }
.die.d6 { border-radius: 12px; } .die.d6 { border-radius: 12px; }
.die.d8 { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); } .die.d8 { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
@@ -247,7 +244,6 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
@keyframes trayin { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } } @keyframes trayin { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .die.rolling, .die.landed, .trayroll { animation: none; } } @media (prefers-reduced-motion: reduce) { .die.rolling, .die.landed, .trayroll { animation: none; } }
@media (max-width: 1050px) { body:has(.lobbychat) main { max-width: 1280px; margin: 0 auto; padding-right: 1rem; } .lobbychat { position: static; width: auto; height: 28rem; border-width: 1px; border-radius: var(--radius); margin-top: 1rem; } .dicebar, .dicetray { left: 50%; } .dicebar { max-width: calc(100vw - 1.5rem); } }
.lobbycard { align-content: start; } .lobbycard { align-content: start; }
.lobbycard .chips { align-items: flex-start; align-content: flex-start; } .lobbycard .chips { align-items: flex-start; align-content: flex-start; }
.lobbycard .chip { align-self: flex-start; } .lobbycard .chip { align-self: flex-start; }
@@ -335,3 +331,47 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
.tabs .tabright button.on { border-color: var(--noxium); } .tabs .tabright button.on { border-color: var(--noxium); }
.tabs .tabright button:first-child { border-radius: var(--radius) 0 0 var(--radius); } .tabs .tabright button:first-child { border-radius: var(--radius) 0 0 var(--radius); }
.tabs .tabright button:last-child { border-radius: 0 var(--radius) var(--radius) 0; } .tabs .tabright button:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
/* lobby: three columns - sheet | player area | chat (fixed at the right edge) */
body:has(.lobbychat) main { padding: .5rem calc(var(--chatw) + .5rem) 0 .5rem; }
.lobbyhead { margin: 0 0 .4rem; } .lobbyhead .tabright { margin-left: auto; }
.lobbygrid { display: grid; grid-template-columns: clamp(480px, 42vw, 800px) minmax(0, 1fr); gap: .8rem; align-items: start; }
.lobbygrid.nosheet { grid-template-columns: minmax(0, 1fr); }
.sheetcol { position: sticky; top: calc(var(--hdr) + .4rem); max-height: calc(100vh - var(--hdr) - 3.2rem); overflow-y: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); padding: .5rem; }
.sheetbar { display: flex; align-items: center; gap: .5rem; margin-bottom: .4rem; } .sheetbar .tabright { margin-left: auto; display: flex; gap: .4rem; align-items: center; }
.sheetbar .seg button { padding: .2rem .6rem; }
.playarea { display: flex; flex-direction: column; gap: .6rem; min-width: 0; height: calc(100vh - var(--hdr) - 3.2rem); position: sticky; top: calc(var(--hdr) + .4rem); }
.showsheet { align-self: flex-start; }
.charbar { display: flex; gap: .5rem; flex-wrap: wrap; }
.pcard { display: flex; align-items: center; gap: .55rem; padding: .35rem .6rem; border: 2px solid var(--line); border-left: 6px solid var(--pc); border-radius: var(--radius); background: var(--panel); min-width: 11rem; }
.pcard.can { cursor: pointer; } .pcard.can:hover { background: var(--panel2); }
.pcard.sel { border-color: var(--pc); background: var(--panel2); box-shadow: 0 0 0 1px var(--pc); }
.pavatar { width: 2.2rem; height: 2.2rem; border-radius: 50%; background: var(--pc); color: #12150f; font-weight: 800; display: grid; place-items: center; flex: none; }
.pinfo { display: grid; line-height: 1.2; min-width: 0; } .pinfo b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.modin { width: 4rem; padding: .2rem .3rem; margin-left: auto; text-align: center; }
.modchip { margin-left: auto; padding: .05rem .5rem; border-radius: 999px; font-weight: 700; border: 1px solid var(--line); } .modchip.up { color: var(--noxium); } .modchip.down { color: var(--danger); }
.diffbar { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; padding: .3rem .6rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
.diffbar label { display: flex; align-items: center; gap: .4rem; } .diffbar select { width: auto; padding: .25rem .4rem; }
.table { position: relative; flex: 1; min-height: 14rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto;
background: radial-gradient(ellipse at center, color-mix(in srgb, var(--noxium) 10%, var(--panel)) 0%, var(--panel) 75%); }
.tablehint { position: absolute; top: .6rem; left: 0; right: 0; text-align: center; margin: 0; }
.tableerr { position: absolute; bottom: .5rem; left: 0; right: 0; text-align: center; }
.dicetray { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 3; display: grid; gap: .6rem; justify-items: center; pointer-events: none; max-width: 95%; }
.trayroll { border-top: 3px solid var(--dc, var(--noxium)); }
.trayout { display: grid; line-height: 1.2; margin-left: .5rem; } .trayout .out { font-weight: 800; }
.die.mystery { filter: grayscale(.4); }
.dicebar { display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; justify-content: center; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: .4rem .8rem; align-self: center; max-width: 100%; }
.ownswitch { display: flex; align-items: center; gap: .3rem; margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
.dicebar .seg button { padding: .2rem .6rem; }
.manual { position: absolute; left: 50%; top: 30%; transform: translateX(-50%); z-index: 4; display: grid; gap: .4rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .35); }
.settings { position: absolute; inset: .6rem; z-index: 6; overflow: auto; align-content: start; display: grid; gap: .5rem; max-width: 34rem; margin: 0 auto; height: fit-content; max-height: calc(100% - 1.2rem); }
.swatches { display: flex; gap: .4rem; flex-wrap: wrap; } .swatchbtn { width: 2rem; height: 2rem; border-radius: 50%; padding: 0; border: 2px solid var(--line); } .swatchbtn.on { border-color: var(--text); box-shadow: 0 0 0 2px var(--panel), 0 0 0 4px var(--text); } .swatchbtn:disabled { opacity: .25; cursor: not-allowed; }
.swatch { display: inline-block; width: .8rem; height: .8rem; border-radius: 50%; vertical-align: middle; }
.out.crit_success { color: var(--noxium); font-weight: 800; } .out.success { color: var(--noxium); } .out.failure { color: var(--danger); } .out.crit_failure { color: var(--danger); font-weight: 800; }
.manualtag { color: var(--diesium); } .rollres.mystery { letter-spacing: .15em; font-weight: 700; color: var(--muted); }
.papers .rollable { cursor: pointer; } .papers .rollable:hover { outline: 1.5px solid var(--pg); outline-offset: 1px; background: color-mix(in srgb, var(--pg) 12%, transparent); }
@media (max-width: 1100px) {
body:has(.lobbychat) main { padding: .5rem; }
.lobbychat { position: static; width: auto; height: 28rem; border-width: 1px; border-radius: var(--radius); margin-top: 1rem; }
.lobbygrid { grid-template-columns: minmax(0, 1fr); } .playarea { order: -1; height: 32rem; position: static; } .sheetcol { position: static; max-height: none; }
}