Compare commits

..
12 Commits
Author SHA1 Message Date
Claude 3e67aba75c Lobby: dice are thrown onto the table and rest at random spots; fix duplicate dice for the roller
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-22 13:20:55 +00:00
Claude 890cbfa993 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
2026-10-22 11:44:36 +00:00
Claude 59df681cfa Lobby: sheet theme switch in the tab bar, remove extra toolbar
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-22 11:12:32 +00:00
Claude 7e69b68b37 Revert "Lobby: CSS 3D dice tray at the top right next to the chat"
Back to the card-style dice tray above the hotbar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 18:13:56 +00:00
Claude d730022447 Lobby: CSS 3D dice tray at the top right next to the chat
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 18:07:07 +00:00
Claude 968d168ff1 Sheet: light/dark switch, slimmer lobby header, remove wide view
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 17:24:48 +00:00
Claude 8711b7fe0e A4 character sheet (summary and lobby), A4/wide switch, print to PDF
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 17:09:41 +00:00
Claude 69e53c1520 Lobby: chat at the right window edge, DM rolls hidden from players
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 17:00:02 +00:00
Claude a88b8d2fa3 Lobby: desktop layout with chat column, dice hotbar and shared dice tray, server-side rolls
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 16:51:16 +00:00
Claude 94d3505b82 Lobby: tabs, live chat with whispers, event log, embedded read-only character sheets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 16:38:28 +00:00
Claude aad8e0515b Add lobbies: DM sees all characters, players only a short card of others
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 16:23:36 +00:00
Claude e8584e043d Make host port configurable via AW_PORT (default 8090, AMP uses 8080)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-21 12:38:03 +00:00
11 changed files with 1561 additions and 52 deletions
+2
View File
@@ -3,3 +3,5 @@
AW_REGISTRATION_CODE=
# 1 when running behind a reverse proxy that sets X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host:
AW_TRUST_PROXY=0
# Port on the host (bound to 127.0.0.1 only). nginx proxies to it. Pick a free one (check: ss -tlnp | grep :8090):
AW_PORT=8090
+4 -4
View File
@@ -48,10 +48,10 @@ cp .env.example .env
nano .env # set AW_REGISTRATION_CODE, and AW_TRUST_PROXY=1 (nginx is in front)
chmod 600 .env
docker compose up -d --build
curl -sI http://127.0.0.1:8080 | head -1 # should print HTTP/1.1 200 OK
curl -sI http://127.0.0.1:8090 | head -1 # should print HTTP/1.1 200 OK
```
The container only listens on `127.0.0.1:8080`, so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`.
The container only listens on `127.0.0.1:8090` (change it with `AW_PORT` in `.env` if the port is taken, e.g. AMP uses 8080), so it is not reachable from outside until nginx forwards to it. The database is `./db/aw.db`.
## 4. nginx + HTTPS
@@ -65,7 +65,7 @@ server {
client_max_body_size 1m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_pass http://127.0.0.1:8090; # same as AW_PORT in .env
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, so clients cannot fake their address
proxy_set_header X-Forwarded-Proto $scheme;
@@ -82,7 +82,7 @@ sudo certbot --nginx -d aw.yourdomain.tld # gets the certificate, adds
Then add inside the HTTPS `server` block: `add_header Strict-Transport-Security "max-age=31536000" always;`
Firewall: only SSH, 80 and 443 should be open. Port 8080 is not published to the outside at all.
Firewall: only SSH, 80 and 443 should be open. The app port (AW_PORT, default 8090) is not published to the outside at all.
Open the site, choose "Create account" and register with your registration code.
+34 -2
View File
@@ -12,7 +12,7 @@ Website for creating characters for the Arcane Wasteland TTRPG, with a searchabl
docker compose up -d --build
```
Then open `http://localhost:8080` (the port is bound to the machine itself; see **DEPLOY.md** for running it on a server with its own user, nginx and HTTPS). Users, characters and presets are stored in `./db/aw.db`.
Then open `http://localhost:8090` (the port is bound to the machine itself; see **DEPLOY.md** for running it on a server with its own user, nginx and HTTPS). Users, characters and presets are stored in `./db/aw.db`.
### Login and accounts
@@ -81,6 +81,27 @@ While creating, the full character sheet stays visible next to the wizard. Edits
Each wizard page has a **Reset this page** button that clears only that page's data, plus **Start over** for the whole character.
### 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 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 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): 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.
## API
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
@@ -91,9 +112,20 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
| GET | `/api/rules` | Rule data including presets |
| GET / POST | `/api/characters` | Own and shared-with-me characters / create one |
| GET | `/api/characters/{id}` | Read (owner or shared users) |
| GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) |
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) |
| GET / POST | `/api/lobbies` | Own and joined lobbies / create one (you are the DM) |
| POST | `/api/lobbies/join` | Join with `{code, character_id}` |
| 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=<id>` to poll) / send, optional `to` = whisper |
| POST | `/api/lobbies/{id}/rolls` | Roll `{sides, count, to?, manual?, mode?}` (d4-d20, d100; up to 10 dice). `manual` = results of real dice, `mode` (DM only) = `hidden`/`mystery`/`public` |
| POST | `/api/lobbies/{id}/checks` | d100 check `{kind: skill\|attr, id, manual?}` for the caller's lobby character |
| 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) |
| POST | `/api/export/pdf` or `/docx` | Character JSON in the body, file in the response |
+4 -1
View File
@@ -33,8 +33,11 @@ def main(argv):
return 1
con.execute("DELETE FROM sessions WHERE user_id=?", (row["id"],))
con.execute("DELETE FROM shares WHERE user_id=?", (row["id"],))
con.execute("DELETE FROM lobby_members WHERE user_id=? OR lobby_id IN (SELECT id FROM lobbies WHERE owner_id=?)", (row["id"], row["id"]))
con.execute("DELETE FROM lobby_messages WHERE lobby_id IN (SELECT id FROM lobbies WHERE owner_id=?)", (row["id"],))
con.execute("DELETE FROM lobbies WHERE owner_id=?", (row["id"],))
con.execute("DELETE FROM users WHERE id=?", (row["id"],))
print("User deleted. Their characters and presets stay on the server without an owner.")
print("User deleted. Their lobbies are removed; their characters and presets stay on the server without an owner.")
else:
print(__doc__)
return 1
+466 -2
View File
@@ -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
@@ -22,6 +23,16 @@ MAX_BODY = 200_000 # bytes per character
MAX_PRESETS = 200
MAX_CHARACTERS = 300 # per user
MAX_SHARES = 30 # per character
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
app = FastAPI(title="Arcane Wasteland Character Creator", docs_url=None, redoc_url=None, openapi_url=None)
@@ -48,6 +59,20 @@ def db() -> sqlite3.Connection:
auth.init_tables(con)
con.execute("""CREATE TABLE IF NOT EXISTS shares (
character_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (character_id, user_id))""")
con.execute("""CREATE TABLE IF NOT EXISTS lobbies (
id TEXT PRIMARY KEY, name TEXT NOT NULL, owner_id INTEGER NOT NULL, code TEXT NOT NULL UNIQUE, created REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_members (
lobby_id TEXT NOT NULL, user_id INTEGER NOT NULL, character_id TEXT, joined REAL NOT NULL,
PRIMARY KEY (lobby_id, user_id))""")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_messages (
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")
@@ -326,11 +351,13 @@ def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
@api.get("/characters/{cid}")
def read_character(cid: str, user: dict = Depends(current_user)):
"""Only the owner and the users the character is shared with may read it."""
"""Readable by the owner, by users the character is shared with, and by the DM of a lobby it plays in."""
with db() as con:
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
allowed = bool(row) and (row["owner_id"] == user["id"] or con.execute(
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone())
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone() or con.execute(
"""SELECT 1 FROM lobby_members m JOIN lobbies l ON l.id = m.lobby_id
WHERE m.character_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone())
if not allowed:
raise HTTPException(404, "Character not found")
return json.loads(row["data"])
@@ -376,6 +403,443 @@ def delete_character(cid: str, user: dict = Depends(current_user)):
_owned(con, "characters", cid, user)
con.execute("DELETE FROM characters WHERE id=?", (cid,))
con.execute("DELETE FROM shares WHERE character_id=?", (cid,))
con.execute("UPDATE lobby_members SET character_id=NULL WHERE character_id=?", (cid,))
return {"ok": True}
# ---- lobbies: the creator is the DM and sees every member's character; players only see a short card of the others
def _lobby_for(con, lid: str, user: dict):
"""Returns the lobby row if the user is its DM or a member, otherwise 404 (hides that it exists)."""
row = con.execute("""SELECT l.*, u.username AS dm FROM lobbies l JOIN users u ON u.id = l.owner_id WHERE l.id=?""",
(lid,)).fetchone()
if not row or (row["owner_id"] != user["id"] and not con.execute(
"SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone()):
raise HTTPException(404, "Lobby not found")
return row
def _dm_only(lobby, user: dict):
if lobby["owner_id"] != user["id"]:
raise HTTPException(403, "Only the DM can do this")
def _new_code(con) -> str:
while True:
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(8))
if not con.execute("SELECT 1 FROM lobbies WHERE code=?", (code,)).fetchone():
return code
def _own_character(con, cid, user: dict):
row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=?", (cid, user["id"])).fetchone()
if not row:
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', ?, ?)",
(lid, uid, text, time.time()))
def _char_name(con, cid) -> str:
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
return _name(row["data"]) if row else "a character"
def _card(data: str, rules: dict) -> dict:
"""The superficial look at a character that fellow players get: name, concept and class/profession names only."""
ch = json.loads(data)
cls = next((c for c in rules["classes"] if c["id"] == ch.get("class_id")), None)
sub = next((x for x in (cls or {}).get("subclasses", []) if x["id"] == ch.get("subclass_id")), None)
prof = next((p for p in rules["professions"] if p["id"] == ch.get("profession")), None)
return {"name": ch.get("name") or "Nameless", "concept": str(ch.get("concept") or "")[:120],
"class": (cls or {}).get("name", ""), "subclass": (sub or {}).get("name", ""),
"profession": (prof or {}).get("name", "")}
@api.get("/lobbies")
def my_lobbies(user: dict = Depends(current_user)):
with db() as con:
mine = con.execute("""SELECT l.id, l.name, (SELECT COUNT(*) FROM lobby_members m WHERE m.lobby_id = l.id) AS n
FROM lobbies l WHERE l.owner_id=? ORDER BY l.created DESC""", (user["id"],)).fetchall()
joined = con.execute("""SELECT l.id, l.name, u.username AS dm, c.data FROM lobby_members m
JOIN lobbies l ON l.id = m.lobby_id JOIN users u ON u.id = l.owner_id
LEFT JOIN characters c ON c.id = m.character_id
WHERE m.user_id=? ORDER BY m.joined DESC""", (user["id"],)).fetchall()
return {"mine": [{"id": r["id"], "name": r["name"], "members": r["n"]} for r in mine],
"joined": [{"id": r["id"], "name": r["name"], "dm": r["dm"], "character": _name(r["data"]) if r["data"] else ""}
for r in joined]}
@api.post("/lobbies")
def create_lobby(body: dict = Body(...), user: dict = Depends(current_user)):
name = str(body.get("name", "")).strip()[:60]
if not name:
raise HTTPException(422, "Give the lobby a name")
with db() as con:
if con.execute("SELECT COUNT(*) FROM lobbies WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_LOBBIES:
raise HTTPException(409, "Lobby limit reached")
lid, code = uuid.uuid4().hex[:12], _new_code(con)
con.execute("INSERT INTO lobbies (id, name, owner_id, code, created) VALUES (?,?,?,?,?)",
(lid, name, user["id"], code, time.time()))
_event(con, lid, f"{user['username']} created the lobby", user["id"])
return {"id": lid, "code": code}
@api.post("/lobbies/join")
def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(current_user)):
ip = client_ip(request)
throttle(f"join:{ip}", 10, 600) # only wrong codes count, so guessing is slow
throttle(f"join:u{user['id']}", 10, 600)
code = re.sub(r"[^A-Za-z0-9]", "", str(body.get("code", ""))).upper()
with db() as con:
lobby = con.execute("SELECT * FROM lobbies WHERE code=?", (code,)).fetchone()
if not lobby:
limiter.fail(f"join:{ip}"); limiter.fail(f"join:u{user['id']}")
raise HTTPException(404, "No lobby with this code")
if lobby["owner_id"] == user["id"]:
raise HTTPException(422, "You are the DM of this lobby")
_own_character(con, body.get("character_id"), user)
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, 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(), _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"]}
@api.get("/lobbies/{lid}")
def read_lobby(lid: str, user: dict = Depends(current_user)):
rules = load_rules()
with db() as con:
lobby = _lobby_for(con, lid, user)
is_dm = lobby["owner_id"] == user["id"]
_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 = []
for r in rows:
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, "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, "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):
"""User id to whisper to (the DM or a member, not yourself), None for everybody."""
to = str(to or "").strip()
if not to:
return None
target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS
(SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""",
(to, lobby["owner_id"], lobby["id"])).fetchone()
if not target or target["id"] == user["id"]:
raise HTTPException(422, "You can only whisper to other people in this lobby")
return target["id"]
@api.get("/lobbies/{lid}/messages")
def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)):
"""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 = _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()
else:
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": 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":
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
@api.post("/lobbies/{lid}/messages")
def send_message(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
text = str(body.get("text", "")).strip()
if not text:
raise HTTPException(422, "Write something first")
if len(text) > MAX_MESSAGE:
raise HTTPException(422, f"Messages can have at most {MAX_MESSAGE} characters")
throttle(f"chat:{user['id']}", 30, 60) # 30 messages per minute
limiter.fail(f"chat:{user['id']}")
with db() as con:
lobby = _lobby_for(con, lid, user)
to_id = _recipient(con, lobby, user, body.get("to"))
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)",
(lid, user["id"], to_id, "whisper" if to_id else "chat", text, 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}
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.
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):
raise HTTPException(422, "Invalid dice")
if sides not in DICE or not 1 <= count <= MAX_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)
limiter.fail(f"chat:{user['id']}")
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"))
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}")
def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
name = str(body.get("name", "")).strip()[:60]
if not name:
raise HTTPException(422, "Give the lobby a name")
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
con.execute("UPDATE lobbies SET name=? WHERE id=?", (name, lid))
_event(con, lid, f"The lobby was renamed to \"{name}\"", user["id"])
return {"ok": True}
@api.delete("/lobbies/{lid}")
def delete_lobby(lid: str, user: dict = Depends(current_user)):
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
return {"ok": True}
@api.post("/lobbies/{lid}/code")
def new_lobby_code(lid: str, user: dict = Depends(current_user)):
"""New join code, the old one stops working (members stay)."""
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
code = _new_code(con)
con.execute("UPDATE lobbies SET code=? WHERE id=?", (code, lid))
_event(con, lid, "The DM created a new join code", user["id"])
return {"code": code}
@api.put("/lobbies/{lid}/me")
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)
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")
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}
@api.delete("/lobbies/{lid}/me")
def leave_lobby(lid: str, user: dict = Depends(current_user)):
with db() as con:
_lobby_for(con, lid, user)
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).rowcount
if gone:
_event(con, lid, f"{user['username']} left the lobby", user["id"])
return {"ok": True}
@api.delete("/lobbies/{lid}/members/{username}")
def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)",
(lid, username)).rowcount
if gone:
_event(con, lid, f"{username} was removed by the DM", user["id"])
return {"ok": True}
+1 -1
View File
@@ -5,7 +5,7 @@ services:
ports:
# Only reachable from the server itself; the reverse proxy (nginx) is the public entrance.
# Docker bypasses firewall rules for published ports, so never publish this on 0.0.0.0.
- "127.0.0.1:8080:8000"
- "127.0.0.1:${AW_PORT:-8090}:8000"
security_opt:
- no-new-privileges:true
environment:
+5 -1
View File
@@ -3,6 +3,7 @@ import Creator from './Creator.jsx'
import Rules from './Rules.jsx'
import Characters from './Characters.jsx'
import Presets from './Presets.jsx'
import Lobbies from './Lobbies.jsx'
import Login from './Login.jsx'
import Account from './Account.jsx'
import { claimLegacy } from './legacy.js'
@@ -52,7 +53,7 @@ export default function App() {
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
const page = hash.split('/')[0]
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']]
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies']]
return (
<>
@@ -77,6 +78,9 @@ export default function App() {
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
)}
{rules && page === 'lobbies' && (
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
)}
{page === 'account' && <Account user={username} onLogout={logout} />}
</main>
</>
+2 -41
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
import SheetPanel from './SheetPanel.jsx'
import Tip from './Tip.jsx'
import PaperSheet from './PaperSheet.jsx'
import { explainAttr, explainAttrPool, explainCapacity, explainCarry, explainDerived, explainProfPool, explainSkill, explainSkillPool } from './explain.js'
const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary']
@@ -433,47 +434,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
<>
<h2>Summary</h2>
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
<div className="sheet card">
<h3 className="sheet-name">{ch.name || 'Nameless'}</h3>
<p className="muted">{ch.player && `Player: ${ch.player} · `}Profession: {prof?.name || '–'}{cls && ` · Class: ${[cls.name, sub?.name].filter(Boolean).join(' / ')}`}</p>
<div className="sheet-cols">
<div>
<h4>Attributes</h4>
<table><tbody>{rules.attributes.map((a) => <tr key={a.id}><td><Tip text={explainAttr(a, ctx)}>{a.name}</Tip></td><td>{c.attrs[a.id]}</td><td className="muted">Mod {c.mods[a.id]}</td></tr>)}</tbody></table>
<h4>Derived stats</h4>
<table><tbody>{rules.derived.map((d) => <tr key={d.id}><td><Tip text={explainDerived(d, ctx)}>{d.name}</Tip></td><td>{c.derived[d.id]}</td></tr>)}</tbody></table>
</div>
<div>
<h4>Talents</h4>
<ul className="desclist">{rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => <li key={t.id}><strong>{t.name}</strong><div className="rowdesc">{t.desc}</div></li>)}</ul>
{!ch.talents.length && <p>–</p>}
<h4><Tip text={explainCapacity(ctx)}>Mutations / implants ({c.capacityUsed}/{c.capacityMax})</Tip></h4>
<ul className="desclist">{rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => <li key={a.id}><strong>{a.name}</strong> <span className="muted small">cost {a.cost}</span><div className="rowdesc">{a.desc}</div></li>)}</ul>
{!ch.augments.length && <p>–</p>}
<h4><Tip text={explainCarry(ctx)}>Equipment ({c.weight}/{c.carryMax} kg)</Tip></h4>
<ul className="desclist">{gearLines.map((g) => <li key={g.item.id}><strong>{g.item.name}{g.qty > 1 ? ` x${g.qty}` : ''}</strong><div className="rowdesc">{[g.item.stat, `${Math.round(g.item.kg * g.qty * 10) / 10} kg`, g.item.note].filter(Boolean).join(' · ')}</div></li>)}</ul>
{!gearLines.length && <p>–</p>}
{ch.gear.other && <p className="small"><strong>Other:</strong> {ch.gear.other}</p>}
</div>
</div>
<h4>Skills</h4>
<div className="skillcols">
{rules.skill_groups.map((g) => (
<div key={g.id} className="skillgroup">
<h5>{g.name}</h5>
<table><tbody>
{c.skills.filter((s) => s.group === g.id).map((s) => (
<tr key={s.id} className={s.usable ? '' : 'dim'}>
<td><Tip text={explainSkill(s, ctx)}>{s.name}</Tip>{s.type === 'advanced' && <span className="muted small"> (adv.)</span>}</td>
<td>{s.total}</td>
<td className="muted">{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.points ? `+${s.points}` : ''}</td>
</tr>
))}
</tbody></table>
</div>
))}
</div>
</div>
<PaperSheet data={ch} baseRules={baseRules} />
<div className="actions wrap">
<button onClick={() => download('pdf')}>Download as PDF</button>
<button onClick={() => download('docx')}>Download as Word</button>
+619
View File
@@ -0,0 +1,619 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
const J = { 'Content-Type': 'application/json' }
const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) })
const msg = (d, fallback) => (typeof d?.detail === 'string' ? d.detail : fallback)
function useMine() {
const [mine, setMine] = useState([])
useEffect(() => { fetch('/api/characters').then((r) => r.json()).then((d) => setMine(d.mine || [])).catch(() => {}) }, [])
return mine
}
function CharSelect({ mine, value, onChange, label }) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} aria-label={label}>
<option value="">Choose a character …</option>
{mine.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
)
}
// ---- list of lobbies: create one (you become the DM) or join one with a code
function LobbyList() {
const mine = useMine()
const [data, setData] = useState(null)
const [name, setName] = useState('')
const [code, setCode] = useState('')
const [cid, setCid] = useState('')
const [err, setErr] = useState('')
const load = () => fetch('/api/lobbies').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your lobbies.'))
useEffect(() => { load() }, [])
const create = async (e) => {
e.preventDefault(); setErr('')
const r = await send('/api/lobbies', 'POST', { name })
const d = await r.json().catch(() => ({}))
if (!r.ok) return setErr(msg(d, 'Could not create the lobby.'))
window.location.hash = `lobbies/${d.id}`
}
const join = async (e) => {
e.preventDefault(); setErr('')
if (!cid) return setErr('Choose which of your characters joins.')
const r = await send('/api/lobbies/join', 'POST', { code, character_id: cid })
const d = await r.json().catch(() => ({}))
if (!r.ok) return setErr(msg(d, 'Could not join.'))
window.location.hash = `lobbies/${d.id}`
}
return (
<section className="page narrow">
<h1>Lobbies</h1>
<p className="muted">
A lobby is a group for one campaign. Whoever creates it is the DM and can look at every character in it.
Players only see a short card of each other (name, concept, class and profession).
</p>
{err && <p className="warn">{err}</p>}
<div className="card">
<h2>Join a lobby</h2>
<form className="row wrapform" onSubmit={join}>
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Join code from your DM" aria-label="Join code" autoComplete="off" />
<CharSelect mine={mine} value={cid} onChange={setCid} label="Character that joins" />
<button type="submit" disabled={!code.trim()}>Join</button>
</form>
{mine.length === 0 && <p className="small muted">You need a saved character first (Character → Summary → Save).</p>}
</div>
<h2>Lobbies you joined</h2>
{data && data.joined.length === 0 && <p className="muted small">None yet.</p>}
<ul className="charlist">
{(data?.joined || []).map((l) => (
<li key={l.id} className="card row">
<div>
<strong>{l.name}</strong>
<div className="muted small">DM {l.dm}{l.character ? ` · playing ${l.character}` : ' · no character selected'}</div>
</div>
<div className="actions"><a className="button" href={`#lobbies/${l.id}`}>Open</a></div>
</li>
))}
</ul>
<h2>Lobbies you run (as DM)</h2>
{data && data.mine.length === 0 && <p className="muted small">None yet.</p>}
<ul className="charlist">
{(data?.mine || []).map((l) => (
<li key={l.id} className="card row">
<div>
<strong>{l.name}</strong>
<div className="muted small">{l.members} player{l.members === 1 ? '' : 's'}</div>
</div>
<div className="actions"><a className="button" href={`#lobbies/${l.id}`}>Open</a></div>
</li>
))}
</ul>
<form className="row wrapform" onSubmit={create}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name of a new lobby" aria-label="Lobby name" maxLength={60} />
<button type="submit" disabled={!name.trim()}>+ Create lobby</button>
</form>
</section>
)
}
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, 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)
const live = useRef(false) // false until the history is loaded: old rolls are not animated again
const cb = useRef({})
cb.current = { onEvent, onRoll }
const busy = useRef(false) // two overlapping polls (timer + right after a roll) would both deliver the same entries
const poll = useCallback(async () => {
if (document.hidden || busy.current) return
busy.current = true
const r = await fetch(`/api/lobbies/${id}/messages?after=${last.current}`).catch(() => null)
busy.current = false
if (!r || !r.ok) return
const fresh = (await r.json()).filter((m) => m.id > last.current)
const wasLive = live.current
live.current = true
if (!fresh.length) return
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' || m.kind === 'check').forEach((m) => cb.current.onRoll(m))
}, [id])
useEffect(() => {
poll()
const t = setInterval(poll, 2500)
return () => clearInterval(t)
}, [poll])
return { msgs, poll }
}
const rollText = (m) => {
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 <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 }) {
const [text, setText] = useState('')
const [events, setEvents] = useState(true)
const [err, setErr] = useState('')
const box = useRef(null)
const stick = useRef(true)
useEffect(() => { // keep the newest line in view unless the user scrolled up
const el = box.current
if (el && stick.current) el.scrollTop = el.scrollHeight
}, [msgs, events])
const submit = async (e) => {
e.preventDefault(); setErr('')
if (!text.trim()) return
const r = await send(`/api/lobbies/${id}/messages`, 'POST', { text, to })
if (!r.ok) return setErr(msg(await r.json().catch(() => ({})), 'Could not send.'))
setText(''); stick.current = true; poll()
}
const shown = msgs.filter((m) => events || m.kind !== 'event')
const who = (m) => (
<>
<strong style={m.color ? { color: m.color } : undefined}>{m.mine ? 'You' : m.from || '?'}</strong>
{m.to && <em className="small whispertag"> {m.to === m.from ? '(only visible to you, the DM)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
</>
)
return (
<div className="chatwrap">
<div className="chatbox" ref={box} onScroll={(e) => { const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40 }}
role="log" aria-live="polite">
{shown.length === 0 && <p className="muted small">Nothing here yet.</p>}
{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 === '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') {
const t = rollText(m)
return (
<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>:{' '}
{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>
)
}
return (
<div key={m.id} className={`msg ${m.kind} ${m.mine ? 'mine' : ''}`}>
<span className="time">{clock(m.time)}</span> {who(m)}<span>: {m.text}</span>
</div>
)
})}
</div>
<form className="row chatform" onSubmit={submit}>
<select value={to} onChange={(e) => setTo(e.target.value)} aria-label="Send to" className={to ? 'whisper' : ''}>
<option value="">Everyone</option>
{people.filter((p) => p !== me).map((p) => <option key={p} value={p}>Only {p} (whisper)</option>)}
</select>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" />
<button type="submit" disabled={!text.trim()}>Send</button>
</form>
{err && <p className="warn small">{err}</p>}
<label className="small muted"><input type="checkbox" checked={events} onChange={(e) => setEvents(e.target.checked)} style={{ width: 'auto' }} /> Show events (joins, character switches …)</label>
<p className="small muted">Whispers and private rolls are only seen by you and the person you pick, not even the DM sees other people's.</p>
</div>
)
}
// ---- 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: '' }
function DieIcon({ sides }) {
const pts = SHAPE[sides] && SHAPE[sides].split(', ').map((p) => p.split(' ').map((v) => parseFloat(v) * .24).join(',')).join(' ')
return (
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
{pts ? <polygon points={pts} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" /> : <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="1.6" />}
</svg>
)
}
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 (
<div className="dicebar" role="toolbar" aria-label="Dice">
<label className="small muted dicecount">Dice
<select value={count} onChange={(e) => setCount(+e.target.value)} aria-label="Number of dice">
{[1, 2, 3, 4, 5, 6, 8, 10].map((n) => <option key={n} value={n}>{n}×</option>)}
</select>
</label>
{DICE.map((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>
</button>
))}
<label className="small ownswitch" title="You rolled real dice: type in the result instead of rolling here">
<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>
)
}
// 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)
useEffect(() => {
if (reduced()) return
const t0 = Date.now(), run = 650 + delay
const iv = setInterval(() => {
if (Date.now() - t0 >= run) { setShown(value); clearInterval(iv) } else setShown(1 + Math.floor(Math.random() * sides))
}, 70)
return () => clearInterval(iv)
}, [sides, value, delay])
const rolling = shown !== value
return (
<span className={`die d${sides} ${rolling ? 'rolling' : 'landed'}${mystery ? ' mystery' : ''}`} style={{ animationDelay: `${delay % 200}ms` }}>
<span>{mystery && !rolling ? '?' : shown ?? '?'}</span>
</span>
)
}
// dice are thrown onto the table from a random side and come to rest at a random spot, in the colour of whoever threw them
const placed = [] // spots of the dice still lying on the table, so new ones do not land on top of them
function spots(n) {
const out = []
for (let i = 0; i < n; i++) {
let p, tries = 0
do { p = { x: 12 + Math.random() * 76, y: 22 + Math.random() * 58 }; tries++ } while (tries < 40 && [...placed, ...out].some((q) => Math.hypot((q.x - p.x) * 1.6, q.y - p.y) < 14))
placed.push(p); if (placed.length > 12) placed.shift()
const a = Math.random() * Math.PI * 2
out.push({ ...p, sx: Math.round(Math.cos(a) * 700), sy: Math.round(Math.sin(a) * 450), sr: Math.round(Math.random() * 720 - 360), er: Math.round(Math.random() * 50 - 25) })
}
return out
}
function ThrownDice({ m }) {
const sec = m.kind === 'roll' && m.roll.mystery && !m.mine
const n = m.kind === 'check' ? 1 : m.roll.n
const [pos] = useState(() => spots(n))
return pos.map((p, i) => (
<span key={i} className="thrown" style={{ left: `${p.x}%`, top: `${p.y}%`, '--sx': `${p.sx}px`, '--sy': `${p.sy}px`, '--sr': `${p.sr}deg`, '--er': `${p.er}deg`,
'--dc': sec ? '#8b8f85' : m.color || 'var(--noxium)', animationDelay: `${i * 90}ms` }}>
{m.kind === 'check' ? <Die sides={100} value={m.check.roll} delay={0} />
: <Die sides={m.roll.sides} value={m.roll.results ? m.roll.results[i] : 0} delay={i * 120} mystery={!m.roll.results} />}
</span>
))
}
function DiceTray({ rolls }) {
if (!rolls.length) return null
return (
<div className="dicetray" aria-hidden="true">
{rolls.map((m) => <ThrownDice key={m.key} m={m} />)}
<div className="tablelog">
{rolls.map((m) => {
const who = <b style={{ color: m.color }}>{m.mine ? 'You' : m.from}</b>
if (m.kind === 'check') {
const c = m.check
return <div key={m.key} className="trayroll">{who} – {c.what}{c.manual ? ' · own dice' : ''}: <span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>{c.target !== undefined && <small> ({c.roll} vs {c.target})</small>}</div>
}
const t = rollText(m)
return <div key={m.key} className="trayroll">{who} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}{m.roll.manual ? ' · own dice' : ''}
{t.sum !== null ? <> = <b>{t.sum}</b></> : ' = ?'}</div>
})}
</div>
</div>
)
}
// ---- 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
function CharTab({ cid, rules, onRoll }) {
const [data, setData] = useState(null)
const [err, setErr] = useState('')
useEffect(() => {
setData(null); setErr('')
const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then(setData).catch(() => setErr('Could not load this character.'))
load()
const t = setInterval(() => !document.hidden && load(), 15000)
return () => clearInterval(t)
}, [cid])
return (
<div>
{err && <p className="warn">{err}</p>}
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} />}
</div>
)
}
// lobby settings: join code, rename, colour, character switch, members, leave / delete
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 [lobby, setLobby] = useState(null)
const [err, setErr] = useState('')
const [info, setInfo] = useState('')
const [sel, setSel] = useState('')
const [to, setTo] = useState('') // whisper recipient, used for chat messages and dice rolls
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 = useCallback(() => fetch(`/api/lobbies/${id}`).then(async (r) => {
if (!r.ok) throw new Error(r.status)
setLobby(await r.json())
}).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') }), [id])
useEffect(() => {
load()
const t = setInterval(() => !document.hidden && load(), 5000) // picks up joins, colours, difficulty and character changes
return () => clearInterval(t)
}, [load])
const onRoll = useCallback((m) => {
const key = `${m.id}`
setTray((t) => [...t.slice(-2), { ...m, key }])
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
}, [])
const feed = useFeed(id, load, onRoll)
const act = async (url, method, body, done) => {
if (!url) return load()
setErr(''); setInfo('')
const r = await send(url, method, body).catch(() => null)
const d = r ? await r.json().catch(() => ({})) : {}
if (!r || !r.ok) return setErr(msg(d, 'That did not work.'))
done ? done(d) : load()
}
if (err && !lobby) return <section className="page lobbypage"><p className="warn">{err}</p><a href="#lobbies">Back to all lobbies</a></section>
if (!lobby) return <section className="page lobbypage"><p className="muted">Loading …</p></section>
const me = lobby.members.find((m) => m.me)
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 cur = sheets.find((m) => m.username === sel) || sheets.find((m) => m.me) || sheets[0]
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 (
<section className="page lobbypage">
<div className="lobbyhead">
<a className="small" href="#lobbies">← Lobbies</a>
<h1>{lobby.name}</h1>
<span className="muted small">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
<span className="tabright">
<button className="ghost" onClick={() => setShowSettings((v) => !v)} aria-expanded={showSettings}>⚙ Lobby</button>
</span>
</div>
{err && <p className="warn">{err}</p>}
{info && <p className="small">{info}</p>}
<div className={`lobbygrid${sheetOpen ? '' : ' nosheet'}`}>
{sheetOpen && (
<div className="sheetcol">
<div className="sheetbar">
<b>{cur ? cur.character.name : 'Character sheet'}</b>
<span className="tabright"><ThemeSwitch /> <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide the sheet">◀</button></span>
</div>
{cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} onRoll={cur.me && canCheck ? check : undefined} />
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
</div>
)}
<div className="playarea">
{!sheetOpen && <button className="ghost showsheet" onClick={() => { setSheetOpen(true); store('aw_sheetopen', '1') }}>▶ Sheet</button>}
<CharBar lobby={lobby} sel={cur?.username} onSel={setSel} />
<DifficultyBar lobby={lobby} onSet={(b) => act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
<div className="table">
{showSettings && <Settings lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} onClose={() => setShowSettings(false)} />}
<DiceTray rolls={tray} />
{canCheck && !pending && <p className="tablehint small muted">Click an attribute or skill on your sheet to roll it.</p>}
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
</div>
<DiceBar to={dest} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
</div>
<aside className="lobbychat card" aria-label="Chat">
<Chat id={id} me={user} people={people} msgs={feed.msgs} poll={feed.poll} to={dest} setTo={setTo} />
</aside>
</div>
</section>
)
}
export default function Lobbies({ section, rules, user, onOpen }) {
return section ? <LobbyView key={section} id={section} rules={rules} user={user} onOpen={onOpen} /> : <LobbyList />
}
+209
View File
@@ -0,0 +1,209 @@
import { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import Tip from './Tip.jsx'
import { compute, effectiveRules, kitItems, normalize } from './calc.js'
import { explainAttr, explainCapacity, explainCarry, explainDerived, explainSkill } from './explain.js'
const PAPER_PX = 794 // 210 mm at 96 dpi
// Light/dark choice for the sheet, shared by all sheets and switches on the page and remembered in the browser
const THEME_KEY = 'aw_sheettheme'
const listeners = new Set()
const readTheme = () => { try { return localStorage.getItem(THEME_KEY) === 'dark' } catch { return false } }
let darkNow = readTheme()
export function useSheetTheme() {
const dark = useSyncExternalStore((cb) => { listeners.add(cb); return () => listeners.delete(cb) }, () => darkNow)
const set = (d) => { darkNow = d; try { localStorage.setItem(THEME_KEY, d ? 'dark' : 'light') } catch { /* storage may be blocked */ } listeners.forEach((f) => f()) }
return [dark, set]
}
export function ThemeSwitch() {
const [dark, setDark] = useSheetTheme()
return (
<span className="seg" role="group" aria-label="Sheet colours">
<button className={dark ? 'ghost' : 'on'} onClick={() => setDark(false)} title="Light sheet">Light</button>
<button className={dark ? 'on' : 'ghost'} onClick={() => setDark(true)} title="Dark sheet (prints light)">Dark</button>
</span>
)
}
// Opens the sheet pages in a clean window (only the paper, A4, no margins) and starts the print dialog:
// "Save as PDF" there gives an A4 PDF that looks exactly like the screen.
export function printSheet(root, title) {
const win = window.open('', '_blank')
if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return }
const clone = root.cloneNode(true)
clone.style.zoom = ''
clone.classList.remove('dark') // printed pages are always light
const head = [...document.querySelectorAll('link[rel=stylesheet], style')].map((n) => n.outerHTML).join('')
win.document.open()
win.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>${(title || 'Character').replace(/[<>&]/g, '')}</title>${head}
<style>@page { size: A4; margin: 0 } html, body { background: #fff !important; margin: 0; padding: 0 }
.papers { display: block !important; gap: 0 !important } .paper { border: 0 !important; box-shadow: none !important; margin: 0 !important; min-height: 296.5mm !important; break-after: page; }
.paper:last-child { break-after: auto }</style></head><body>${clone.outerHTML}</body></html>`)
win.document.close()
const go = () => { win.focus(); win.print() }
const links = [...win.document.querySelectorAll('link[rel=stylesheet]')]
let pending = links.length
if (!pending) return go()
links.forEach((l) => { l.addEventListener('load', () => { if (--pending === 0) setTimeout(go, 150) }); l.addEventListener('error', () => { if (--pending === 0) go() }) })
}
// Scales the A4 pages down when the space is narrower than the paper
function Fit({ children, rootRef, dark }) {
const wrap = useRef(null)
const [z, setZ] = useState(1)
useLayoutEffect(() => {
const f = () => wrap.current && setZ(Math.min(1, wrap.current.clientWidth / PAPER_PX))
f()
const ro = new ResizeObserver(f)
ro.observe(wrap.current)
return () => ro.disconnect()
}, [])
return <div ref={wrap} className="paperfit"><div ref={rootRef} className={`papers${dark ? ' dark' : ''}`} style={{ zoom: z }}>{children}</div></div>
}
const Pips = ({ n, locked }) => (
<span className={`ppips ${locked ? 'locked' : ''}`} aria-label={n === 2 ? 'Expertise' : n === 1 ? 'Proficient' : locked ? 'Locked' : ''}>
<i className={n >= 1 ? 'on' : ''} /><i className={n >= 2 ? 'on' : ''} />
</span>
)
// 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, onRoll }) {
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
const c = useMemo(() => compute(ch, rules), [ch, rules])
const x = { ch, rules, c }
const root = useRef(null)
const [dark] = useSheetTheme()
const prof = rules.professions.find((p) => p.id === ch.profession)
const cls = rules.classes.find((k) => k.id === ch.class_id)
const sub = cls?.subclasses.find((s) => s.id === ch.subclass_id)
const preset = ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name || 'Custom'
const short = (s) => rules.attributes.filter((a) => s.attrs.includes(a.id)).map((a) => a.short).join('/')
const talents = rules.talents.filter((t) => ch.talents.includes(t.id))
const augments = rules.augments.filter((a) => ch.augments.includes(a.id))
const gear = kitItems(ch, rules)
const name = ch.name || 'Nameless'
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 Foot = ({ n }) => <div className="pfoot"><span>Arcane Wasteland</span><span>{name}</span><span>Page {n} / 2</span></div>
return (
<div className="papersheet">
{!bare && (
<div className="papertools">
{tools}
<span className="toolright">
<ThemeSwitch />
<button className="ghost" onClick={() => printSheet(root.current, name)} title="Opens the pages for printing, choose Save as PDF there">Print / PDF (A4)</button>
</span>
</div>
)}
<Fit rootRef={root} dark={dark}>
<section className="paper">
<header className="phead">
<div className="pname">{name}</div>
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
</header>
<div className="pfields">
<Field label="Player">{ch.player}</Field>
<Field label="Concept">{ch.concept}</Field>
<Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field>
<Field label="Profession">{prof?.name}</Field>
<Field label="Rules">{preset}</Field>
</div>
<div className="pattrs">
{rules.attributes.map((a) => (
<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>
<b className="pattr-v">{c.attrs[a.id]}</b>
<span className="pattr-m">Mod {sign(c.mods[a.id])}</span>
</div>
))}
</div>
<div className="pderived">
{rules.derived.map((d) => (
<div key={d.id} className="pstat">
<Tip text={explainDerived(d, x)}><span>{d.name}</span></Tip>
<b>{c.derived[d.id]}</b>
</div>
))}
</div>
<div className="psec">
<h3>Skills <small>● proficient · ●● expertise · attribute · points · total</small></h3>
<div className="pskills">
{rules.skill_groups.map((g) => (
<div key={g.id} className="pgroup">
<h4>{g.name}</h4>
{c.skills.filter((s) => s.group === g.id).map((s) => (
<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} />
<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="pp">{s.points || ''}</span>
<b className="pt">{s.total}</b>
</div>
))}
</div>
))}
</div>
</div>
<div className="psec pnotes"><h3>Conditions &amp; notes</h3><div className="plines" /></div>
<Foot n={1} />
</section>
<section className="paper">
<header className="phead small">
<div className="pname">{name}</div>
<div className="pbrand">{[cls?.name, prof?.name].filter(Boolean).join(' · ')}</div>
</header>
<div className="psec">
<h3>Talents</h3>
{talents.length === 0 && <p className="pnone">–</p>}
<div className="pcols">
{talents.map((t) => <div key={t.id} className="pitem"><b>{t.name}</b><p>{t.desc}</p></div>)}
</div>
</div>
<div className="psec">
<h3><Tip text={explainCapacity(x)}>Mutations &amp; implants</Tip> <small>Capacity {c.capacityUsed} / {c.capacityMax}</small></h3>
{augments.length === 0 && <p className="pnone">–</p>}
<div className="pcols">
{augments.map((a) => (
<div key={a.id} className="pitem"><b>{a.name}</b> <span className="ptag">{a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost {a.cost}</span><p>{a.desc}</p></div>
))}
</div>
</div>
<div className="psec">
<h3><Tip text={explainCarry(x)}>Equipment</Tip> <small>Carried {c.weight} / {c.carryMax} kg</small></h3>
{gear.length === 0 && <p className="pnone">–</p>}
{gear.length > 0 && (
<table className="pgear"><thead><tr><th>Item</th><th>Type</th><th>Stats</th><th>Qty</th><th>kg</th></tr></thead>
<tbody>{gear.map((g) => (
<tr key={g.item.id}><td><b>{g.item.name}</b>{g.item.note && <span className="pnote"> {g.item.note}</span>}</td><td>{g.label}</td><td>{g.item.stat}</td>
<td>{g.qty}</td><td>{Math.round(g.item.kg * g.qty * 10) / 10}</td></tr>
))}</tbody></table>
)}
{ch.gear?.other && <p className="pother"><b>Other:</b> {ch.gear.other}</p>}
</div>
{ch.background && <div className="psec"><h3>Background</h3><p className="pbg">{ch.background}</p></div>}
<div className="psec pnotes"><h3>Notes</h3><div className="plines" /></div>
<Foot n={2} />
</section>
</Fit>
</div>
)
}
+215
View File
@@ -169,3 +169,218 @@ tr.dim td { opacity: .55; }
.tipwrap:hover .tipicon, .tipwrap:focus .tipicon { color: var(--noxium); border-color: var(--noxium); }
.tippop { position: fixed; z-index: 1000; max-width: 340px; padding: .55rem .75rem; background: var(--panel2); color: var(--text); border: 1px solid var(--diesium); border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.35); font-size: .85rem; line-height: 1.4; pointer-events: none; }
.tippop div + div { margin-top: .35rem; }
/* lobbies */
a.button { display: inline-block; font-weight: 600; text-decoration: none; background: var(--noxium); color: #12150f; border: 1px solid var(--noxium); padding: .5rem 1rem; border-radius: var(--radius); }
a.button:hover { filter: brightness(1.1); }
.wrapform { flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
.wrapform > input, .wrapform > select { flex: 1 1 12rem; width: auto; }
.lobbycard { display: grid; gap: .45rem; }
.lobbycard.me { border-color: var(--noxium); }
.lobbycard p { margin: 0; }
.joincode { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1.5rem; letter-spacing: .15em; }
span.chip { border: 1px solid var(--line); font-size: .85rem; padding: .1rem .6rem; }
.lobbycard .chips { gap: .4rem; }
h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
/* lobby page: tabs, chat, sheet */
.lobbypage { max-width: 980px; }
.tabs { display: flex; flex-wrap: wrap; gap: .3rem; border-bottom: 1px solid var(--line); margin: .8rem 0 1rem; }
.tabs button { background: transparent; color: var(--text); border: 1px solid transparent; border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; font-weight: 500; padding: .45rem .9rem; margin-bottom: -1px; }
.tabs button.on { background: var(--panel); border-color: var(--line); border-bottom: 1px solid var(--panel); color: var(--text); font-weight: 700; }
.chatwrap { display: grid; gap: .6rem; }
.chatbox { height: min(55vh, 520px); overflow-y: auto; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: .6rem .8rem; display: flex; flex-direction: column; gap: .25rem; }
.msg { overflow-wrap: anywhere; line-height: 1.35; }
.msg .time { color: var(--muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
.msg.event { color: var(--muted); font-size: .85rem; font-style: italic; }
.msg.mine strong { color: var(--noxium); }
.msg.whisper { background: var(--panel2); border-left: 3px solid #8a63c7; padding: .15rem .5rem; border-radius: 4px; }
.msg.whisper em { color: #8a63c7; }
.chatform { flex-wrap: nowrap; gap: .5rem; }
.chatform select { width: 12rem; flex: 0 0 auto; }
.chatform select.whisper { border-color: #8a63c7; }
.chatform input { flex: 1 1 auto; width: auto; min-width: 0; }
.sheetcols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; }
.sheetview h4 { margin: 1rem 0 .3rem; }
.sheetview table { width: 100%; }
@media (max-width: 760px) { .sheetcols { grid-template-columns: 1fr; } .chatform { flex-wrap: wrap; } .chatform select { width: 100%; } }
/* 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; }
body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(--chatw) + 1.5rem); }
.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 .chatwrap { flex: 1; min-height: 0; grid-template-rows: minmax(0, 1fr) auto auto auto auto; }
.lobbychat .chatbox { height: auto; min-height: 0; }
.lobbychat .chatform { flex-wrap: wrap; }
.lobbychat .chatform select { width: 100%; flex-basis: 100%; }
.lobbychat .chatform input { flex: 1 1 8rem; }
.partygrid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.msg.rollmsg .rollres b { font-size: 1.05rem; color: var(--noxium); }
.whispertag { color: #8a63c7; }
.dicecount { display: flex; align-items: center; gap: .3rem; margin-right: .3rem; }
.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:hover:not(:disabled) { border-color: var(--noxium); color: var(--noxium); }
.dicedest { margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
.dicedest.priv { color: #8a63c7; font-weight: 600; }
.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; }
.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(--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.d6 { border-radius: 12px; }
.die.d8 { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
.die.d10 { clip-path: polygon(50% 0, 98% 40%, 50% 100%, 2% 40%); }
.die.d12 { clip-path: polygon(50% 0, 98% 38%, 80% 96%, 20% 96%, 2% 38%); }
.die.d20 { clip-path: polygon(25% 4%, 75% 4%, 100% 50%, 75% 96%, 25% 96%, 0 50%); }
.die.d100 { border-radius: 50%; }
.die.rolling { animation: tumble .32s linear infinite; filter: brightness(.9); }
.die.landed { animation: land .25s ease-out; }
@keyframes tumble { 0% { transform: rotate(0) translateY(0); } 25% { transform: rotate(90deg) translateY(-8px); } 50% { transform: rotate(180deg) translateY(0); } 75% { transform: rotate(270deg) translateY(-5px); } 100% { transform: rotate(360deg) translateY(0); } }
@keyframes land { 0% { transform: scale(1.35); } 100% { transform: scale(1); } }
@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; } }
.lobbycard { align-content: start; }
.lobbycard .chips { align-items: flex-start; align-content: flex-start; }
.lobbycard .chip { align-self: flex-start; }
/* ---- A4 character sheet (fixed colours on purpose: it is printed on white paper) ---- */
.papertools { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: 0 0 .5rem; }
.papertools .toolright { margin-left: auto; display: flex; gap: .5rem; align-items: center; }
.papertools button { padding: .3rem .8rem; }
.seg { display: inline-flex; }
.seg button { border-radius: 0; padding: .4rem .9rem; }
.seg button:first-child { border-radius: var(--radius) 0 0 var(--radius); }
.seg button:last-child { border-radius: 0 var(--radius) var(--radius) 0; margin-left: -1px; }
.seg button.on { background: var(--noxium); color: #12150f; border-color: var(--noxium); }
.paperfit { width: 100%; }
.papers { display: grid; gap: 14px; justify-items: start; width: 210mm;
--pi: #1a1f14; --pg: #2f6a0d; --pbar: #2f6a0d; --pm: #6a7260; --pl: #cfd5c3; --pd: #c3cab6; --pt: #eef3e6; --pb: #fff; --p2: #4d5645; --p3: #3b4433; --p4: #5c6553; --pn: #b9c1ab; }
.papers.dark { --pi: #e4e9d8; --pg: #8fd44c; --pbar: #3c7a16; --pm: #98a28a; --pl: #343b2d; --pd: #353c2e; --pt: #252b1f; --pb: #161a12; --p2: #b4bda6; --p3: #c5cdb7; --p4: #a1aa93; --pn: #454d3a; }
.papers.dark .paper { box-shadow: 0 2px 12px rgba(0, 0, 0, .5); }
.paper { box-sizing: border-box; width: 210mm; min-height: 297mm; padding: 11mm 12mm 8mm; background: var(--pb); color: var(--pi); border: 1px solid var(--pl); box-shadow: 0 2px 12px rgba(0, 0, 0, .14);
display: flex; flex-direction: column; gap: 3.5mm; font-size: 8.6pt; line-height: 1.25; break-after: page; }
.paper * { color: inherit; }
.paper .tipped { border-bottom: 0; text-decoration: none; cursor: default; }
.paper p { margin: 0; }
.phead { display: flex; justify-content: space-between; align-items: flex-end; gap: 8mm; border-bottom: 2px solid var(--pg); padding-bottom: 2mm; }
.pname { font-size: 24pt; font-weight: 800; line-height: 1.05; letter-spacing: .01em; overflow-wrap: anywhere; }
.phead.small .pname { font-size: 15pt; }
.pbrand { text-align: right; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; font-size: 8.5pt; color: var(--pg) !important; white-space: nowrap; }
.pbrand span { display: block; font-weight: 500; letter-spacing: .08em; font-size: 7pt; color: var(--pm) !important; }
.pfields { display: grid; grid-template-columns: 1fr 1.6fr 1.3fr 1fr .8fr; gap: 3mm; }
.pfield { border-bottom: 1px solid var(--pi); padding-bottom: .6mm; min-width: 0; }
.pfield span { display: block; font-size: 6.2pt; text-transform: uppercase; letter-spacing: .1em; color: var(--pm) !important; }
.pfield b { font-weight: 600; font-size: 9pt; overflow-wrap: anywhere; }
.pattrs { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2.5mm; }
.pattr { border: 1.4px solid var(--pi); border-radius: 3mm; text-align: center; padding: 1.6mm 1mm 1.4mm; display: grid; gap: .2mm; }
.pattr-n { font-size: 6.6pt; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
.pattr-v { font-size: 20pt; line-height: 1; }
.pattr-m { font-size: 7pt; color: var(--p2) !important; border-top: 1px solid var(--pl); padding-top: .8mm; }
.pderived { display: grid; grid-template-columns: repeat(auto-fit, minmax(28mm, 1fr)); gap: 2.5mm; }
.pstat { background: var(--pt); border-radius: 2mm; padding: 1.4mm 2.4mm; display: flex; justify-content: space-between; align-items: baseline; font-weight: 600; font-size: 8pt; }
.pstat b { font-size: 13pt; }
.psec h3 { margin: 0 0 1.6mm; font-size: 9pt; text-transform: uppercase; letter-spacing: .12em; color: #fff !important; background: var(--pbar); padding: .8mm 2.4mm; border-radius: 1.4mm; display: flex; justify-content: space-between; align-items: center; gap: 3mm; }
.psec h3 * { color: #fff !important; }
.psec h3 small { font-size: 6.6pt; font-weight: 500; letter-spacing: .04em; text-transform: none; }
.pskills { column-count: 2; column-gap: 6mm; }
.pgroup { break-inside: avoid; margin-bottom: 3mm; }
.pgroup h4 { margin: 0 0 .6mm; font-size: 7.4pt; text-transform: uppercase; letter-spacing: .1em; color: var(--pg) !important; border-bottom: 1px solid var(--pg); }
.prow { display: grid; grid-template-columns: 6.5mm 1fr 11mm 6mm 8mm; align-items: center; gap: 1mm; padding: .35mm 0; border-bottom: 1px dotted var(--pd); }
.prow .pn { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.prow .pn em { font-size: 6pt; color: var(--pm) !important; font-style: normal; text-transform: uppercase; letter-spacing: .05em; margin-left: .8mm; }
.prow .pa { font-size: 6.4pt; color: var(--pm) !important; text-align: center; }
.prow .pp { font-size: 7pt; color: var(--p2) !important; text-align: right; }
.prow .pt { text-align: center; border: 1px solid var(--pi); border-radius: 1.4mm; font-size: 8.6pt; padding: .1mm 0; background: var(--pb); }
.prow.locked { opacity: .45; }
.prow.locked .pn { text-decoration: line-through; }
.ppips { display: inline-flex; gap: .7mm; }
.ppips i { width: 2.1mm; height: 2.1mm; border: 1px solid var(--pi); border-radius: 50%; background: var(--pb); display: inline-block; }
.ppips i.on { background: var(--pg); border-color: var(--pg); }
.pcols { column-count: 2; column-gap: 6mm; }
.pitem { break-inside: avoid; margin-bottom: 2.2mm; }
.pitem b { font-size: 9pt; }
.pitem p { font-size: 7.8pt; color: var(--p3) !important; }
.ptag { font-size: 6.6pt; color: var(--pm) !important; text-transform: uppercase; letter-spacing: .05em; }
.pnone { color: var(--pm) !important; }
.pgear { width: 100%; border-collapse: collapse; font-size: 8pt; }
.pgear th { text-align: left; font-size: 6.4pt; text-transform: uppercase; letter-spacing: .08em; color: var(--pm) !important; border-bottom: 1px solid var(--pi); padding: .6mm 1mm; }
.pgear td { padding: .45mm 1mm; border-bottom: 1px dotted var(--pd); vertical-align: top; }
.pgear td:nth-child(n+4), .pgear th:nth-child(n+4) { text-align: right; white-space: nowrap; }
.pnote { font-size: 6.6pt; color: var(--p4) !important; }
.pother { margin-top: 1.6mm; font-size: 8pt; }
.pbg { font-size: 8pt; white-space: pre-wrap; }
.pnotes { flex: 1; display: flex; flex-direction: column; min-height: 18mm; }
.plines { flex: 1; background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 6.4mm, var(--pn) 6.4mm, var(--pn) calc(6.4mm + 1px)); border-radius: 1mm; }
.pfoot { margin-top: auto; display: flex; justify-content: space-between; font-size: 6.4pt; color: var(--pm) !important; text-transform: uppercase; letter-spacing: .08em; border-top: 1px solid var(--pl); padding-top: 1.2mm; }
@media print { .paper { box-shadow: none; } }
/* lobby header: one slim line instead of three */
.lobbyhead { display: flex; align-items: baseline; gap: .8rem; flex-wrap: wrap; margin: 0 0 .3rem; }
.lobbyhead h1 { font-size: 1.3rem; margin: 0; }
.lobbypage .tabs { margin: .4rem 0 .7rem; }
.lobbypage .tabs button { padding: .3rem .8rem; }
.tabs { align-items: center; }
.tabs .tabright { margin-left: auto; align-self: center; margin-bottom: .3rem; }
.tabs .tabright button { padding: .25rem .75rem; margin-bottom: 0; border-radius: 0; border-bottom: 1px solid var(--line); }
.tabs .tabright button.on { border-color: var(--noxium); }
.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; }
/* 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; }
}
/* the table: dice fly in from a random side and rest at a random spot */
.table { overflow: hidden; }
.dicetray { inset: 0; top: 0; left: 0; transform: none; display: block; max-width: none; }
.thrown { position: absolute; width: 2.9rem; height: 2.9rem; margin: -1.45rem 0 0 -1.45rem; transform: rotate(var(--er)); animation: throwin .8s cubic-bezier(.15, .7, .3, 1) both; filter: drop-shadow(0 4px 4px rgba(0, 0, 0, .35)); }
@keyframes throwin { from { transform: translate(var(--sx), var(--sy)) rotate(var(--sr)) scale(1.3); opacity: .2; } to { transform: rotate(var(--er)); opacity: 1; } }
.tablelog { position: absolute; left: .6rem; bottom: .5rem; display: grid; gap: .25rem; max-width: 70%; }
.tablelog .trayroll { padding: .2rem .6rem; border-radius: 8px; font-size: .85rem; background: color-mix(in srgb, var(--panel) 88%, transparent); border: 1px solid var(--line); border-top: 3px solid var(--line); box-shadow: none; }
@media (prefers-reduced-motion: reduce) { .thrown { animation: none; } }