Initiative tracker, radial token menu, slimmer lobby header
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""Initiative tracker. One fight per lobby, stored as JSON on the lobby row.
|
||||
|
||||
{"round": 1, "current": "<entry id>", "entries": [{"id", "kind": "player"|"npc", "user": username, "token": token id,
|
||||
"name", "init": number, "base": initiative modifier, "roll": d20}]}
|
||||
The DM starts and ends the fight; players are added automatically at the start. Initiative is 1d20 + the character's
|
||||
Initiative value (Agility Mod + Perception Mod), the DM can overwrite every number."""
|
||||
import json
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from fastapi import Body, Depends, HTTPException
|
||||
|
||||
from .derive import compute
|
||||
|
||||
MAX_ENTRIES = 60
|
||||
|
||||
|
||||
def schema(con):
|
||||
if "combat" not in [r["name"] for r in con.execute("PRAGMA table_info(lobbies)")]:
|
||||
con.execute("ALTER TABLE lobbies ADD COLUMN combat TEXT")
|
||||
|
||||
|
||||
def _sorted(c: dict):
|
||||
c["entries"].sort(key=lambda e: (-(e["init"] if e["init"] is not None else -999), e.get("seq", 0)))
|
||||
|
||||
|
||||
def install(api, db, current_user, lobby_for, dm_only, hub, event, load_rules):
|
||||
def base_of(data, rules) -> int:
|
||||
try:
|
||||
return int(compute(json.loads(data), rules)["derived"].get("initiative", 0))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def new_entry(c, kind, name, base, **kw):
|
||||
roll = secrets.randbelow(20) + 1
|
||||
c["seq"] = c.get("seq", 0) + 1
|
||||
return {"id": uuid.uuid4().hex[:10], "kind": kind, "name": name, "base": base, "roll": roll, "init": roll + base, "seq": c["seq"], **kw}
|
||||
|
||||
def load(lobby):
|
||||
return json.loads(lobby["combat"]) if lobby["combat"] else None
|
||||
|
||||
def save(con, lid, c, log=None, uid=None):
|
||||
con.execute("UPDATE lobbies SET combat=? WHERE id=?", (json.dumps(c) if c else None, lid))
|
||||
if log:
|
||||
event(con, lid, log, uid)
|
||||
hub.notify(lid, "lobby")
|
||||
|
||||
def view(con, lobby, is_dm):
|
||||
"""What the viewer gets: hidden NPCs are left out for players; NPCs whose token is gone disappear."""
|
||||
c = load(lobby)
|
||||
if not c:
|
||||
return None
|
||||
toks = {r["id"]: r["hidden"] for r in con.execute("SELECT id, hidden FROM lobby_tokens WHERE lobby_id=?", (lobby["id"],))}
|
||||
out = []
|
||||
for e in c["entries"]:
|
||||
if e["kind"] == "npc":
|
||||
if e["token"] not in toks:
|
||||
continue
|
||||
if toks[e["token"]] and not is_dm:
|
||||
continue
|
||||
out.append({k: e[k] for k in ("id", "kind", "name", "init", "base", "roll") if k in e} | {k: e[k] for k in ("user", "token") if k in e})
|
||||
cur = c.get("current")
|
||||
return {"round": c["round"], "current": cur if any(e["id"] == cur for e in out) else None, "entries": out}
|
||||
|
||||
@api.post("/lobbies/{lid}/combat")
|
||||
def start(lid: str, user: dict = Depends(current_user)):
|
||||
rules = load_rules()
|
||||
with db() as con:
|
||||
lobby = lobby_for(con, lid, user)
|
||||
dm_only(lobby, user)
|
||||
c = {"round": 1, "current": None, "seq": 0, "entries": []}
|
||||
for r in con.execute("""SELECT u.username, ch.data FROM lobby_members m JOIN users u ON u.id = m.user_id
|
||||
JOIN characters ch ON ch.id = m.character_id WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)):
|
||||
nm = json.loads(r["data"]).get("name") or r["username"]
|
||||
c["entries"].append(new_entry(c, "player", nm, base_of(r["data"], rules), user=r["username"]))
|
||||
_sorted(c)
|
||||
c["current"] = c["entries"][0]["id"] if c["entries"] else None
|
||||
save(con, lid, c, "⚔ A fight begins. Initiative: " + ", ".join(f"{e['name']} {e['init']}" for e in c["entries"]), user["id"])
|
||||
return {"ok": True}
|
||||
|
||||
@api.put("/lobbies/{lid}/combat")
|
||||
def change(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
||||
rules = load_rules()
|
||||
with db() as con:
|
||||
lobby = lobby_for(con, lid, user)
|
||||
dm_only(lobby, user)
|
||||
c = load(lobby)
|
||||
if not c:
|
||||
raise HTTPException(409, "There is no fight")
|
||||
op, eid = body.get("op"), str(body.get("id") or "")
|
||||
ents = c["entries"]
|
||||
ent = next((e for e in ents if e["id"] == eid), None)
|
||||
log = None
|
||||
if op == "add":
|
||||
if len(ents) >= MAX_ENTRIES:
|
||||
raise HTTPException(409, "Too many entries")
|
||||
if body.get("token"):
|
||||
t = con.execute("""SELECT t.id, t.name, t.user_id, n.data FROM lobby_tokens t LEFT JOIN characters n ON n.id = t.npc_id
|
||||
WHERE t.id=? AND t.lobby_id=?""", (str(body["token"]), lid)).fetchone()
|
||||
if not t or t["user_id"]:
|
||||
raise HTTPException(404, "No NPC token")
|
||||
if any(e.get("token") == t["id"] for e in ents):
|
||||
raise HTTPException(409, "Already in the fight")
|
||||
e = new_entry(c, "npc", t["name"], base_of(t["data"], rules) if t["data"] else 0, token=t["id"])
|
||||
elif body.get("user"):
|
||||
r = con.execute("""SELECT u.username, ch.data FROM lobby_members m JOIN users u ON u.id = m.user_id
|
||||
JOIN characters ch ON ch.id = m.character_id WHERE m.lobby_id=? AND u.username=?""", (lid, str(body["user"]))).fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "No such player with a character")
|
||||
if any(e.get("user") == r["username"] for e in ents):
|
||||
raise HTTPException(409, "Already in the fight")
|
||||
e = new_entry(c, "player", json.loads(r["data"]).get("name") or r["username"], base_of(r["data"], rules), user=r["username"])
|
||||
else:
|
||||
raise HTTPException(422, "Say which token or player to add")
|
||||
ents.append(e)
|
||||
log = f"{e['name']} joins the fight with initiative {e['init']}"
|
||||
elif op == "remove":
|
||||
if not ent:
|
||||
raise HTTPException(404, "No such entry")
|
||||
if c["current"] == eid:
|
||||
i = ents.index(ent)
|
||||
nxt = ents[(i + 1) % len(ents)] if len(ents) > 1 else None
|
||||
c["current"] = nxt["id"] if nxt else None
|
||||
ents.remove(ent)
|
||||
elif op == "init":
|
||||
if not ent:
|
||||
raise HTTPException(404, "No such entry")
|
||||
try:
|
||||
ent["init"] = max(-99, min(999, int(body.get("value"))))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(422, "Initiative must be a whole number")
|
||||
elif op == "reroll":
|
||||
if not ent:
|
||||
raise HTTPException(404, "No such entry")
|
||||
ent["roll"] = secrets.randbelow(20) + 1
|
||||
ent["init"] = ent["roll"] + ent["base"]
|
||||
log = f"{ent['name']} rerolls initiative: {ent['init']}"
|
||||
elif op in ("next", "prev"):
|
||||
_sorted(c)
|
||||
if ents:
|
||||
i = next((k for k, e in enumerate(ents) if e["id"] == c.get("current")), -1)
|
||||
if op == "next":
|
||||
if i + 1 >= len(ents):
|
||||
c["round"] += 1
|
||||
i = (i + 1) % len(ents)
|
||||
else:
|
||||
if i <= 0:
|
||||
c["round"] = max(1, c["round"] - 1)
|
||||
i = (i - 1) % len(ents)
|
||||
c["current"] = ents[i]["id"]
|
||||
else:
|
||||
raise HTTPException(422, "Unknown action")
|
||||
cur = c.get("current")
|
||||
_sorted(c)
|
||||
if cur and not any(e["id"] == cur for e in c["entries"]):
|
||||
c["current"] = c["entries"][0]["id"] if c["entries"] else None
|
||||
save(con, lid, c, log, user["id"])
|
||||
return {"ok": True}
|
||||
|
||||
@api.delete("/lobbies/{lid}/combat")
|
||||
def end(lid: str, user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
lobby = lobby_for(con, lid, user)
|
||||
dm_only(lobby, user)
|
||||
c = load(lobby)
|
||||
if c:
|
||||
save(con, lid, None, f"The fight is over after {c['round']} round{'s' if c['round'] != 1 else ''}.", user["id"])
|
||||
return {"ok": True}
|
||||
|
||||
return view
|
||||
+5
-1
@@ -18,6 +18,7 @@ from . import rules_edit
|
||||
from . import maps as maps_mod
|
||||
from . import sounds as sounds_mod
|
||||
from . import portrait as portrait_mod
|
||||
from . import combat as combat_mod
|
||||
from .realtime import hub
|
||||
from .export import to_docx, to_pdf
|
||||
|
||||
@@ -109,6 +110,7 @@ def db() -> sqlite3.Connection:
|
||||
con.execute("ALTER TABLE characters ADD COLUMN npc INTEGER NOT NULL DEFAULT 0")
|
||||
maps_mod.schema(con)
|
||||
sounds_mod.schema(con)
|
||||
combat_mod.schema(con)
|
||||
for table in ("characters", "presets"): # older databases: add the owner column
|
||||
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
|
||||
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
|
||||
@@ -658,6 +660,7 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
|
||||
rows = con.execute("""SELECT m.user_id, m.character_id, m.color, m.char_mod, u.username, c.data FROM lobby_members m
|
||||
JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id
|
||||
WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
|
||||
combat = combat_view(con, lobby, is_dm)
|
||||
members = []
|
||||
online = hub.online(lid)
|
||||
for r in rows:
|
||||
@@ -672,7 +675,7 @@ def read_lobby(lid: str, user: dict = Depends(current_user)):
|
||||
if hidden and not is_dm:
|
||||
diff = {"label": "", "mod": None, "hidden": True}
|
||||
return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
|
||||
"dm_online": lobby["owner_id"] in online, "code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
|
||||
"dm_online": lobby["owner_id"] in online, "combat": combat, "code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
|
||||
"sounds": sounds_mod.settings(lobby["sounds"]), "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR}
|
||||
|
||||
|
||||
@@ -1125,6 +1128,7 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin
|
||||
|
||||
|
||||
maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps", _can_read_character)
|
||||
combat_view = combat_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, load_rules)
|
||||
purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user