Files
AWCharacterCreator/backend/app/combat.py
T

171 lines
8.2 KiB
Python

"""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