Tape measure, area templates, map markers with notes

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-25 15:43:26 +00:00
co-authored by Claude Sonnet 5.5
parent 0f97129243
commit b31936235d
7 changed files with 358 additions and 9 deletions
+1 -1
View File
@@ -928,7 +928,7 @@ 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,))
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_scenes"):
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_scenes", "lobby_templates", "lobby_marks"):
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
purge_sounds(lid)
+137 -1
View File
@@ -29,6 +29,10 @@ 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_TEMPLATES = 24 # per lobby
MAX_MARKS = 60 # per player and lobby
TEMPLATE_KINDS = ("circle", "cone", "line", "square")
MARK_ICONS = ("📌", "⚠️", "💰", "🔒", "🚪", "❓", "⭐", "☠️", "🏕️", "👁️")
MAX_PROPS = 40 # custom prop images per user
COLORS = ("#c0392b", "#8e44ad", "#16a085", "#7f8c8d", "#d35400", "#2c3e50", "#27ae60", "#b9770e")
@@ -51,6 +55,12 @@ def schema(con):
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 lobby_templates (
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, user_id INTEGER NOT NULL, kind TEXT NOT NULL, x REAL NOT NULL, y REAL NOT NULL,
dir REAL NOT NULL, size REAL NOT NULL, width REAL NOT NULL, created REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_marks (
id TEXT PRIMARY KEY, lobby_id TEXT NOT NULL, map_id TEXT NOT NULL, user_id INTEGER NOT NULL, x REAL NOT NULL, y REAL NOT NULL,
icon TEXT NOT NULL, title TEXT NOT NULL, note TEXT NOT NULL, shared INTEGER NOT NULL DEFAULT 0, created 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))")
@@ -290,6 +300,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
for lid in lobbies:
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals"):
con.execute(f"DELETE FROM {tb} WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_marks WHERE map_id=?", (mid,))
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,))
@@ -633,6 +644,14 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
ctx = view_ctx(con, lobby, m, user, as_)
res = {"map": meta(m), "tokens": tokens_for(con, lobby, user, ctx),
"vision": {"los": ctx["los"], "empty": ctx["empty"], "rev": ctx["rev"], "fog": ctx.get("fog")}}
res["templates"] = [{"id": t["id"], "kind": t["kind"], "x": t["x"], "y": t["y"], "dir": t["dir"], "size": t["size"], "width": t["width"],
"user": t["username"], "dm": t["user_id"] == lobby["owner_id"], "mine": t["user_id"] == user["id"]}
for t in con.execute("""SELECT t.*, u.username FROM lobby_templates t JOIN users u ON u.id = t.user_id
WHERE t.lobby_id=? ORDER BY t.created""", (lid,))]
res["marks"] = [{"id": k["id"], "x": k["x"], "y": k["y"], "icon": k["icon"], "title": k["title"], "note": k["note"], "shared": bool(k["shared"]),
"user": k["username"], "mine": k["user_id"] == user["id"]}
for k in con.execute("""SELECT k.*, u.username FROM lobby_marks k JOIN users u ON u.id = k.user_id
WHERE k.lobby_id=? AND k.map_id=? AND (k.user_id=? OR k.shared=1) ORDER BY k.created""", (lid, m["id"], user["id"]))]
if ctx["is_dm"]:
res["los_on"] = bool(lobby["los"])
res["wall_count"] = len(ctx["walls"])
@@ -797,6 +816,121 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
hub.notify(lid, "ping", delay=0, x=round(x, 1), y=round(y, 1), user=user["username"], dm=lobby["owner_id"] == user["id"])
return {"ok": True}
def throttle(lid, uid, gap=0.8):
now = time.time()
if now - pinged.get((lid, uid), 0) < gap:
raise HTTPException(429, "Slow down")
pinged[(lid, uid)] = now
def num(body, key, lo, hi, default=None):
try:
v = float(body.get(key, default))
except (TypeError, ValueError):
raise HTTPException(422, f"Invalid {key}")
if not lo <= v <= hi:
raise HTTPException(422, f"{key} is out of range")
return v
@api.post("/lobbies/{lid}/measure")
def share_measure(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""A measured distance, shown to everybody for a few seconds."""
with db() as con:
lobby = lobby_for(con, lid, user)
m = need_map(con, lobby)
pts = [num(body, k, 0, m["w"] if k[0] == "x" else m["h"]) for k in ("x1", "y1", "x2", "y2")]
throttle(lid, user["id"], 0.4)
hub.notify(lid, "measure", delay=0, x1=pts[0], y1=pts[1], x2=pts[2], y2=pts[3], user=user["username"], dm=lobby["owner_id"] == user["id"])
return {"ok": True}
@api.post("/lobbies/{lid}/templates")
def add_template(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""An area (spell template) on the map, visible to everybody until it is removed."""
kind = body.get("kind")
if kind not in TEMPLATE_KINDS:
raise HTTPException(422, "Unknown template")
with db() as con:
lobby = lobby_for(con, lid, user)
m = need_map(con, lobby)
x, y = num(body, "x", 0, m["w"]), num(body, "y", 0, m["h"])
d, size = num(body, "dir", -720, 720, 0) % 360, num(body, "size", 0.25, 60)
width = num(body, "width", 1, 180, 1)
if kind == "line":
width = min(width, 20)
if kind == "cone":
width = min(max(width, 20), 180)
if con.execute("SELECT COUNT(*) FROM lobby_templates WHERE lobby_id=?", (lid,)).fetchone()[0] >= MAX_TEMPLATES:
raise HTTPException(409, "Too many templates on the map - remove some first")
tid = uuid.uuid4().hex[:12]
con.execute("INSERT INTO lobby_templates (id, lobby_id, user_id, kind, x, y, dir, size, width, created) VALUES (?,?,?,?,?,?,?,?,?,?)",
(tid, lid, user["id"], kind, x, y, d, size, width, time.time()))
hub.notify(lid, "map")
return {"id": tid}
@api.delete("/lobbies/{lid}/templates/{tid}")
def remove_template(lid: str, tid: str, user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
if lobby["owner_id"] == user["id"]:
con.execute("DELETE FROM lobby_templates WHERE id=? AND lobby_id=?", (tid, lid))
else:
con.execute("DELETE FROM lobby_templates WHERE id=? AND lobby_id=? AND user_id=?", (tid, lid, user["id"]))
hub.notify(lid, "map")
return {"ok": True}
@api.delete("/lobbies/{lid}/templates")
def clear_templates(lid: str, user: dict = Depends(current_user)):
with db() as con:
dm_only(lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_templates WHERE lobby_id=?", (lid,))
hub.notify(lid, "map")
return {"ok": True}
def clean_mark(body, m):
icon = body.get("icon") if body.get("icon") in MARK_ICONS else MARK_ICONS[0]
return (num(body, "x", 0, m["w"]), num(body, "y", 0, m["h"]), icon, str(body.get("title", "")).strip()[:40] or "Marker",
str(body.get("note", ""))[:1000], int(bool(body.get("shared"))))
@api.post("/lobbies/{lid}/marks")
def add_mark(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""A pin with a note on the current map. Private to its author unless shared with the table."""
with db() as con:
lobby = lobby_for(con, lid, user)
m = need_map(con, lobby)
x, y, icon, title, note, shared = clean_mark(body, m)
if con.execute("SELECT COUNT(*) FROM lobby_marks WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone()[0] >= MAX_MARKS:
raise HTTPException(409, "Too many markers - remove some first")
kid = uuid.uuid4().hex[:12]
con.execute("INSERT INTO lobby_marks (id, lobby_id, map_id, user_id, x, y, icon, title, note, shared, created) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(kid, lid, m["id"], user["id"], x, y, icon, title, note, shared, time.time()))
hub.notify(lid, "map")
return {"id": kid}
@api.put("/lobbies/{lid}/marks/{kid}")
def edit_mark(lid: str, kid: str, body: dict = Body(...), user: dict = Depends(current_user)):
with db() as con:
lobby = lobby_for(con, lid, user)
m = need_map(con, lobby)
k = con.execute("SELECT * FROM lobby_marks WHERE id=? AND lobby_id=? AND user_id=?", (kid, lid, user["id"])).fetchone()
if not k:
raise HTTPException(404, "Marker not found")
merged = {"x": k["x"], "y": k["y"], "icon": k["icon"], "title": k["title"], "note": k["note"], "shared": k["shared"], **body}
x, y, icon, title, note, shared = clean_mark(merged, m)
con.execute("UPDATE lobby_marks SET x=?, y=?, icon=?, title=?, note=?, shared=? WHERE id=?", (x, y, icon, title, note, shared, kid))
hub.notify(lid, "map")
return {"ok": True}
@api.delete("/lobbies/{lid}/marks/{kid}")
def remove_mark(lid: str, kid: str, user: dict = Depends(current_user)):
"""Authors remove their own markers; the DM can also remove markers that were shared with the table."""
with db() as con:
lobby = lobby_for(con, lid, user)
if lobby["owner_id"] == user["id"]:
con.execute("DELETE FROM lobby_marks WHERE id=? AND lobby_id=? AND (user_id=? OR shared=1)", (kid, lid, user["id"]))
else:
con.execute("DELETE FROM lobby_marks WHERE id=? AND lobby_id=? AND user_id=?", (kid, lid, user["id"]))
hub.notify(lid, "map")
return {"ok": True}
@api.put("/lobbies/{lid}/sight")
def set_sight(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: switch line of sight on or off for the table (off: players see the whole map)."""
@@ -835,6 +969,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
"fog": lobby["fog"], "los": lobby["los"], "combat": lobby["combat"]})
def restore(con, lid, state: dict):
con.execute("DELETE FROM lobby_templates WHERE lobby_id=?", (lid,))
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,))
@@ -860,6 +995,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
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_templates WHERE lobby_id=?", (lid,))
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,))
@@ -910,7 +1046,7 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
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"):
for tb in ("lobby_tokens", "lobby_explored", "lobby_reveals", "lobby_templates"):
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"])