Compare commits

...
4 Commits
Author SHA1 Message Date
Claude 162d8c9224 Lobby: WebSockets for real-time sync
Server pushes small change notes (messages / lobby / sheet) per lobby;
clients fetch data through the normal API so visibility rules stay in one
place. Polling stays as slow fallback. nginx WebSocket headers documented.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 13:41:02 +00:00
Claude 1c672f0ab8 Lobby: private rolls and checks for players, DM tab with lobby actions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 13:32:57 +00:00
Claude 78f0de5333 Rules editor: admins can edit items, skills, professions (and categories/groups) in the app
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-22 14:48:55 +00:00
Claude a8c9740a05 Skills: +-5 buttons, advanced skills use 50% of the attribute (creation.advanced_attr_percent)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-22 14:37:52 +00:00
16 changed files with 717 additions and 43 deletions
+2
View File
@@ -5,3 +5,5 @@ AW_REGISTRATION_CODE=
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
# Who may use the rules editor (comma separated usernames). Empty = only the first account that was ever created:
AW_ADMINS=
+18 -1
View File
@@ -51,7 +51,7 @@ docker compose up -d --build
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: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`.
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`; changes from the rules editor are in `./db/rules_overrides.json` (back up the whole `db` folder). Set `AW_ADMINS` in `.env` to choose who may use the editor (default: the first account).
## 4. nginx + HTTPS
@@ -70,10 +70,17 @@ server {
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, so clients cannot fake their address
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# the lobby uses a WebSocket for live updates (without these three lines it falls back to polling)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
```
Already running an older config? Add the last four lines inside `location /`, then `sudo nginx -t && sudo systemctl reload nginx`. Nothing else changes; the lobby still works without them, just with a few seconds of delay.
```bash
sudo ln -s /etc/nginx/sites-available/aw /etc/nginx/sites-enabled/aw
sudo nginx -t && sudo systemctl reload nginx
@@ -118,3 +125,13 @@ docker compose down # stop
```
If `docker` says it cannot connect to the daemon, check the two `export` lines from step 1 and `systemctl --user status docker`.
## Updating
```bash
cd ~/AWCharacterCreator
git pull
docker compose up -d --build
```
The `db` folder (database and rules editor changes) is kept. New settings from `.env.example` have to be added to your `.env` by hand.
+12 -1
View File
@@ -52,6 +52,7 @@ In the creator, the Concept page only has a "Rules preset" selector. Presets are
### Skills, proficiency and expertise
- `creation.advanced_attr_percent`: share of the attribute that counts for **advanced skills** (default 50, rounded down; 100 = full attribute like basic skills).
- `creation.advanced_unlock_cost`: pool points needed to unlock an advanced skill (free if the character's profession grants proficiency in it; proficiency picks do not unlock a skill).
- `creation.free_proficiencies`: free proficiency picks every character gets. Talents can add more with `"proficiency_picks": N` (see "Versatile Training").
- Proficiency is +`proficiency_bonus`; two picks on one skill (a profession proficiency counts as one) give Expertise, +`expertise_bonus`.
@@ -85,13 +86,22 @@ Each wizard page has a **Reset this page** button that clears only that page's d
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.
### Rules editor
Admins get an **Editor** tab to add, change and delete **items, item categories, skills, skill groups and professions** with forms. Admins are the usernames in `AW_ADMINS` (comma separated, set in `.env`); without that setting only the first account ever created is admin.
- Changes are saved in `db/rules_overrides.json` (next to the database, so it is part of your backup). For each edited list it **replaces** the list from `rules.json`; the shipped file is not touched. A previous copy is kept as `rules_overrides.json.bak`. "Reset to shipped" removes the override of one list.
- New entries get their id from the name (lowercase, `_`). Ids of existing entries cannot be changed, because characters refer to them.
- The server checks every reference: an item needs an existing category, a skill a group and attributes, a profession existing skills. Removing a category, group or skill that is still used is refused. Removing an item, skill or profession that saved characters use asks for confirmation first (their data stays, the entry disappears from the sheets).
- Talents, mutations, implants, classes and the rules text are still edited in `rules.json`. Players see changes after reloading the page.
### 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.
- **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. Everything in the lobby (chat, dice, difficulty, colours, sheets) updates live over a **WebSocket** (`/api/lobbies/{id}/ws`); it only sends a short "something changed" note and the data is fetched through the normal API, so the visibility rules (whispers, hidden numbers) stay in one place. If the socket is blocked or drops, the page reconnects and meanwhile polls every few seconds. The chat 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.
@@ -119,6 +129,7 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
| 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) |
| WebSocket | `/api/lobbies/{id}/ws` | Live notes `{t: messages / lobby / sheet}` for lobby members (cookie login, same origin) |
| 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 |
+2 -1
View File
@@ -39,8 +39,9 @@ def compute(ch: dict, rules: dict) -> dict:
unlocked = s["id"] in (ch.get("unlocked") or [])
usable = s["type"] == "basic" or unlocked or prof_pip > 0 # advanced skills must be unlocked (a profession proficiency does it for free)
best = max(attrs[a] for a in s["attrs"]) # with several attributes the higher one counts
base = best * cr.get("advanced_attr_percent", 100) // 100 if s["type"] == "advanced" else best # advanced skills only use part of the attribute
skills.append({**s, "points": pts, "prof": prof, "pips": pips, "usable": usable, "unlocked": unlocked,
"total": best + pts + prof})
"base": base, "total": base + pts + prof})
aug_by_id = {a["id"]: a for a in rules["augments"]}
chosen = [aug_by_id[i] for i in ch.get("augments", []) if i in aug_by_id]
+190 -10
View File
@@ -7,12 +7,15 @@ import time
import uuid
from pathlib import Path
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response
import asyncio
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import auth
from .derive import compute
from . import rules_edit
from .realtime import hub
from .export import to_docx, to_pdf
BASE = Path(__file__).resolve().parent.parent
@@ -37,15 +40,39 @@ 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)
limiter = auth.Limiter()
@app.on_event("startup")
async def _attach_hub():
hub.attach(asyncio.get_running_loop())
TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse proxy that sets X-Forwarded-For / -Proto
def load_rules() -> dict:
# Re-read on every request so rules.json can be edited without a restart.
# Changes made in the rules editor live next to the database (writable), the shipped rules.json stays untouched.
OVERRIDES_PATH = Path(DB_PATH).parent / "rules_overrides.json"
def base_rules() -> dict:
return json.loads(RULES_PATH.read_text(encoding="utf-8"))
def read_overrides() -> dict:
try:
return json.loads(OVERRIDES_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def load_rules() -> dict:
# Re-read on every request so rules.json can be edited without a restart; editor changes replace whole sections.
r = base_rules()
for sec, items in read_overrides().items():
if sec in rules_edit.SECTIONS:
r[sec] = items
return r
def db() -> sqlite3.Connection:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB_PATH)
@@ -147,13 +174,25 @@ async def security(request: Request, call_next):
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "same-origin"
host = request.headers.get("x-forwarded-host", "") if TRUST_PROXY else ""
host = re.sub(r"[^A-Za-z0-9.:\-\[\]]", "", host or request.headers.get("host", ""))
resp.headers["Content-Security-Policy"] = ("default-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; frame-ancestors 'none'")
f"img-src 'self' data: blob:; connect-src 'self' ws://{host} wss://{host}; frame-ancestors 'none'")
if request.url.path.startswith("/api/"):
resp.headers["Cache-Control"] = "no-store"
return resp
def is_admin(user: dict) -> bool:
"""Admins (rules editor): the names in AW_ADMINS, or without that setting the very first account."""
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
if names:
return user["username"].lower() in names
with db() as con:
first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
return bool(first) and first["id"] == user["id"]
def current_user(request: Request) -> dict:
with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
@@ -187,6 +226,8 @@ def check_password_rules(pw: str):
def me(request: Request):
with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
if user:
user = {**user, "admin": is_admin(user)}
return {"user": user, "registration_open": bool(auth.registration_code())}
@@ -213,7 +254,8 @@ def register(request: Request, response: Response, body: dict = Body(...)):
(username, auth.hash_password(password), time.time()))
token = auth.create_session(con, cur.lastrowid)
set_cookie(request, response, token)
return {"user": {"id": cur.lastrowid, "username": username}}
u = {"id": cur.lastrowid, "username": username}
return {"user": {**u, "admin": is_admin(u)}}
@app.post("/api/auth/login")
@@ -235,7 +277,8 @@ def login(request: Request, response: Response, body: dict = Body(...)):
token = auth.create_session(con, row["id"])
limiter.reset(keys[1])
set_cookie(request, response, token)
return {"user": {"id": row["id"], "username": row["username"]}}
u = {"id": row["id"], "username": row["username"]}
return {"user": {**u, "admin": is_admin(u)}}
@app.post("/api/auth/logout")
@@ -394,6 +437,9 @@ def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(curren
with db() as con:
_owned(con, "characters", cid, user)
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
for lid in lobbies:
hub.notify(lid, "sheet", cid=cid)
return {"ok": True}
@@ -467,6 +513,7 @@ 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()))
hub.notify(lid, "messages")
def _char_name(con, cid) -> str:
@@ -578,6 +625,7 @@ def set_difficulty(lid: str, body: dict = Body(...), user: dict = Depends(curren
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))
hub.notify(lid, "lobby")
return {"ok": True}
@@ -596,19 +644,20 @@ def set_member_mod(lid: str, username: str, body: dict = Body(...), user: dict =
(mod, lid, username))
if not cur.rowcount:
raise HTTPException(404, "No such player in this lobby")
hub.notify(lid, "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."""
"""User id to whisper to (the DM, a member or yourself for private rolls and notes), 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")
if not target:
raise HTTPException(422, "You can only whisper to people in this lobby")
return target["id"]
@@ -667,6 +716,7 @@ def send_message(lid: str, body: dict = Body(...), user: dict = Depends(current_
(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))
hub.notify(lid, "messages", None if to_id is None else {user["id"], to_id})
return {"id": cur.lastrowid}
@@ -692,6 +742,7 @@ def _post(con, lid: str, uid: int, to_id, kind: str, payload: dict) -> int:
(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))
hub.notify(lid, "messages", None if to_id is None else {uid, to_id})
return cur.lastrowid
@@ -762,7 +813,8 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us
payload = {"kind": kind, "what": what, "base": base, "mod": lobby["diff_mod"], "cmod": member["char_mod"], "diff": lobby["diff_label"],
"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)
private = str(body.get("to") or "") == user["username"] # a private check is only seen by the roller
mid = _post(con, lid, user["id"], user["id"] if private else None, "check", payload)
return {"id": mid, "roll": roll, "outcome": outcome}
@@ -785,6 +837,7 @@ def delete_lobby(lid: str, user: dict = Depends(current_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,))
hub.close_lobby(lid)
return {"ok": True}
@@ -815,6 +868,7 @@ def update_my_membership(lid: str, body: dict = Body(...), user: dict = Depends(
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"]))
hub.notify(lid, "lobby")
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"]))
@@ -829,6 +883,8 @@ def leave_lobby(lid: str, user: dict = Depends(current_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"])
if gone:
hub.close_user(lid, user["id"])
return {"ok": True}
@@ -840,6 +896,9 @@ def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
(lid, username)).rowcount
if gone:
_event(con, lid, f"{username} was removed by the DM", user["id"])
gone_id = con.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"]
if gone:
hub.close_user(lid, gone_id)
return {"ok": True}
@@ -883,6 +942,127 @@ def export(fmt: str, ch: dict = Body(...), user: dict = Depends(current_user)):
headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'})
# ---- rules editor (admins only)
def admin_user(user: dict = Depends(current_user)) -> dict:
if not is_admin(user):
raise HTTPException(403, "Only admins can edit the rules")
return user
@api.get("/admin/rules")
def editor_state(user: dict = Depends(admin_user)):
"""The editable sections as they are now, and which of them differ from the shipped rules.json."""
r = load_rules()
return {"sections": {k: r[k] for k in rules_edit.SECTIONS}, "customized": [k for k in read_overrides() if k in rules_edit.SECTIONS]}
def _all_characters(con) -> list:
return [json.loads(x["data"]) for x in con.execute("SELECT data FROM characters")]
def _save_overrides(data: dict):
OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True)
if OVERRIDES_PATH.exists():
OVERRIDES_PATH.with_suffix(".json.bak").write_bytes(OVERRIDES_PATH.read_bytes())
tmp = OVERRIDES_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, OVERRIDES_PATH)
def _apply(section: str, new: list, confirm: bool):
r = load_rules()
old_ids = {x["id"] for x in r[section]}
removed = old_ids - {x["id"] for x in new}
bad = rules_edit.dangling(section, new, r)
if bad:
raise HTTPException(422, "Cannot save: " + "; ".join(bad[:5]) + (" ..." if len(bad) > 5 else ""))
with db() as con:
used = rules_edit.usage(_all_characters(con), section, removed)
if used and not confirm:
raise HTTPException(409, f"{used} saved character{'s' if used != 1 else ''} use{'' if used != 1 else 's'} entries you are removing. "
"Their data stays in the characters but the removed entries disappear from the sheets.")
return used
@api.put("/admin/rules/{section}")
def editor_save(section: str, body: dict = Body(...), user: dict = Depends(admin_user)):
try:
new = rules_edit.clean(section, body.get("items"), load_rules())
except rules_edit.RuleError as e:
raise HTTPException(422, str(e))
_apply(section, new, bool(body.get("confirm")))
data = read_overrides()
data[section] = new
_save_overrides(data)
return {"ok": True, "count": len(new)}
@api.delete("/admin/rules/{section}")
def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin_user)):
"""Back to the shipped rules.json for this section."""
if section not in rules_edit.SECTIONS:
raise HTTPException(404, "Unknown section")
data = read_overrides()
if section in data:
_apply(section, base_rules()[section], confirm)
del data[section]
_save_overrides(data)
return {"ok": True}
@app.websocket("/api/lobbies/{lid}/ws")
async def lobby_socket(websocket: WebSocket, lid: str):
"""One socket per open lobby page. Only notes go out ("messages", "lobby", "sheet"), the data is fetched through the API."""
origin = websocket.headers.get("origin")
hosts = {websocket.headers.get("host", "")}
if TRUST_PROXY:
hosts.add(websocket.headers.get("x-forwarded-host", ""))
if origin and origin.split("://", 1)[-1] not in hosts:
await websocket.close(code=4403)
return
def check():
with db() as con:
user = auth.user_for_token(con, websocket.cookies.get(auth.SESSION_COOKIE))
if not user:
return None
lobby = con.execute("SELECT owner_id FROM lobbies WHERE id=?", (lid,)).fetchone()
ok = lobby and (lobby["owner_id"] == user["id"] or con.execute(
"SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone())
return user if ok else None
user = await asyncio.to_thread(check)
if not user:
await websocket.close(code=4401)
return
await websocket.accept()
if not hub.add(lid, websocket, user["id"]):
await websocket.close(code=4429)
return
try:
last_check = time.time()
while True:
try:
text = await asyncio.wait_for(websocket.receive_text(), timeout=70)
except asyncio.TimeoutError:
break # the client pings every 25 seconds, silence means it is gone
if text == "ping":
await websocket.send_text("pong")
if time.time() - last_check > 300: # session or membership may have ended
last_check = time.time()
if not await asyncio.to_thread(check):
break
except WebSocketDisconnect:
pass
finally:
hub.remove(lid, websocket)
try:
await websocket.close()
except Exception:
pass
app.include_router(api)
+79
View File
@@ -0,0 +1,79 @@
"""Real-time pushes for lobbies over WebSockets.
The sockets only carry short "something changed" notes ({"t": "messages"} or {"t": "lobby"}). The browser then loads the
data through the normal API, so all visibility rules (whispers, hidden rolls, who sees which character) stay in one place.
Without a working socket the client keeps polling, so nothing breaks when a proxy blocks WebSockets."""
import asyncio
import threading
MAX_PER_USER = 6 # open sockets per user (several tabs)
MAX_PER_LOBBY = 80
class Hub:
def __init__(self):
self.rooms: dict = {} # lobby id -> {websocket: user id}
self.loop = None
self.lock = threading.Lock()
def attach(self, loop):
self.loop = loop
def add(self, lid: str, ws, uid: int) -> bool:
with self.lock:
room = self.rooms.setdefault(lid, {})
if len(room) >= MAX_PER_LOBBY or sum(1 for u in room.values() if u == uid) >= MAX_PER_USER:
return False
room[ws] = uid
return True
def remove(self, lid: str, ws):
with self.lock:
room = self.rooms.get(lid)
if room:
room.pop(ws, None)
if not room:
del self.rooms[lid]
def online(self, lid: str) -> set:
with self.lock:
return set(self.rooms.get(lid, {}).values())
async def _send(self, lid: str, note: dict, users):
with self.lock:
targets = [ws for ws, uid in self.rooms.get(lid, {}).items() if users is None or uid in users]
for ws in targets:
try:
await ws.send_json(note)
except Exception:
self.remove(lid, ws)
def notify(self, lid: str, kind: str, users=None, delay: float = 0.08, **extra):
"""Callable from normal (threadpool) endpoints. The short delay lets the database transaction finish first."""
if not self.loop or lid not in self.rooms:
return
async def go():
await asyncio.sleep(delay)
await self._send(lid, {"t": kind, **extra}, users)
asyncio.run_coroutine_threadsafe(go(), self.loop)
def close_user(self, lid: str, uid: int):
"""Drops the sockets of someone who left or was removed."""
if not self.loop:
return
with self.lock:
targets = [ws for ws, u in self.rooms.get(lid, {}).items() if u == uid]
for ws in targets:
asyncio.run_coroutine_threadsafe(ws.close(code=4403), self.loop)
def close_lobby(self, lid: str):
if not self.loop:
return
with self.lock:
targets = list(self.rooms.get(lid, {}))
for ws in targets:
asyncio.run_coroutine_threadsafe(ws.close(code=4404), self.loop)
hub = Hub()
+125
View File
@@ -0,0 +1,125 @@
"""Validation for the rules editor (items, skills, professions and their categories).
Only whitelisted fields are kept and every reference (category, group, attribute, skill) has to exist."""
import re
SECTIONS = ("equipment", "equipment_categories", "skills", "skill_groups", "professions")
MAX_ENTRIES = 400
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
class RuleError(ValueError):
pass
def _text(v, limit, label, required=False):
v = str(v if v is not None else "").strip()
if required and not v:
raise RuleError(f"{label} must not be empty")
if len(v) > limit:
raise RuleError(f"{label} is too long (max {limit} characters)")
return v
def _num(v, lo, hi, label, integer=False):
try:
n = float(v)
except (TypeError, ValueError):
raise RuleError(f"{label} must be a number")
if not lo <= n <= hi:
raise RuleError(f"{label} must be between {lo} and {hi}")
return int(n) if integer else round(n, 2)
def _ids(v, allowed, label, lo=0, hi=50):
if not isinstance(v, list) or not lo <= len(v) <= hi:
raise RuleError(f"{label}: choose {lo} to {hi}" if hi < 50 else f"{label} must be a list")
out = []
for x in v:
if x not in allowed:
raise RuleError(f"{label}: unknown entry '{x}'")
if x not in out:
out.append(x)
return out
def clean(section: str, items, rules: dict) -> list:
"""Returns the cleaned list for one section. `rules` is the effective rules (for the other sections' ids)."""
if section not in SECTIONS:
raise RuleError("Unknown section")
if not isinstance(items, list) or len(items) > MAX_ENTRIES:
raise RuleError(f"A list of at most {MAX_ENTRIES} entries is expected")
attrs = {a["id"] for a in rules["attributes"]}
out, seen = [], set()
cats = {c["id"] for c in (items if section == "equipment_categories" else rules["equipment_categories"])}
groups = {g["id"] for g in (items if section == "skill_groups" else rules["skill_groups"])}
skills = {s["id"] for s in (items if section == "skills" else rules["skills"])}
for it in items:
if not isinstance(it, dict):
raise RuleError("Every entry has to be an object")
iid = str(it.get("id", ""))
if not ID_RE.match(iid):
raise RuleError(f"Invalid id '{iid}' (lowercase letters, digits and _ only, up to 40)")
if iid in seen:
raise RuleError(f"The id '{iid}' is used twice")
seen.add(iid)
name = _text(it.get("name"), 60, f"Name of '{iid}'", True)
e = {"id": iid, "name": name}
label = f"'{name}'"
if section == "equipment":
if it.get("cat") not in cats:
raise RuleError(f"{label}: unknown category")
e.update(cat=it["cat"], stat=_text(it.get("stat"), 40, f"{label} stat"), kg=_num(it.get("kg", 0), 0, 1000, f"{label} weight"),
note=_text(it.get("note"), 200, f"{label} note"))
if it.get("tier"):
e["tier"] = _text(it["tier"], 20, f"{label} tier")
elif section == "equipment_categories":
e["stat"] = _text(it.get("stat"), 40, f"{label} stat column")
elif section == "skills":
if it.get("group") not in groups:
raise RuleError(f"{label}: unknown group")
if it.get("type") not in ("basic", "advanced"):
raise RuleError(f"{label}: type must be basic or advanced")
a = _ids(it.get("attrs"), attrs, f"{label} attributes", 1, 3)
e.update(group=it["group"], attrs=a, type=it["type"], desc=_text(it.get("desc"), 300, f"{label} description"))
elif section == "professions":
e.update(desc=_text(it.get("desc"), 500, f"{label} description"), bonus_attrs=_ids(it.get("bonus_attrs"), attrs, f"{label} bonus attributes", 0, 7),
proficiencies=_ids(it.get("proficiencies"), skills, f"{label} proficiencies", 0, 12),
picks=_num(it.get("picks", 0), 0, 10, f"{label} free picks", True), note=_text(it.get("note"), 200, f"{label} note"))
if not e["note"]:
del e["note"]
if it.get("placeholder"):
e["placeholder"] = True
out.append(e)
return out
def dangling(section: str, new: list, rules: dict) -> list:
"""Things in the other sections that would point to an entry that no longer exists."""
ids = {x["id"] for x in new}
msgs = []
if section == "equipment_categories":
msgs += [f"Item '{e['name']}' uses the removed category" for e in rules["equipment"] if e["cat"] not in ids]
if section == "skill_groups":
msgs += [f"Skill '{s['name']}' uses the removed group" for s in rules["skills"] if s["group"] not in ids]
if section == "skills":
for p in rules["professions"]:
gone = [i for i in p["proficiencies"] if i not in ids]
if gone:
msgs.append(f"Profession '{p['name']}' grants proficiency in a removed skill ({', '.join(gone)})")
return msgs
def character_refs(ch: dict) -> dict:
"""Ids a saved character points to, per section."""
kit = ch.get("kit") or {}
items = {i for i in [kit.get("weapon"), kit.get("armor"), kit.get("tool"), *(kit.get("resources") or []), *(kit.get("extras") or {})] if i}
sk = set((ch.get("skill_points") or {}).keys()) | set((ch.get("proficiencies") or {}).keys()) | set((ch.get("proficiency_picks") or {}).keys()) \
| set(ch.get("unlocked") or []) | set(ch.get("profession_picks") or [])
return {"equipment": items, "skills": sk, "professions": {ch["profession"]} if ch.get("profession") else set()}
def usage(chars: list, section: str, removed: set) -> int:
"""How many saved characters use one of the removed ids."""
if section not in ("equipment", "skills", "professions") or not removed:
return 0
return sum(1 for ch in chars if character_refs(ch)[section] & removed)
+3 -1
View File
@@ -15,6 +15,7 @@
"skill_points_default": 150,
"skill_max_divisor": 2,
"advanced_unlock_cost": 10,
"advanced_attr_percent": 50,
"free_proficiencies": 2,
"max_proficiency_pips": 2,
"talents_min": 1,
@@ -41,6 +42,7 @@
{"key": "profession_attr_bonus", "label": "Profession attribute bonus", "group": "Profession", "min": 0, "max": 50},
{"key": "free_proficiencies", "label": "Free proficiency picks", "group": "Skills", "min": 0, "max": 20},
{"key": "skill_points_default", "label": "Skill point pool", "group": "Skills", "min": 0, "max": 1000},
{"key": "advanced_attr_percent", "label": "Advanced skills: share of the attribute that counts (%)", "group": "Skills", "min": 0, "max": 100},
{"key": "advanced_unlock_cost", "label": "Cost to unlock an advanced skill", "group": "Skills", "min": 0, "max": 100},
{"key": "skill_max_divisor", "label": "Skill maximum = attribute divided by", "group": "Skills", "min": 1, "max": 10},
{"key": "proficiency_bonus", "label": "Proficiency bonus", "group": "Skills", "min": 0, "max": 50},
@@ -434,7 +436,7 @@
"placeholder": true
},
{
"p": "The unlock cost for advanced skills (10 points) is a house rule of the app, not part of the brainstorm. It can be changed in rules.json (creation.advanced_unlock_cost).",
"p": "The unlock cost for advanced skills (10 points) and the rule that advanced skills only use half of the attribute are house rules of the app, not part of the brainstorm. They can be changed in rules.json (creation.advanced_unlock_cost, creation.advanced_attr_percent).",
"placeholder": true
},
{
+2
View File
@@ -12,6 +12,8 @@ services:
# Code that new users must enter to create an account (set it in the .env file next to this file).
# Empty = registration is disabled.
AW_REGISTRATION_CODE: ${AW_REGISTRATION_CODE:-}
# Who may use the rules editor: comma separated usernames. Empty = only the first account that was ever created.
AW_ADMINS: ${AW_ADMINS:-}
# Set to 1 when a reverse proxy (nginx, Caddy, Traefik) in front sets X-Forwarded-* headers.
AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0}
volumes:
+3 -1
View File
@@ -6,6 +6,7 @@ import Presets from './Presets.jsx'
import Lobbies from './Lobbies.jsx'
import Login from './Login.jsx'
import Account from './Account.jsx'
import Editor from './Editor.jsx'
import { claimLegacy } from './legacy.js'
const useHash = () => {
@@ -53,7 +54,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'], ['lobbies', 'Lobbies']]
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])]
return (
<>
@@ -81,6 +82,7 @@ export default function App() {
{rules && page === 'lobbies' && (
<Lobbies section={hash.split('/')[1]} rules={rules} user={username} onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
)}
{rules && page === 'editor' && auth.user.admin && <Editor rules={rules} onReload={loadRules} />}
{page === 'account' && <Account user={username} onLogout={logout} />}
</main>
</>
+4 -2
View File
@@ -314,9 +314,11 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
<button className="ghost small-btn" title={`Lock again: you get ${c.unlockCost} points back, its points and proficiency picks are returned`}
onClick={() => setCh((x) => ({ ...x, unlocked: x.unlocked.filter((id) => id !== s.id), skill_points: { ...x.skill_points, [s.id]: 0 }, proficiency_picks: { ...x.proficiency_picks, [s.id]: 0 } }))}>Lock</button>
)}
<button onClick={() => setPts(s.points - 1)} aria-label={`${s.name} minus`}>−</button>
<button className="step5" onClick={() => setPts(s.points - 5)} disabled={s.points <= 0} aria-label={`${s.name} minus 5`}>−5</button>
<button onClick={() => setPts(s.points - 1)} disabled={s.points <= 0} aria-label={`${s.name} minus`}>−</button>
<span className="val">{s.points}</span>
<button onClick={() => setPts(s.points + 1)} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
<button onClick={() => setPts(Math.min(s.points + 1, s.max, s.points + c.skillLeft))} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus`}>+</button>
<button className="step5" onClick={() => setPts(Math.min(s.points + 5, s.max, s.points + c.skillLeft))} disabled={s.points >= s.max || c.skillLeft <= 0} aria-label={`${s.name} plus 5`}>+5</button>
<Tip text={explainSkill(s, ctx)}><span className="mod">{s.total}</span></Tip><span className="small muted" title="Most points you can add"> max {s.max}</span>
</div>
)}
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, useMemo, useState } from 'react'
const J = { 'Content-Type': 'application/json' }
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40)
// What can be edited: fields per entry and the columns of the list
const defs = (saved, attrs) => {
const cats = (saved.equipment_categories || []).map((c) => [c.id, c.name])
const groups = (saved.skill_groups || []).map((g) => [g.id, g.name])
const skills = (saved.skills || []).map((s) => [s.id, s.name])
const name = (list, id) => (list.find(([k]) => k === id) || [])[1] || id
return {
equipment: {
label: 'Items', noun: 'item',
fields: [['name', 'Name', 'text'], ['cat', 'Category', 'select', cats], ['stat', 'Stat (damage, armor ...)', 'text'], ['tier', 'Tier (optional, e.g. light)', 'text'],
['kg', 'Weight (kg)', 'number'], ['note', 'Note', 'text'], ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
blank: { cat: cats[0]?.[0] || '', stat: '', kg: 1, note: '' },
cols: [['Name', (e) => e.name], ['Category', (e) => name(cats, e.cat)], ['Stat', (e) => `${e.stat}${e.tier ? ` (${e.tier})` : ''}`], ['kg', (e) => e.kg]],
},
equipment_categories: {
label: 'Item categories', noun: 'category',
fields: [['name', 'Name', 'text'], ['stat', 'Name of the stat column', 'text']],
blank: { stat: '' }, cols: [['Name', (e) => e.name], ['Stat column', (e) => e.stat]],
},
skills: {
label: 'Skills', noun: 'skill',
fields: [['name', 'Name', 'text'], ['group', 'Group', 'select', groups], ['type', 'Type', 'select', [['basic', 'Basic'], ['advanced', 'Advanced']]],
['attrs', 'Attributes (the higher one counts)', 'multi', attrs], ['desc', 'Description', 'textarea'], ['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
blank: { group: groups[0]?.[0] || '', type: 'basic', attrs: [], desc: '' },
cols: [['Name', (e) => e.name], ['Group', (e) => name(groups, e.group)], ['Type', (e) => e.type], ['Attributes', (e) => e.attrs.map((a) => name(attrs, a)).join(' / ')]],
},
skill_groups: {
label: 'Skill groups', noun: 'group', fields: [['name', 'Name', 'text']], blank: {}, cols: [['Name', (e) => e.name]],
},
professions: {
label: 'Professions', noun: 'profession',
fields: [['name', 'Name', 'text'], ['desc', 'Description', 'textarea'], ['bonus_attrs', 'Attributes that can get the profession bonus', 'multi', attrs],
['proficiencies', 'Skills with fixed proficiency', 'multi', skills], ['picks', 'Free proficiency picks', 'number'], ['note', 'Note', 'text'],
['placeholder', 'Placeholder (still open in the brainstorm)', 'bool']],
blank: { desc: '', bonus_attrs: [], proficiencies: [], picks: 0 },
cols: [['Name', (e) => e.name], ['Bonus', (e) => e.bonus_attrs.map((a) => name(attrs, a)).join(', ')], ['Proficiencies', (e) => e.proficiencies.map((s) => name(skills, s)).join(', ')], ['Picks', (e) => e.picks]],
},
}
}
function Field({ f, value, onChange }) {
const [key, label, type, opts] = f
if (type === 'bool') return <label className="small"><input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} style={{ width: 'auto' }} /> {label}</label>
return (
<label className="edfield">
<span className="small muted">{label}</span>
{type === 'text' && <input value={value ?? ''} onChange={(e) => onChange(e.target.value)} />}
{type === 'number' && <input type="number" step="any" value={value ?? ''} onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))} />}
{type === 'textarea' && <textarea rows={3} value={value ?? ''} onChange={(e) => onChange(e.target.value)} />}
{type === 'select' && <select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>{opts.map(([k, n]) => <option key={k} value={k}>{n}</option>)}</select>}
{type === 'multi' && (
<span className="multi">
{opts.map(([k, n]) => (
<label key={k} className="small"><input type="checkbox" style={{ width: 'auto' }} checked={(value || []).includes(k)}
onChange={(e) => onChange(e.target.checked ? [...(value || []), k] : (value || []).filter((x) => x !== k))} /> {n}</label>
))}
</span>
)}
</label>
)
}
export default function Editor({ rules, onReload }) {
const [state, setState] = useState(null)
const [tab, setTab] = useState('equipment')
const [drafts, setDrafts] = useState({}) // section -> unsaved list
const [editing, setEditing] = useState('')
const [q, setQ] = useState('')
const [msg, setMsg] = useState({ text: '', bad: false })
const [busy, setBusy] = useState(false)
const load = () => fetch('/api/admin/rules').then(async (r) => {
if (!r.ok) throw new Error(r.status)
setState(await r.json())
}).catch((e) => setMsg({ text: String(e.message) === '403' ? 'Only admins can use the rules editor.' : 'Could not load the editor.', bad: true }))
useEffect(() => { load() }, [])
const D = useMemo(() => state && defs(state.sections, rules.attributes.map((a) => [a.id, a.name])), [state, rules.attributes])
if (!state) return <section className="page"><p className={msg.bad ? 'warn' : 'muted'}>{msg.text || 'Loading …'}</p></section>
const d = D[tab]
const saved = state.sections[tab]
const list = drafts[tab] || saved
const dirty = !!drafts[tab]
const setList = (l) => setDrafts((x) => ({ ...x, [tab]: l }))
const patch = (id, key, v) => setList(list.map((e) => {
if (e.id !== id) return e
const n = { ...e, [key]: v }
if (e._new && key === 'name') { // new entries get their id from the name
let base = slug(v) || 'entry', cand = base, i = 2
while (list.some((x) => x !== e && x.id === cand)) cand = `${base}_${i++}`
const old = e.id
n.id = cand
if (editing === old) setEditing(cand)
}
return n
}))
const add = () => {
let id = 'new_entry', i = 2
while (list.some((x) => x.id === id)) id = `new_entry_${i++}`
setList([...list, { id, name: '', ...d.blank, _new: true }])
setEditing(id); setQ('')
}
const del = (e) => { if (editing === e.id) setEditing(''); setList(list.filter((x) => x.id !== e.id)) }
const save = async (confirm = false) => {
setBusy(true); setMsg({ text: '', bad: false })
const items = list.map(({ _new, ...e }) => e)
const r = await fetch(`/api/admin/rules/${tab}`, { method: 'PUT', headers: J, body: JSON.stringify({ items, confirm }) }).catch(() => null)
const body = r ? await r.json().catch(() => ({})) : {}
setBusy(false)
if (r && r.status === 409 && window.confirm(`${body.detail}\n\nSave anyway?`)) return save(true)
if (!r || !r.ok) return setMsg({ text: typeof body.detail === 'string' ? body.detail : 'Could not save.', bad: true })
setDrafts((x) => { const n = { ...x }; delete n[tab]; return n })
setEditing(''); setMsg({ text: `Saved ${body.count} entries. Everybody sees the change after reloading the page.`, bad: false })
await load(); onReload?.()
}
const reset = async (confirm = false) => {
if (!confirm && !window.confirm(`Throw away all your changes to "${d.label}" and go back to the shipped rules?`)) return
const r = await fetch(`/api/admin/rules/${tab}?confirm=${confirm}`, { method: 'DELETE' }).catch(() => null)
const body = r ? await r.json().catch(() => ({})) : {}
if (r && r.status === 409 && window.confirm(`${body.detail}\n\nReset anyway?`)) return reset(true)
if (!r || !r.ok) return setMsg({ text: typeof body.detail === 'string' ? body.detail : 'Could not reset.', bad: true })
setDrafts((x) => { const n = { ...x }; delete n[tab]; return n })
setMsg({ text: 'Back to the shipped rules.', bad: false })
await load(); onReload?.()
}
const shown = list.filter((e) => !q || e.name.toLowerCase().includes(q.toLowerCase()) || e.id.includes(q.toLowerCase()))
const cur = list.find((e) => e.id === editing)
return (
<section className="page editor">
<h1>Rules editor</h1>
<p className="muted small">Changes are stored next to the database and replace the entries of rules.json for that list; the shipped file stays as it is.
Existing characters keep working. Talents, mutations and implants are still edited in rules.json.</p>
<div className="tabs" role="tablist">
{Object.entries(D).map(([id, x]) => (
<button key={id} role="tab" aria-selected={tab === id} className={tab === id ? 'on' : ''} onClick={() => { setTab(id); setEditing(''); setQ(''); setMsg({ text: '', bad: false }) }}>
{x.label}{drafts[id] ? ' *' : ''}{state.customized.includes(id) ? ' •' : ''}
</button>
))}
</div>
<div className="row wrapform">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${d.label.toLowerCase()} …`} aria-label="Search" style={{ maxWidth: '18rem' }} />
<span className="muted small">{list.length} entries{state.customized.includes(tab) ? ' · customised (•)' : ' · as shipped'}</span>
<span className="actions">
<button onClick={add}>+ Add {d.noun}</button>
<button onClick={() => save()} disabled={!dirty || busy}>Save changes</button>
<button className="ghost" onClick={() => { setDrafts((x) => { const n = { ...x }; delete n[tab]; return n }); setEditing('') }} disabled={!dirty}>Discard</button>
<button className="ghost danger" onClick={() => reset()} disabled={!state.customized.includes(tab)}>Reset to shipped</button>
</span>
</div>
{msg.text && <p className={msg.bad ? 'warn' : 'small'} role={msg.bad ? 'alert' : 'status'}>{msg.text}</p>}
{cur && (
<div className="card edform">
<div className="row"><strong>{cur.name || `New ${d.noun}`}</strong><span className="muted small">id: {cur.id}{cur._new ? ' (from the name)' : ''}</span></div>
<div className="edgrid">
{d.fields.map((f) => <Field key={f[0]} f={f} value={cur[f[0]]} onChange={(v) => patch(cur.id, f[0], v)} />)}
</div>
<div className="actions"><button className="ghost" onClick={() => setEditing('')}>Done</button></div>
</div>
)}
<div className="tablewrap"><table className="edtable">
<thead><tr>{d.cols.map(([h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
<tbody>
{shown.map((e) => (
<tr key={e.id} className={editing === e.id ? 'sel' : ''}>
{d.cols.map(([h, fn]) => <td key={h}>{fn(e)}{h === 'Name' && e.placeholder ? <span className="badge">Placeholder</span> : ''}</td>)}
<td className="actions"><button className="ghost" onClick={() => setEditing(e.id)}>Edit</button><button className="ghost danger" onClick={() => del(e)}>Delete</button></td>
</tr>
))}
{shown.length === 0 && <tr><td colSpan={d.cols.length + 1} className="muted">Nothing found.</td></tr>}
</tbody>
</table></div>
</section>
)
}
+77 -24
View File
@@ -109,8 +109,40 @@ 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!' }
// One WebSocket per open lobby. It only says "something changed" ({t: 'messages' | 'lobby' | 'sheet'}), the data is loaded through
// the normal API. Without a working socket the page falls back to polling, so a proxy that blocks WebSockets does not break anything.
function useSocket(id, handlers) {
const [up, setUp] = useState(false)
const h = useRef(handlers)
h.current = handlers
useEffect(() => {
let ws, timer, ping, tries = 0, closed = false
const open = () => {
ws = new WebSocket(`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/api/lobbies/${id}/ws`)
ws.onopen = () => {
tries = 0; setUp(true); h.current.open?.()
ping = setInterval(() => ws.readyState === 1 && ws.send('ping'), 25000)
}
ws.onmessage = (e) => {
if (e.data === 'pong') return
try { const n = JSON.parse(e.data); h.current[n.t]?.(n) } catch { /* ignore malformed notes */ }
}
ws.onclose = (e) => {
clearInterval(ping); setUp(false)
if (closed || (e.code >= 4400 && e.code < 4500)) return // refused or removed: the normal polling shows why
timer = setTimeout(open, Math.min(10000, 1000 * 2 ** tries++))
}
}
try { open() } catch { /* no WebSocket support: polling only */ }
const wake = () => { if (!document.hidden) h.current.open?.() } // catch up after the tab was in the background
document.addEventListener('visibilitychange', wake)
return () => { closed = true; clearTimeout(timer); clearInterval(ping); document.removeEventListener('visibilitychange', wake); ws?.close() }
}, [id])
return up
}
// Chat, whispers, event log, dice rolls and checks arrive through one feed that is polled while the page is open.
function useFeed(id, onEvent, onRoll) {
function useFeed(id, onEvent, onRoll, socketUp) {
const [msgs, setMsgs] = useState([])
const last = useRef(0)
const live = useRef(false) // false until the history is loaded: old rolls are not animated again
@@ -134,9 +166,9 @@ function useFeed(id, onEvent, onRoll) {
}, [id])
useEffect(() => {
poll()
const t = setInterval(poll, 2500)
const t = setInterval(poll, socketUp ? 20000 : 2500) // the socket says when something is new, the timer is only a safety net
return () => clearInterval(t)
}, [poll])
}, [poll, socketUp])
return { msgs, poll }
}
@@ -177,7 +209,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
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>}
{m.to && <em className="small whispertag"> {m.to === m.from ? '(private, only you)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
</>
)
@@ -216,6 +248,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
<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>
<option value={me}>Only me (private)</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" />
@@ -243,7 +276,7 @@ function DieIcon({ sides }) {
const MODES = [['mystery', 'Mystery', 'Players see grey dice, but not the result'], ['hidden', 'Hidden', 'Only you see the roll'], ['public', 'Public', 'Everybody sees the roll']]
function DiceBar({ to, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
return (
<div className="dicebar" role="toolbar" aria-label="Dice">
<label className="small muted dicecount">Dice
@@ -264,7 +297,7 @@ function DiceBar({ to, isDm, count, setCount, mode, setMode, own, setOwn, onRoll
{MODES.map(([k, label, tip]) => <button key={k} className={mode === k ? 'on' : 'ghost'} title={tip} onClick={() => setMode(k)}>{label}</button>)}
</span>
)}
{to && <span className="small dicedest priv">Private: {to}</span>}
{to && <span className="small dicedest priv">{to === me ? 'Private: only you' : `Private: ${to}`}</span>}
</div>
)
}
@@ -346,7 +379,7 @@ function DiceTray({ rolls }) {
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>
return <div key={m.key} className="trayroll">{who} – {c.what}{c.manual ? ' · own dice' : ''}{m.to ? ' · only you' : ''}: <span className={`out ${c.outcome}`}>{OUTCOME[c.outcome]}</span>{c.target !== undefined && <small> ({c.roll} vs {c.target})</small>}</div>
}
const t = rollText(m)
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' : ''}
@@ -410,7 +443,7 @@ function DifficultyBar({ lobby, onSet }) {
}
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
function CharTab({ cid, rules, onRoll }) {
function CharTab({ cid, rules, onRoll, tick }) {
const [data, setData] = useState(null)
const [err, setErr] = useState('')
useEffect(() => {
@@ -420,6 +453,9 @@ function CharTab({ cid, rules, onRoll }) {
const t = setInterval(() => !document.hidden && load(), 15000)
return () => clearInterval(t)
}, [cid])
useEffect(() => { // pushed by the server when the player saved the character
if (tick) fetch(`/api/characters/${cid}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setData(d)).catch(() => {})
}, [tick]) // eslint-disable-line
return (
<div>
{err && <p className="warn">{err}</p>}
@@ -429,14 +465,14 @@ function CharTab({ cid, rules, onRoll }) {
}
// lobby settings: join code, rename, colour, character switch, members, leave / delete
function Settings({ lobby, id, mine, act, setInfo, onClose }) {
function Settings({ lobby, id, mine, act, setInfo, onClose, inline }) {
const [rename, setRename] = useState('')
const [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>
<div className={inline ? 'settings inline' : 'settings card'}>
{!inline && <div className="row"><h2>Lobby</h2><button className="ghost" onClick={onClose}>Close</button></div>}
{lobby.is_dm && (
<>
<div className="row">
@@ -505,6 +541,7 @@ function LobbyView({ id, rules, user }) {
const [busy, setBusy] = useState(false)
const [mErr, setMErr] = useState('')
const [showSettings, setShowSettings] = useState(false)
const [leftTab, setLeftTab] = useState('sheet') // the DM's left column: character sheet or lobby actions
const [sheetOpen, setSheetOpen] = useState(() => store('aw_sheetopen') !== '0')
const setOwn = (v) => { setOwnState(v); store('aw_owndice', v ? '1' : '0') }
@@ -512,18 +549,27 @@ function LobbyView({ id, rules, user }) {
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])
const sock = useRef({})
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
const up = useSocket(id, {
open: () => { sock.current.load?.(); sock.current.poll?.() },
messages: () => sock.current.poll?.(),
lobby: () => sock.current.load?.(),
sheet: (n) => setSheetTick((t) => ({ cid: n.cid, n: t.n + 1 })),
})
useEffect(() => {
load()
const t = setInterval(() => !document.hidden && load(), 5000) // picks up joins, colours, difficulty and character changes
const t = setInterval(() => !document.hidden && load(), up ? 30000 : 5000) // joins, colours, difficulty: pushed, with a timer as safety net
return () => clearInterval(t)
}, [load])
}, [load, up])
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 feed = useFeed(id, load, onRoll, up)
sock.current = { load, poll: feed.poll }
const act = async (url, method, body, done) => {
if (!url) return load()
@@ -560,10 +606,10 @@ function LobbyView({ id, rules, user }) {
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 })
post(`/api/lobbies/${id}/checks`, { kind, id: cid, to: dest === user ? user : '' })
}
const submitManual = (vals) => {
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0] }, () => setPending(null))
if (pending.type === 'check') post(`/api/lobbies/${id}/checks`, { kind: pending.kind, id: pending.id, manual: vals[0], to: dest === user ? user : '' }, () => setPending(null))
else post(`/api/lobbies/${id}/rolls`, { sides: pending.sides, count: pending.count, to: dest, manual: vals, ...(lobby.is_dm ? { mode } : {}) }, () => setPending(null))
}
@@ -574,7 +620,7 @@ function LobbyView({ id, rules, user }) {
<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>
<button className="ghost" onClick={() => (lobby.is_dm ? (setSheetOpen(true), setLeftTab('lobby')) : setShowSettings((v) => !v))} aria-expanded={lobby.is_dm ? sheetOpen && leftTab === 'lobby' : showSettings}>⚙ Lobby</button>
</span>
</div>
{err && <p className="warn">{err}</p>}
@@ -584,26 +630,33 @@ function LobbyView({ id, rules, user }) {
{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>
{lobby.is_dm ? (
<span className="seg" role="tablist">
<button role="tab" className={leftTab === 'sheet' ? 'on' : 'ghost'} aria-selected={leftTab === 'sheet'} onClick={() => setLeftTab('sheet')}>Sheet{cur ? `: ${cur.character.name}` : ''}</button>
<button role="tab" className={leftTab === 'lobby' ? 'on' : 'ghost'} aria-selected={leftTab === 'lobby'} onClick={() => setLeftTab('lobby')}>Lobby actions</button>
</span>
) : <b>{cur ? cur.character.name : 'Character sheet'}</b>}
<span className="tabright">{(!lobby.is_dm || leftTab === 'sheet') && <ThemeSwitch />} <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide this column">◀</button></span>
</div>
{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>}
{lobby.is_dm && leftTab === 'lobby'
? <Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} />
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} 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} />
<CharBar lobby={lobby} sel={cur?.username} onSel={(u) => { setSel(u); setLeftTab('sheet') }} />
<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)} />}
{showSettings && !lobby.is_dm && <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} />
<DiceBar to={dest} me={user} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
</div>
<aside className="lobbychat card" aria-label="Chat">
+2 -1
View File
@@ -124,8 +124,9 @@ export function compute(ch, rules) {
const pips = profPip + freePips
const prof = [0, cr.proficiency_bonus, cr.expertise_bonus][pips]
const best = Math.max(...s.attrs.map((a) => attrs[a])) // with several attributes the higher one counts
const base = s.type === 'advanced' ? Math.floor((best * (cr.advanced_attr_percent ?? 100)) / 100) : best // advanced skills only use part of the attribute
const unlocked = ch.unlocked.includes(s.id)
return { ...s, points, prof, profPip, freePips, pips, unlocked, total: best + points + prof,
return { ...s, points, prof, profPip, freePips, pips, unlocked, base, total: base + points + prof,
usable: s.type === 'basic' || unlocked || profPip > 0, // advanced skills must be unlocked (a profession proficiency does it for free)
free: s.type === 'advanced' && profPip > 0, // unlocked by the profession, costs nothing
max: Math.floor(best / cr.skill_max_divisor) }
+1 -1
View File
@@ -29,7 +29,7 @@ export function explainSkill(s, { ch, rules, c }) {
const list = s.attrs.map((id) => `${attrShort(rules, id)} ${c.attrs[id]}`).join(', ')
const training = s.pips === 2 ? `Expertise +${s.prof}` : s.pips === 1 ? `Proficient +${s.prof}` : 'no training +0'
const lines = [
`Target number = best attribute ${best} (${s.attrs.length > 1 ? `higher of ${list}` : list}) + ${s.points} skill points + ${training} = ${s.total}`,
`Target number = ${s.type === 'advanced' && s.base !== best ? `${cr.advanced_attr_percent}% of the best attribute (${best}) = ${s.base}` : `best attribute ${best}`} (${s.attrs.length > 1 ? `higher of ${list}` : list}) + ${s.points} skill points + ${training} = ${s.total}`,
`Most points you can put in: best attribute ÷ ${cr.skill_max_divisor}, rounded down = ${s.max}`,
]
if (s.type === 'advanced') {
+12
View File
@@ -384,3 +384,15 @@ body:has(.lobbychat) main { padding: .5rem calc(var(--chatw) + .5rem) 0 .5rem; }
.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; } }
.stepper .step5 { font-size: .75rem; padding: .25rem .4rem; min-width: 2.2rem; }
/* rules editor */
.editor .edform { margin: .8rem 0; display: grid; gap: .6rem; }
.edgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: .6rem .9rem; }
.edfield { display: grid; gap: .2rem; }
.edfield .multi { display: flex; flex-wrap: wrap; gap: .3rem .9rem; padding: .3rem 0; max-height: 11rem; overflow: auto; }
.edtable td.actions { text-align: right; white-space: nowrap; }
.edtable tr.sel { background: var(--panel2); }
.editor .tabs { margin-top: .6rem; }
.settings.inline { position: static; max-width: none; margin: 0; border: 0; background: transparent; padding: 0; }