"""Initiative tracker. One fight per lobby, stored as JSON on the lobby row. {"round": 1, "current": "", "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 if e["kind"] == "npc" and not is_dm and e["id"] != c.get("current"): e = {**e, "name": ""} # players only learn an NPC's name when it is its turn 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