Scenes (switch maps without losing state), decor props with rotation, editor visibility toggle

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
2026-10-24 19:37:55 +00:00
co-authored by Claude Sonnet 5.5
parent 928e018fcd
commit c1fdb7cc76
7 changed files with 521 additions and 51 deletions
+2 -1
View File
@@ -928,7 +928,8 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)):
_dm_only(_lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_scenes"):
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
purge_sounds(lid)
hub.close_lobby(lid)
+286 -26
View File
@@ -2,10 +2,12 @@
Coordinates are pixels of the stored image. A token's size is in grid squares (the map's `grid` is pixels per square).
Images are re-encoded with Pillow on upload, so only real images get through and metadata is gone."""
import base64
import hashlib
import io
import json
import math
import re
import shutil
import time
import uuid
@@ -26,6 +28,8 @@ MAX_MAPS = 30 # per DM
MAX_TOKENS = 120 # per map / lobby
MAX_WALLS = 400 # wall and door segments per map
MAX_SHARES = 30
MAX_SCENES = 12 # saved scenes per lobby
MAX_PROPS = 40 # custom prop images per user
COLORS = ("#c0392b", "#8e44ad", "#16a085", "#7f8c8d", "#d35400", "#2c3e50", "#27ae60", "#b9770e")
@@ -45,6 +49,10 @@ def schema(con):
con.execute("""CREATE TABLE IF NOT EXISTS lobby_reveals (
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, x REAL NOT NULL, y REAL NOT NULL, radius REAL NOT NULL, dir REAL NOT NULL,
fov REAL NOT NULL, targets TEXT, created REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_scenes (
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, name TEXT NOT NULL, map_id TEXT, state TEXT NOT NULL DEFAULT '{}', updated REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS user_props (
id TEXT PRIMARY KEY, owner_id INTEGER NOT NULL, name TEXT NOT NULL, created REAL NOT NULL)""")
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"),
@@ -52,7 +60,9 @@ def schema(con):
("lobby_tokens", "hp", "INTEGER"), ("lobby_tokens", "hp_max", "INTEGER"),
("lobby_tokens", "conds", "TEXT NOT NULL DEFAULT '{}'"), ("lobby_tokens", "show_hp", "INTEGER NOT NULL DEFAULT 0"),
("lobby_tokens", "show_cond", "INTEGER NOT NULL DEFAULT 0"),
("lobby_members", "vgroup", "INTEGER NOT NULL DEFAULT 0")):
("lobby_members", "vgroup", "INTEGER NOT NULL DEFAULT 0"),
("lobby_tokens", "prop", "TEXT"), ("lobby_tokens", "rot", "REAL NOT NULL DEFAULT 0"), ("lobby_tokens", "size_h", "REAL"),
("lobbies", "scene_id", "TEXT")):
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}")
@@ -97,9 +107,17 @@ def clean_token(t: dict, w: int, h: int, keep_id=True) -> dict:
tid = str(t.get("id", "")) if keep_id else ""
npc = str(t.get("npc_id") or "")
hp_max, hp = clean_int(t.get("hp_max"), 1, 9999), clean_int(t.get("hp"), -999, 9999)
prop = str(t.get("prop") or "")
prop = prop if re.fullmatch(r"[bc]:[a-z0-9_]{1,24}", prop) else ""
try:
rot = float(t.get("rot") or 0) % 360
size_h = None if t.get("size_h") in (None, "") else min(max(float(t["size_h"]), 0.25), 8)
except (TypeError, ValueError):
raise HTTPException(422, "Invalid rotation or size")
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")),
"npc_id": npc if npc.isalnum() and len(npc) <= 16 else "", "hp": hp, "hp_max": hp_max}
"npc_id": npc if npc.isalnum() and len(npc) <= 16 else "", "hp": hp, "hp_max": hp_max,
"prop": prop, "rot": rot, "size_h": size_h if prop else None}
def clean_int(v, lo, hi):
@@ -269,7 +287,10 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
con.execute("UPDATE lobbies SET map_id=NULL WHERE map_id=?", (mid,))
con.execute("DELETE FROM map_shares WHERE map_id=?", (mid,))
for lid in lobbies:
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"):
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
con.execute("UPDATE lobbies SET scene_id=NULL WHERE scene_id IN (SELECT id FROM lobby_scenes WHERE map_id=?)", (mid,))
con.execute("DELETE FROM lobby_scenes WHERE map_id=?", (mid,))
con.execute("DELETE FROM maps WHERE id=?", (mid,))
path(mid).unlink(missing_ok=True)
(store_dir / f"{mid}.thumb.webp").unlink(missing_ok=True)
@@ -334,6 +355,80 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
raise HTTPException(404, "Image missing")
return Response(p.read_bytes(), media_type="image/webp", headers={"Cache-Control": "private, max-age=3600"})
# ---- custom prop images (decor tokens such as chests or tables); the built-in ones are drawn by the frontend
props_dir = store_dir.parent / "props"
props_dir.mkdir(parents=True, exist_ok=True)
def encode_prop(raw: bytes) -> bytes:
try:
im = Image.open(io.BytesIO(raw))
if im.format not in ("PNG", "JPEG", "WEBP"):
raise ValueError("format")
im.load()
except Exception:
raise HTTPException(422, "Please upload a PNG, JPEG or WebP image")
im = ImageOps.exif_transpose(im)
im.thumbnail((512, 512))
im = im.convert("RGBA")
out = io.BytesIO()
im.save(out, "WEBP", quality=88, method=4)
return out.getvalue()
@api.get("/props")
def list_props(user: dict = Depends(current_user)):
with db() as con:
return [{"id": r["id"], "name": r["name"]} for r in
con.execute("SELECT id, name FROM user_props WHERE owner_id=? ORDER BY created", (user["id"],))]
@api.post("/props")
async def upload_prop(request: Request, name: str = "", user: dict = Depends(current_user)):
"""Raw image bytes in the body, the name as ?name=. Transparent PNGs work best (top view)."""
if int(request.headers.get("content-length") or 0) > 3_000_000:
raise HTTPException(413, "The file is too large (at most 3 MB)")
chunks, size = [], 0
async for c in request.stream():
size += len(c)
if size > 3_000_000:
raise HTTPException(413, "The file is too large (at most 3 MB)")
chunks.append(c)
with db() as con:
if con.execute("SELECT COUNT(*) FROM user_props WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_PROPS:
raise HTTPException(409, "Prop limit reached, delete one first")
data = await run_in_threadpool(encode_prop, b"".join(chunks))
pid = uuid.uuid4().hex[:12]
(props_dir / f"{pid}.webp").write_bytes(data)
with db() as con:
con.execute("INSERT INTO user_props (id, owner_id, name, created) VALUES (?,?,?,?)", (pid, user["id"], name.strip()[:30] or "Prop", time.time()))
return {"id": pid}
@api.delete("/props/{pid}")
def delete_prop(pid: str, user: dict = Depends(current_user)):
with db() as con:
if not con.execute("SELECT 1 FROM user_props WHERE id=? AND owner_id=?", (pid, user["id"])).fetchone():
raise HTTPException(404, "Prop not found")
con.execute("DELETE FROM user_props WHERE id=?", (pid,))
(props_dir / f"{pid}.webp").unlink(missing_ok=True)
return {"ok": True}
@api.get("/props/{pid}/image")
def prop_image(pid: str, user: dict = Depends(current_user)):
with db() as con:
ok = con.execute("SELECT 1 FROM user_props WHERE id=? AND owner_id=?", (pid, user["id"])).fetchone()
if not ok:
raise HTTPException(404, "Prop not found")
return image_response(props_dir / f"{pid}.webp")
@api.get("/lobbies/{lid}/prop-image/{pid}")
def lobby_prop_image(lid: str, pid: str, user: dict = Depends(current_user)):
"""Members of a lobby may load the prop images of the lobby's DM."""
with db() as con:
lobby = lobby_for(con, lid, user)
ok = con.execute("SELECT 1 FROM user_props WHERE id=? AND owner_id=?", (pid, lobby["owner_id"])).fetchone()
if not ok or not re.fullmatch(r"[0-9a-f]{12}", pid):
raise HTTPException(404, "Prop not found")
return image_response(props_dir / f"{pid}.webp")
# ---- the map on a lobby's table
def sync_players(con, lid: str):
@@ -346,6 +441,13 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
for uid in have - ids:
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=? AND user_id=?", (lid, uid))
def put_token(con, lid, t, npc=None, tid=None):
"""Inserts a (cleaned or snapshot) token row for the lobby."""
con.execute("""INSERT INTO lobby_tokens (id, lobby_id, name, x, y, color, size, hidden, npc_id, hp, hp_max, prop, rot, size_h)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(tid or uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(bool(t["hidden"])), npc,
t.get("hp") if t.get("hp") is not None else t.get("hp_max"), t.get("hp_max"), t.get("prop") or None, t.get("rot") or 0, t.get("size_h")))
def mask_dims(m):
f = max(1, -(-max(m["w"], m["h"]) // 512))
return f, -(-m["w"] // f), -(-m["h"] // f)
@@ -478,7 +580,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
name = (json.loads(r["data"]).get("name") if r["data"] else "") or r["username"]
except ValueError:
name = r["username"]
if not player and not full and r["id"] != turn_tok:
if not player and not full and r["id"] != turn_tok and not r["prop"]:
name = ""
pt = ""
src = r["data"] if player else r["ndata"]
@@ -494,7 +596,9 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
status["conds"] = json.loads(r["conds"] or "{}")
if full:
status.update(show_hp=bool(r["show_hp"]), show_cond=bool(r["show_cond"]), hp_hint=health(src) if src else None)
out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name, "portrait": pt, **status,
if r["prop"]:
status = {}
out.append({"id": r["id"], "kind": "player" if player else "prop" if r["prop"] else "npc", "prop": r["prop"], "rot": r["rot"], "size_h": r["size_h"], "username": r["username"], "name": name, "portrait": pt, **status,
"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)),
@@ -667,29 +771,185 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
hub.notify(lid, "map")
return {"ok": True}
# ---- scenes: saved states of the table (map + tokens + doors + reveals + explored ground + combat)
TOKEN_COLS = ("id", "name", "x", "y", "color", "size", "hidden", "npc_id", "hp", "hp_max", "conds", "show_hp", "show_cond", "prop", "rot", "size_h")
def snapshot(con, lid) -> str:
lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone()
names = {r["id"]: r["username"] for r in con.execute("SELECT id, username FROM users WHERE id IN (SELECT user_id FROM lobby_members WHERE lobby_id=?)", (lid,))}
toks = []
for r in con.execute("SELECT * FROM lobby_tokens WHERE lobby_id=? ORDER BY rowid", (lid,)):
d = {k: r[k] for k in TOKEN_COLS}
if r["user_id"] is not None:
if r["user_id"] not in names:
continue
d["username"] = names[r["user_id"]]
toks.append(d)
reveals = [{k: z[k] for k in ("id", "x", "y", "radius", "dir", "fov", "targets", "created")}
for z in con.execute("SELECT * FROM lobby_reveals WHERE lobby_id=?", (lid,))]
explored = [{"username": names[z["user_id"]], "map_id": z["map_id"], "rev": z["rev"], "data": base64.b64encode(z["data"]).decode()}
for z in con.execute("SELECT * FROM lobby_explored WHERE lobby_id=?", (lid,)) if z["user_id"] in names]
return json.dumps({"tokens": toks, "reveals": reveals, "explored": explored, "doors": lobby["doors"], "sight": lobby["sight"],
"fog": lobby["fog"], "los": lobby["los"], "combat": lobby["combat"]})
def restore(con, lid, state: dict):
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_explored WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,))
ids = {r["username"]: r["user_id"] for r in con.execute("SELECT u.username, m.user_id FROM lobby_members m JOIN users u ON u.id = m.user_id WHERE m.lobby_id=?", (lid,))}
sync_players(con, lid)
for d in state.get("tokens", []):
if d.get("username"):
uid = ids.get(d["username"])
if uid is not None:
con.execute("""UPDATE lobby_tokens SET x=?, y=?, hidden=?, hp=?, hp_max=?, conds=?, show_hp=?, show_cond=?, size=? WHERE lobby_id=? AND user_id=?""",
(d["x"], d["y"], d["hidden"], d["hp"], d["hp_max"], d["conds"], d["show_hp"], d["show_cond"], d["size"], lid, uid))
else:
con.execute(f"INSERT INTO lobby_tokens (lobby_id, {', '.join(TOKEN_COLS)}) VALUES (?{',?' * len(TOKEN_COLS)})", (lid, *[d[k] for k in TOKEN_COLS]))
for z in state.get("reveals", []):
con.execute("INSERT INTO lobby_reveals (id, lobby_id, x, y, radius, dir, fov, targets, created) VALUES (?,?,?,?,?,?,?,?,?)",
(z["id"], lid, z["x"], z["y"], z["radius"], z["dir"], z["fov"], z["targets"], z["created"]))
for z in state.get("explored", []):
if z["username"] in ids:
con.execute("INSERT INTO lobby_explored (lobby_id, user_id, map_id, data, rev, last) VALUES (?,?,?,?,?,NULL)",
(lid, ids[z["username"]], z["map_id"], base64.b64decode(z["data"]), z["rev"]))
con.execute("UPDATE lobbies SET doors=?, sight=?, fog=?, los=?, combat=? WHERE id=?",
(state.get("doors", "{}"), state.get("sight"), state.get("fog", 1), state.get("los", 1), state.get("combat"), lid))
def fresh_state(con, lid, r, user):
"""The state of a map that was just put on the table: its prepared tokens, nothing explored."""
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_explored WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,))
con.execute("UPDATE lobbies SET sight=NULL, doors='{}', combat=NULL WHERE id=?", (lid,))
for t in json.loads(r["tokens"]):
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
put_token(con, lid, t, npc)
sync_players(con, lid)
def autosave(con, lid):
"""Stores the table's current state in its scene (creating the scene for tables that predate scenes)."""
lobby = con.execute("SELECT * FROM lobbies WHERE id=?", (lid,)).fetchone()
if not lobby["map_id"]:
return
now = time.time()
if lobby["scene_id"] and con.execute("SELECT 1 FROM lobby_scenes WHERE id=? AND lobby_id=?", (lobby["scene_id"], lid)).fetchone():
con.execute("UPDATE lobby_scenes SET state=?, updated=? WHERE id=?", (snapshot(con, lid), now, lobby["scene_id"]))
else:
new_scene(con, lid, lobby["map_id"], snapshot(con, lid))
def new_scene(con, lid, mid, state, name=None) -> str:
if con.execute("SELECT COUNT(*) FROM lobby_scenes WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_SCENES:
raise HTTPException(409, f"Too many saved scenes (max {MAX_SCENES}) - delete one first")
m = con.execute("SELECT name FROM maps WHERE id=?", (mid,)).fetchone()
base = (name or (m["name"] if m else "Scene")).strip()[:50] or "Scene"
taken = {r["name"] for r in con.execute("SELECT name FROM lobby_scenes WHERE lobby_id=?", (lid,))}
nm, i = base, 2
while nm in taken:
nm, i = f"{base} ({i})", i + 1
sid = uuid.uuid4().hex[:12]
con.execute("INSERT INTO lobby_scenes (id, lobby_id, name, map_id, state, updated) VALUES (?,?,?,?,?,?)", (sid, lid, nm, mid, state, time.time()))
con.execute("UPDATE lobbies SET scene_id=? WHERE id=?", (sid, lid))
return sid
@api.put("/lobbies/{lid}/map")
def set_lobby_map(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: show a map from the library (or null to clear the table). The map's prepared tokens are copied to the lobby."""
"""DM: show a map from the library (or null to clear the table). The table's current state is kept as a scene;
the new map starts fresh with its prepared tokens as a scene of its own."""
with db() as con:
dm_only(lobby_for(con, lid, user), user)
mid = body.get("map_id")
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_explored WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_reveals WHERE lobby_id=?", (lid,))
con.execute("UPDATE lobbies SET sight=NULL WHERE id=?", (lid,))
if mid:
r = usable_map(con, str(mid), user)
for t in json.loads(r["tokens"]):
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, hp, hp_max) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(uuid.uuid4().hex[:12], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]), npc,
t.get("hp") if t.get("hp") is not None else t.get("hp_max"), t.get("hp_max")))
sync_players(con, lid)
r = usable_map(con, str(mid), user) if mid else None
autosave(con, lid)
con.execute("UPDATE lobbies SET scene_id=NULL WHERE id=?", (lid,))
if r:
fresh_state(con, lid, r, user)
con.execute("UPDATE lobbies SET map_id=? WHERE id=?", (r["id"], lid))
new_scene(con, lid, r["id"], snapshot(con, lid))
event(con, lid, f"The DM put the map \"{r['name']}\" on the table", user["id"])
else:
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"):
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
con.execute("UPDATE lobbies SET map_id=NULL, sight=NULL, doors='{}', combat=NULL WHERE id=?", (lid,))
event(con, lid, "The DM cleared the table", user["id"])
con.execute("UPDATE lobbies SET map_id=?, doors='{}' WHERE id=?", (str(mid) if mid else None, lid))
hub.notify(lid, "map")
hub.notify(lid, "lobby")
return {"ok": True}
@api.get("/lobbies/{lid}/scenes")
def list_scenes(lid: str, user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
if lobby["map_id"] and not con.execute("SELECT 1 FROM lobby_scenes WHERE id=? AND lobby_id=?", (lobby["scene_id"] or "", lid)).fetchone():
try:
autosave(con, lid) # a table that predates scenes becomes one
except HTTPException:
pass
lobby = lobby_for(con, lid, user)
rows = con.execute("""SELECT s.id, s.name, s.map_id, s.updated, m.name AS map_name, LENGTH(s.state) AS size FROM lobby_scenes s
LEFT JOIN maps m ON m.id = s.map_id WHERE s.lobby_id=? ORDER BY s.updated DESC""", (lid,)).fetchall()
return [{"id": r["id"], "name": r["name"], "map_id": r["map_id"], "map_name": r["map_name"] or "", "updated": r["updated"],
"current": r["id"] == lobby["scene_id"]} for r in rows]
@api.put("/lobbies/{lid}/scene")
def switch_scene(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: continue another saved scene. The current table is saved first, so nothing is lost when switching back."""
with db() as con:
dm_only(lobby_for(con, lid, user), user)
sc = con.execute("SELECT * FROM lobby_scenes WHERE id=? AND lobby_id=?", (str(body.get("scene_id", "")), lid)).fetchone()
if not sc:
raise HTTPException(404, "Scene not found")
r = usable_map(con, sc["map_id"], user) if sc["map_id"] else None
if not r:
raise HTTPException(409, "The map of this scene is no longer available")
autosave(con, lid)
restore(con, lid, json.loads(sc["state"]))
con.execute("UPDATE lobbies SET map_id=?, scene_id=? WHERE id=?", (r["id"], sc["id"], lid))
event(con, lid, f"The DM switched to the scene \"{sc['name']}\"", user["id"])
hub.notify(lid, "map")
hub.notify(lid, "lobby")
return {"ok": True}
@api.post("/lobbies/{lid}/scenes")
def save_scene_copy(lid: str, body: dict = Body(default={}), user: dict = Depends(current_user)):
"""DM: store a copy of the current table (a checkpoint to come back to). The table keeps running on its own scene."""
with db() as con:
lobby = lobby_for(con, lid, user)
dm_only(lobby, user)
if not lobby["map_id"]:
raise HTTPException(409, "There is no map on the table")
autosave(con, lid)
current = con.execute("SELECT scene_id FROM lobbies WHERE id=?", (lid,)).fetchone()["scene_id"]
sid = new_scene(con, lid, lobby["map_id"], snapshot(con, lid), str(body.get("name", "")) or None)
con.execute("UPDATE lobbies SET scene_id=? WHERE id=?", (current, lid))
hub.notify(lid, "lobby")
return {"id": sid}
@api.put("/lobbies/{lid}/scenes/{sid}")
def edit_scene(lid: str, sid: str, body: dict = Body(...), user: dict = Depends(current_user)):
with db() as con:
dm_only(lobby_for(con, lid, user), user)
if not con.execute("SELECT 1 FROM lobby_scenes WHERE id=? AND lobby_id=?", (sid, lid)).fetchone():
raise HTTPException(404, "Scene not found")
name = str(body.get("name", "")).strip()[:50]
if name:
if con.execute("SELECT 1 FROM lobby_scenes WHERE lobby_id=? AND name=? AND id<>?", (lid, name, sid)).fetchone():
raise HTTPException(409, "There is already a scene with this name")
con.execute("UPDATE lobby_scenes SET name=? WHERE id=?", (name, sid))
if body.get("save"):
autosave(con, lid)
hub.notify(lid, "lobby")
return {"ok": True}
@api.delete("/lobbies/{lid}/scenes/{sid}")
def delete_scene(lid: str, sid: str, user: dict = Depends(current_user)):
with db() as con:
dm_only(lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_scenes WHERE id=? AND lobby_id=?", (sid, lid))
con.execute("UPDATE lobbies SET scene_id=NULL WHERE id=? AND scene_id=?", (lid, sid))
hub.notify(lid, "lobby")
return {"ok": True}
def need_map(con, lobby):
@@ -708,9 +968,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
raise HTTPException(409, "Too many tokens")
t = clean_token(body, m["w"], m["h"], keep_id=False)
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, hp, hp_max) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(t["id"], lid, t["name"], t["x"], t["y"], t["color"], t["size"], int(t["hidden"]), npc,
t["hp"] if t["hp"] is not None else t["hp_max"], t["hp_max"]))
put_token(con, lid, t, npc, t["id"])
hub.notify(lid, "map")
return {"id": t["id"]}
@@ -726,18 +984,20 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
raise HTTPException(404, "Token not found")
if not is_dm and t["x"] is None and lobby["los"] and json.loads(m["walls"]):
raise HTTPException(403, "The DM puts your token on the map") # with line of sight on you could not see where
merged = {"name": t["name"], "x": t["x"], "y": t["y"], "color": t["color"], "size": t["size"], "hidden": t["hidden"]}
merged = {"name": t["name"], "x": t["x"], "y": t["y"], "color": t["color"], "size": t["size"], "hidden": t["hidden"],
"prop": t["prop"], "rot": t["rot"], "size_h": t["size_h"]}
if "x" in body or "y" in body:
merged["x"], merged["y"] = body.get("x"), body.get("y")
if is_dm and t["user_id"] is None:
for k in ("name", "color", "size", "hidden"):
for k in ("name", "color", "size", "hidden", "rot", "size_h"):
if k in body:
merged[k] = body[k]
elif is_dm and "size" in body:
merged["size"] = body["size"]
c = clean_token(merged, m["w"], m["h"])
con.execute("UPDATE lobby_tokens SET name=?, x=?, y=?, color=?, size=?, hidden=? WHERE id=?",
(c["name"] if t["user_id"] is None else t["name"], c["x"], c["y"], c["color"], c["size"], int(c["hidden"]), tid))
con.execute("UPDATE lobby_tokens SET name=?, x=?, y=?, color=?, size=?, hidden=?, rot=?, size_h=? WHERE id=?",
(c["name"] if t["user_id"] is None else t["name"], c["x"], c["y"], c["color"], c["size"], int(c["hidden"]),
c["rot"] if t["user_id"] is None else 0, c["size_h"] if t["user_id"] is None else None, tid))
if is_dm and any(k in body for k in ("hp", "hp_max", "conds", "show_hp", "show_cond")):
hp_max, hp = t["hp_max"], t["hp"]
if "hp_max" in body: