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:
+137
-1
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user