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:
+2
-1
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user