From 1db8a45cae794915737c7267f5bc1adc3d72b1cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 21:36:09 +0000 Subject: [PATCH] NPC editor, NPC tokens from library, admin management - NPC library (own + shared) built on the character wizard with a free NPC preset - NPC tokens in map editor and lobby, DM sheet on token click, DM checks for NPCs - Admins can promote/demote other users Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K --- README.md | 15 +++- backend/app/main.py | 143 +++++++++++++++++++++++++++++------- backend/app/maps.py | 57 +++++++++++--- backend/data/rules.json | 1 + frontend/src/App.jsx | 8 +- frontend/src/Characters.jsx | 41 ++++++++--- frontend/src/Creator.jsx | 57 +++++++++----- frontend/src/Editor.jsx | 37 ++++++++++ frontend/src/Lobbies.jsx | 28 ++++--- frontend/src/MapView.jsx | 39 +++++++++- frontend/src/PaperSheet.jsx | 7 ++ frontend/src/Presets.jsx | 2 +- frontend/src/styles.css | 15 ++++ 13 files changed, 364 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index f47829e..0450c3b 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ On the **Concept** page *Add picture* takes a PNG, JPEG or WebP: move and zoom i ### 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. +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. Admins can also promote other users: the **Users and admins** card above the tabs lists all accounts with *Make admin* / *Remove admin*. Names in `AW_ADMINS` stay admin and can only be changed in `.env`; the last admin cannot be removed. - 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. @@ -116,6 +116,14 @@ The lobby page is built for desktop with three columns: the **character sheet** - **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. +### NPCs, enemies and creatures + +The **NPCs** tab is a library of rules-based NPCs. It uses the same wizard as the character creator (attributes, skills, talents, mutations, equipment, portrait), with a free "NPC / creature" preset that has large pools and no profession requirement, plus a type (NPC, enemy, monster, creature, animal, robot), a default token size, a description, special abilities and attacks, and loot. + +- NPCs are stored like characters (flag `npc`) and are listed separately; they can be duplicated and shared with other DMs (read-only). +- In the map editor and at the table, **+ From NPC library** / **+ NPC** places a token that takes the NPC's name, size and portrait. Plain **+ Token** still works. +- The DM clicks an NPC token to see its sheet in the left column and can click attributes and skills to roll checks for it (shown in the chat as "NPC: Skill"). Players never see the NPC link or sheet. + ### Maps, tokens and line of sight - **Maps tab** (top navigation): all your maps with preview. Upload PNG, JPEG or WebP (up to 12 MB, 30 maps each; the server checks and re-encodes every image as WebP, max 4096 px per side, into `db/maps/`). **Edit** opens the map editor, **Share** gives other users access by username (like characters: they can put the map on their own table or **Copy** it, but not change it), **Duplicate** copies a map. @@ -135,7 +143,10 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l | GET | `/api/auth/me` | Current user (or `null`) | | 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 / POST | `/api/characters[?npc=1]` | Own and shared-with-me characters (or the NPC library) / create one | +| GET | `/api/characters/{id}/portrait` | Portrait image (readers of the character) | +| GET | `/api/lobbies/{id}/token-portrait/{token}` | Portrait of an NPC token (lobby members) | +| GET / PUT | `/api/admin/users[/{username}]` | List accounts / make or remove an admin (admins) | | 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) | diff --git a/backend/app/main.py b/backend/app/main.py index 7e768ad..f73783a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -103,6 +103,10 @@ def db() -> sqlite3.Connection: ("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}") + if "is_admin" not in [r["name"] for r in con.execute("PRAGMA table_info(users)")]: + con.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0") + if "npc" not in [r["name"] for r in con.execute("PRAGMA table_info(characters)")]: + con.execute("ALTER TABLE characters ADD COLUMN npc INTEGER NOT NULL DEFAULT 0") maps_mod.schema(con) sounds_mod.schema(con) for table in ("characters", "presets"): # older databases: add the owner column @@ -188,14 +192,21 @@ async def security(request: Request, call_next): return resp -def is_admin(user: dict) -> bool: - """Admins (rules editor): the names in AW_ADMINS, or without that setting the very first account.""" +def admin_ids(con) -> set: + """Admins: the names in AW_ADMINS, accounts an admin promoted, and (when there is neither) 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: + ids = {r["id"] for r in con.execute("SELECT id, username FROM users") if r["username"].lower() in names} + ids |= {r["id"] for r in con.execute("SELECT id FROM users WHERE is_admin=1")} + if not ids: first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone() - return bool(first) and first["id"] == user["id"] + if first: + ids.add(first["id"]) + return ids + + +def is_admin(user: dict) -> bool: + with db() as con: + return user["id"] in admin_ids(con) def current_user(request: Request) -> dict: @@ -366,23 +377,35 @@ def _name(data: str) -> str: return json.loads(data).get("name") or "Nameless" +def _entry(r, shares=None, owner=None) -> dict: + ch = json.loads(r["data"]) + e = {"id": r["id"], "name": ch.get("name") or "Nameless", "updated": r["updated"], "portrait": portrait_mod.tag(ch.get("portrait"))} + if r["npc"]: + e["kind"] = str(ch.get("npc_kind") or "") + e["size"] = ch.get("token_size", 1) + if shares is not None: + e["shared_with"] = shares.get(r["id"], []) + if owner is not None: + e["owner"] = owner or "?" + return e + + @api.get("/characters") -def my_characters(user: dict = Depends(current_user)): +def my_characters(npc: int = 0, user: dict = Depends(current_user)): + """Your characters and the ones shared with you. With ?npc=1 the NPC library (NPCs, enemies, creatures) instead.""" + flag = 1 if npc else 0 with db() as con: - rows = con.execute("SELECT id, data, updated FROM characters WHERE owner_id=? ORDER BY updated DESC", - (user["id"],)).fetchall() + rows = con.execute("SELECT id, data, updated, npc FROM characters WHERE owner_id=? AND npc=? ORDER BY updated DESC", + (user["id"], flag)).fetchall() shares: dict = {} for r in con.execute("""SELECT s.character_id, u.username FROM shares s JOIN users u ON u.id = s.user_id - JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? ORDER BY u.username""", - (user["id"],)): + JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? AND c.npc=? ORDER BY u.username""", + (user["id"], flag)): shares.setdefault(r["character_id"], []).append(r["username"]) - shared = con.execute("""SELECT c.id, c.data, c.updated, o.username AS owner FROM shares s + shared = con.execute("""SELECT c.id, c.data, c.updated, c.npc, o.username AS owner FROM shares s JOIN characters c ON c.id = s.character_id LEFT JOIN users o ON o.id = c.owner_id - WHERE s.user_id=? ORDER BY c.updated DESC""", (user["id"],)).fetchall() - return {"mine": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "shared_with": shares.get(r["id"], [])} - for r in rows], - "shared": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "owner": r["owner"] or "?"} - for r in shared]} + WHERE s.user_id=? AND c.npc=? ORDER BY c.updated DESC""", (user["id"], flag)).fetchall() + return {"mine": [_entry(r, shares=shares) for r in rows], "shared": [_entry(r, owner=r["owner"]) for r in shared]} @api.post("/characters") @@ -393,20 +416,39 @@ def create_character(ch: dict = Body(...), user: dict = Depends(current_user)): if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS: raise HTTPException(409, "Character limit reached") cid, now = uuid.uuid4().hex[:12], time.time() - con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id) VALUES (?,?,?,?,?,?)", - (cid, "", json.dumps(ch), now, now, user["id"])) + con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id, npc) VALUES (?,?,?,?,?,?,?)", + (cid, "", json.dumps(ch), now, now, user["id"], 1 if ch.get("npc") else 0)) return {"id": cid} +def _can_read_character(con, cid: str, user: dict) -> bool: + """The owner, users the character is shared with, the DM of a lobby it plays in, and the DM of a lobby with a token of this NPC.""" + row = con.execute("SELECT owner_id FROM characters WHERE id=?", (cid,)).fetchone() + return bool(row) and bool(row["owner_id"] == user["id"] or con.execute( + "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() or con.execute( + """SELECT 1 FROM lobby_tokens t JOIN lobbies l ON l.id = t.lobby_id + WHERE t.npc_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone()) + + +@api.get("/characters/{cid}/portrait") +def character_portrait(cid: str, user: dict = Depends(current_user)): + with db() as con: + row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone() + allowed = _can_read_character(con, cid, user) + img = portrait_mod.image(json.loads(row["data"]).get("portrait")) if row and allowed else None + if not img: + raise HTTPException(404, "No portrait") + return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"}) + + @api.get("/characters/{cid}") def read_character(cid: str, user: dict = Depends(current_user)): """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() 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()) + allowed = _can_read_character(con, cid, user) if not allowed: raise HTTPException(404, "Character not found") return json.loads(row["data"]) @@ -443,7 +485,7 @@ def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(curren check_size(ch) with db() as con: _owned(con, "characters", cid, user) - con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid)) + con.execute("UPDATE characters SET data=?, updated=?, npc=? WHERE id=?", (json.dumps(ch), time.time(), 1 if ch.get("npc") else 0, 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) @@ -485,7 +527,7 @@ def _new_code(con) -> str: def _own_character(con, cid, user: dict): - row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=?", (cid, user["id"])).fetchone() + row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=? AND npc=0", (cid, user["id"])).fetchone() if not row: raise HTTPException(422, "Pick one of your own characters") @@ -810,6 +852,14 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us 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() + npc_name = "" + if body.get("npc_token"): # the DM rolls for an NPC on the table + _dm_only(lobby, user) + t = con.execute("""SELECT t.name, c.data FROM lobby_tokens t JOIN characters c ON c.id = t.npc_id + WHERE t.id=? AND t.lobby_id=?""", (str(body["npc_token"]), lid)).fetchone() + if not t: + raise HTTPException(404, "This token has no NPC sheet") + member, npc_name = {"char_mod": 0, "data": t["data"]}, t["name"] 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) @@ -819,12 +869,12 @@ def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_us raise HTTPException(404, "Unknown skill") if not s["usable"]: raise HTTPException(422, "This skill is still locked") - what, base = s["name"], s["total"] + what, base = (f"{npc_name}: " if npc_name else "") + 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] + what, base = (f"{npc_name}: " if npc_name else "") + 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) @@ -975,6 +1025,43 @@ def admin_user(user: dict = Depends(current_user)) -> dict: return user +@api.get("/admin/users") +def admin_users(user: dict = Depends(admin_user)): + names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()} + with db() as con: + ids = admin_ids(con) + rows = con.execute("SELECT id, username, created FROM users ORDER BY username COLLATE NOCASE").fetchall() + return [{"username": r["username"], "admin": r["id"] in ids, "fixed": r["username"].lower() in names, "me": r["id"] == user["id"], + "created": r["created"]} for r in rows] + + +@api.put("/admin/users/{username}") +def set_admin(username: str, body: dict = Body(...), user: dict = Depends(admin_user)): + """Make a user an admin or take it away again. Names listed in AW_ADMINS can only be changed in the server settings.""" + want = bool(body.get("admin")) + names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()} + with db() as con: + target = con.execute("SELECT id, username FROM users WHERE username=?", (username,)).fetchone() + if not target: + raise HTTPException(404, "No user with this name") + ids = admin_ids(con) + if want: + if not any(r["is_admin"] for r in con.execute("SELECT is_admin FROM users")) and not names: # the implicit first-account admin becomes explicit + con.execute("UPDATE users SET is_admin=1 WHERE id IN (%s)" % ",".join(str(i) for i in ids)) + con.execute("UPDATE users SET is_admin=1 WHERE id=?", (target["id"],)) + else: + if target["username"].lower() in names: + raise HTTPException(409, "This admin is set in AW_ADMINS on the server and can only be removed there") + if ids - {target["id"]} == set(): + raise HTTPException(409, "There must be at least one admin") + if target["id"] not in ids: + return {"ok": True} + if not any(r["is_admin"] for r in con.execute("SELECT is_admin FROM users")): # implicit first account: make it explicit first + con.execute("UPDATE users SET is_admin=1 WHERE id IN (%s)" % ",".join(str(i) for i in ids)) + con.execute("UPDATE users SET is_admin=0 WHERE id=?", (target["id"],)) + return {"ok": True} + + @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.""" @@ -1036,7 +1123,7 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin return {"ok": True} -maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps") +maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps", _can_read_character) purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds") diff --git a/backend/app/maps.py b/backend/app/maps.py index e039682..86151c9 100644 --- a/backend/app/maps.py +++ b/backend/app/maps.py @@ -16,7 +16,7 @@ from fastapi.responses import Response from PIL import Image, ImageOps from . import vision -from .portrait import tag as portrait_tag +from .portrait import image as portrait_image, tag as portrait_tag MAX_UPLOAD = 12_000_000 # bytes of the uploaded file MAX_SIDE = 4096 # larger images are scaled down @@ -36,6 +36,8 @@ def schema(con): id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, user_id INTEGER, name TEXT NOT NULL, x REAL, y REAL, color TEXT, size REAL NOT NULL DEFAULT 1, hidden INTEGER NOT NULL DEFAULT 0)""") con.execute("CREATE INDEX IF NOT EXISTS lobby_tokens_lobby ON lobby_tokens (lobby_id)") + if "npc_id" not in [r["name"] for r in con.execute("PRAGMA table_info(lobby_tokens)")]: + con.execute("ALTER TABLE lobby_tokens ADD COLUMN npc_id TEXT") con.execute("CREATE TABLE IF NOT EXISTS map_shares (map_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (map_id, user_id))") for table, col, ddl in (("lobbies", "map_id", "TEXT"), ("lobbies", "doors", "TEXT NOT NULL DEFAULT '{}'"), ("lobbies", "los", "INTEGER NOT NULL DEFAULT 1"), ("maps", "sight", "REAL NOT NULL DEFAULT 0")): @@ -81,8 +83,10 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict: if (x is None) != (y is None): x = y = None tid = str(t.get("id", "")) if keep_id else "" + npc = str(t.get("npc_id") or "") return {"id": tid if tid.isalnum() and len(tid) <= 16 else uuid.uuid4().hex[:12], "name": name, "x": x, "y": y, - "size": size, "color": clean_color(t.get("color")), "hidden": bool(t.get("hidden"))} + "size": size, "color": clean_color(t.get("color")), "hidden": bool(t.get("hidden")), + "npc_id": npc if npc.isalnum() and len(npc) <= 16 else ""} def clean_walls(raw, w: int, h: int) -> list: @@ -105,7 +109,7 @@ def clean_walls(raw, w: int, h: int) -> list: return out -def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Path): +def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Path, can_read): store_dir.mkdir(parents=True, exist_ok=True) def path(mid: str) -> Path: @@ -171,7 +175,18 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa def read_map(mid: str, user: dict = Depends(current_user)): with db() as con: r = own_map(con, mid, user) - return {**meta(r), "tokens": json.loads(r["tokens"]), "walls": json.loads(r["walls"])} + tokens = json.loads(r["tokens"]) + for t in tokens: # NPC tokens: tell the editor whether the NPC has a portrait + t["portrait"] = "" + if t.get("npc_id") and can_read(con, t["npc_id"], user): + row = con.execute("SELECT data FROM characters WHERE id=?", (t["npc_id"],)).fetchone() + try: + t["portrait"] = portrait_tag(json.loads(row["data"]).get("portrait")) if row else "" + except ValueError: + pass + else: + t["npc_id"] = "" + return {**meta(r), "tokens": tokens, "walls": json.loads(r["walls"])} @api.put("/maps/{mid}") def update_map(mid: str, body: dict = Body(...), user: dict = Depends(current_user)): @@ -330,7 +345,8 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa def tokens_for(con, lobby, user, ctx) -> list: sync_players(con, lobby["id"]) - rows = con.execute("""SELECT t.*, u.username, m.color AS mcolor, c.data FROM lobby_tokens t + rows = con.execute("""SELECT t.*, u.username, m.color AS mcolor, c.data, n.data AS ndata FROM lobby_tokens t + LEFT JOIN characters n ON n.id = t.npc_id LEFT JOIN users u ON u.id = t.user_id LEFT JOIN lobby_members m ON m.lobby_id = t.lobby_id AND m.user_id = t.user_id LEFT JOIN characters c ON c.id = m.character_id @@ -352,15 +368,17 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa except ValueError: name = r["username"] pt = "" - if player and r["data"]: + src = r["data"] if player else r["ndata"] + if src: try: - pt = portrait_tag(json.loads(r["data"]).get("portrait")) + pt = portrait_tag(json.loads(src).get("portrait")) except ValueError: pass out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name, "portrait": pt, "x": r["x"], "y": r["y"], "size": r["size"], "hidden": bool(r["hidden"]), "color": (r["mcolor"] or "#999999") if player else r["color"], - "can_move": ctx["is_dm"] or (r["user_id"] == user["id"] and not (ctx["los"] and r["x"] is None))}) + "can_move": ctx["is_dm"] or (r["user_id"] == user["id"] and not (ctx["los"] and r["x"] is None)), + **({"npc_id": r["npc_id"]} if full and r["npc_id"] else {})}) return out @api.get("/lobbies/{lid}/map") @@ -434,8 +452,9 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa if mid: r = usable_map(con, str(mid), user) for t in json.loads(r["tokens"]): - con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden) VALUES (?,?,?,?,?,?,?,?)", - (uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]))) + npc = t.get("npc_id") if t.get("npc_id") and can_read(con, t["npc_id"], user) else None # only NPCs this DM may open + con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id) VALUES (?,?,?,?,?,?,?,?,?)", + (uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]), npc)) sync_players(con, lid) event(con, lid, f"The DM put the map \"{r['name']}\" on the table", user["id"]) else: @@ -459,8 +478,9 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa if con.execute("SELECT COUNT(*) FROM lobby_tokens WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_TOKENS: raise HTTPException(409, "Too many tokens") t = clean_token(body, m["w"], m["h"], keep_id=False) - con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden) VALUES (?,?,?,?,?,?,?,?)", - (t["id"], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]))) + npc = t["npc_id"] if t["npc_id"] and can_read(con, t["npc_id"], user) else None + con.execute("INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id) VALUES (?,?,?,?,?,?,?,?,?)", + (t["id"], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]), npc)) hub.notify(lid, "map") return {"id": t["id"]} @@ -499,4 +519,17 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa hub.notify(lid, "map") return {"ok": True} + @api.get("/lobbies/{lid}/token-portrait/{tid}") + def token_portrait(lid: str, tid: str, user: dict = Depends(current_user)): + """Portrait of an NPC token for the lobby's members (hidden tokens only for the DM).""" + with db() as con: + lobby = lobby_for(con, lid, user) + t = con.execute("SELECT t.hidden, n.data FROM lobby_tokens t JOIN characters n ON n.id = t.npc_id WHERE t.id=? AND t.lobby_id=?", (tid, lid)).fetchone() + if not t or (t["hidden"] and lobby["owner_id"] != user["id"]): + raise HTTPException(404, "No portrait") + img = portrait_image(json.loads(t["data"]).get("portrait")) + if not img: + raise HTTPException(404, "No portrait") + return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"}) + return sync_players diff --git a/backend/data/rules.json b/backend/data/rules.json index b8e90d2..e9aa642 100644 --- a/backend/data/rules.json +++ b/backend/data/rules.json @@ -31,6 +31,7 @@ {"id": "standard", "name": "Standard", "desc": "The numbers from the brainstorm document.", "creation": {}}, {"id": "gritty", "name": "Gritty survival", "desc": "Example preset: fewer points, one starting talent, harder to learn advanced skills.", "creation": {"attr_points": 70, "skill_points_default": 100, "talents_max": 1, "free_proficiencies": 1, "advanced_unlock_cost": 15}}, {"id": "heroic", "name": "Heroic", "desc": "Example preset: more points, higher attribute cap, three starting talents.", "creation": {"attr_points": 110, "attr_max_raise": 25, "attr_max": 50, "skill_points_default": 200, "talents_max": 3, "free_proficiencies": 3, "advanced_unlock_cost": 5}}, + {"id": "npc", "name": "NPC / creature (free)", "desc": "Used by the NPC editor: huge pools, no limits.", "npc": true, "creation": {"attr_points": 500, "attr_max_raise": 100, "attr_max": 150, "skill_points_default": 1000, "talents_min": 0, "talents_max": 10, "free_proficiencies": 20, "advanced_unlock_cost": 0}}, {"id": "oneshot", "name": "One-shot", "desc": "Example preset: quick, strong characters for a single session.", "creation": {"attr_points": 100, "skill_points_default": 120, "talents_max": 2, "free_proficiencies": 3, "advanced_unlock_cost": 0}} ], diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8f309f5..4487a2b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -27,6 +27,7 @@ export default function App() { const [error, setError] = useState('') // Used to load a saved character into the creator const [loadRequest, setLoadRequest] = useState(null) + const [npcLoad, setNpcLoad] = useState(null) const [charName, setCharName] = useState('') // name of the character being worked on, shown in the nav // Ask the server who we are; any later 401 (session expired) sends us back to the login @@ -55,7 +56,7 @@ export default function App() { if (!auth.user) return
setAuth({ ...auth, user: u })} />
const page = hash.split('/')[0] - const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['maps', 'Maps'], ['characters', 'My characters'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])] + const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['maps', 'Maps'], ['characters', 'My characters'], ['npcs', 'NPCs'], ['lobbies', 'Lobbies'], ...(auth.user.admin ? [['editor', 'Editor']] : [])] return ( <> @@ -81,6 +82,11 @@ export default function App() { { setLoadRequest(entry); window.location.hash = 'creator' }} onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} /> )} + {rules && page === 'npcs' && ( + { setNpcLoad(entry); window.location.hash = 'npc' }} + onNew={() => { setNpcLoad({ new: true }); window.location.hash = 'npc' }} /> + )} + {rules && page === 'npc' && setNpcLoad(null)} onName={() => {}} />} {rules && page === 'lobbies' && ( { setLoadRequest(entry); window.location.hash = 'creator' }} /> )} diff --git a/frontend/src/Characters.jsx b/frontend/src/Characters.jsx index a20b164..6ab071e 100644 --- a/frontend/src/Characters.jsx +++ b/frontend/src/Characters.jsx @@ -19,7 +19,7 @@ function SharePanel({ c, reload }) { return (
-

These users can view this character (read-only), for example your DM:

+

These users can view this entry (read-only), for example another DM:

{c.shared_with.length === 0 && Nobody yet.} {c.shared_with.map((u) => ( @@ -35,12 +35,13 @@ function SharePanel({ c, reload }) { ) } -export default function Characters({ onOpen, onNew }) { +export default function Characters({ onOpen, onNew, npc = false }) { + const what = npc ? 'NPC' : 'character' const [data, setData] = useState(null) const [open, setOpen] = useState(null) // id of the character whose share panel is open const [err, setErr] = useState('') - const load = () => fetch('/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your characters.')) + const load = () => fetch(npc ? '/api/characters?npc=1' : '/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load the list.')) useEffect(() => { load() }, []) const remove = async (c) => { @@ -49,32 +50,46 @@ export default function Characters({ onOpen, onNew }) { if (!r || !r.ok) return setErr('Deleting failed.') load() } + const copy = async (c) => { + const r = await fetch(`/api/characters/${c.id}`).catch(() => null) + if (!r || !r.ok) return setErr('Duplicating failed.') + const data = await r.json() + const p = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, name: `${data.name || 'NPC'} (copy)` }) }).catch(() => null) + if (!p || !p.ok) return setErr('Duplicating failed.') + load() + } + const kindName = (k) => ({ npc: 'NPC', enemy: 'Enemy', monster: 'Monster', creature: 'Creature', animal: 'Animal', robot: 'Robot / machine' })[k] || '' + const thumb = (c) => c.portrait ? : const when = (t) => new Date(t * 1000).toLocaleString('en-GB') return (
-

My characters

- +

{npc ? 'NPCs and creatures' : 'My characters'}

+

- These characters are saved in your account. Only you and the users you share a character with can see it. - Shared users can look at it but not change it. + {npc ? 'Your library of NPCs, enemies, monsters and creatures. Place them as tokens in the map editor or at the table; a DM sees their sheet by clicking the token. You can share NPCs with other DMs.' + : 'These characters are saved in your account. Only you and the users you share a character with can see it. Shared users can look at it but not change it.'}

{err &&

{err}

} - {data && data.mine.length === 0 &&

Nothing saved yet. Use "New character", then click "Save" on the Summary page.

} + {data && data.mine.length === 0 &&

Nothing saved yet. Use "{npc ? 'New NPC' : 'New character'}", then click "{npc ? 'Save NPC' : 'Save'}" on the Summary page.

}
    {(data?.mine || []).map((c) => (
  • -
    - {c.name} +
    + {thumb(c)} +
    + {c.name}{npc && c.kind && {kindName(c.kind)}}
    ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
    +
    + {npc && }
    @@ -90,9 +105,11 @@ export default function Characters({ onOpen, onNew }) {
      {data.shared.map((c) => (
    • -
      - {c.name} +
      + {thumb(c)} +
      {c.name}
      from {c.owner} · {when(c.updated)}
      +
    • diff --git a/frontend/src/Creator.jsx b/frontend/src/Creator.jsx index fd98d83..0f0f721 100644 --- a/frontend/src/Creator.jsx +++ b/frontend/src/Creator.jsx @@ -14,9 +14,13 @@ function readDraft(key) { try { return JSON.parse(localStorage.getItem(key)) } catch { return null } } -export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName }) { - const DRAFT = `aw_draft:${user}` // drafts are kept per account - const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard') +const NPC_KINDS = [['npc', 'NPC'], ['enemy', 'Enemy'], ['monster', 'Monster'], ['creature', 'Creature'], ['animal', 'Animal'], ['robot', 'Robot / machine']] + +export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName, npc = false }) { + const DRAFT = `${npc ? 'aw_draft_npc' : 'aw_draft'}:${user}` // drafts are kept per account + const freshCharacter = () => npc + ? { ...applyPreset(emptyCharacter(baseRules), baseRules, 'npc'), npc: true, npc_kind: 'enemy', token_size: 1, abilities: '', loot: '' } + : applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard') const [ch, setCh] = useState(() => { const d = readDraft(DRAFT) return d?.ch ? normalize(d.ch, baseRules) : freshCharacter() @@ -58,7 +62,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad if (!loadRequest) return if (loadRequest.new) { // "New character" button on the My characters page const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved - if (untouched || window.confirm('Start a new character? Your current draft is discarded unless you saved it.')) { + if (untouched || window.confirm('Start a new ' + (npc ? 'NPC' : 'character') + '? Your current draft is discarded unless you saved it.')) { setBoth(freshCharacter()); setSaved(null); setStep(0) } clearLoad() @@ -74,7 +78,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad // Direct link #creator/ (read only; saving creates a copy) useEffect(() => { const id = window.location.hash.split('/')[1] - if (!id || loadRequest) return + if (!id || loadRequest || npc) return fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }) .then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') }) .catch(() => setMsg('Character not found, or it is not shared with you.')) @@ -94,7 +98,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad const resetAll = () => { if (!window.confirm('Discard all input and start over?')) return - setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation }); setSaved(null); setStep(0) + setBoth({ ...emptyCharacter(rules), preset: ch.preset, creation: ch.creation, ...(npc ? { npc: true, npc_kind: ch.npc_kind, token_size: ch.token_size, abilities: '', loot: '' } : {}) }); setSaved(null); setStep(0) } const resetThisStep = () => { if (!window.confirm(`Reset only the "${STEPS[step]}" page? Everything else is kept.`)) return @@ -154,10 +158,25 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad const stepConcept = ( <>

      Concept

      -
      + {npc && ( +
      +
      + + +
      +

      NPCs use generous pools (attributes, skills, talents), so you can build anything from a rat to a boss.

      +
      + )} + {!npc &&
      @@ -167,11 +186,11 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad return diff.length > 0 &&

      Differs from Standard: {diff.map((x) => `${x.label} ${cr[x.key]}`).join(' · ')}

      })()}

      Create or edit presets on the Presets page.

      -
      +
      }
      set({ portrait: p })} />
      - - + + {!npc && }
      {sub &&

      {sub.desc}

      } -