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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-23 21:36:09 +00:00
parent 57f262b6aa
commit 1db8a45cae
13 changed files with 364 additions and 86 deletions
+45 -12
View File
@@ -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