Files
encounterflow/battleflow/routes/api.py
Peter van Arkel 644b207997 pre-push commit
2025-11-20 14:40:42 +01:00

358 lines
11 KiB
Python

from __future__ import annotations
import os, uuid
from flask import Blueprint, jsonify, request, abort, send_from_directory, current_app
from werkzeug.utils import secure_filename
from ..security import require_token
from ..models import Actor, Preset, PresetGroup
from ..state import STATE
from ..storage import save_state, load_state, save_presets, load_presets, save_groups, load_groups, ensure_default_icons
api_bp = Blueprint("api", __name__)
ALLOWED_AVATAR_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'}
# ---------- Health (public) ----------
@api_bp.get("/health")
def health():
"""
Public health endpoint (no auth required).
Add token via ?token= or X-Token to see authed:true.
"""
supplied = request.args.get("token") or request.headers.get("X-Token")
expected = current_app.config.get("COMBAT_TOKEN")
authed = bool(expected and supplied == expected)
return jsonify({
"ok": True,
"name": current_app.config.get("PRODUCT_NAME"),
"subtitle": current_app.config.get("PRODUCT_SUBTITLE"),
"authed": authed,
"actors": len(STATE.actors),
"visible": STATE.visible,
"round": STATE.round,
"dead_mode": STATE.dead_mode,
"data_dir": current_app.config.get("DATA_DIR"),
})
# ---------- Assets ----------
@api_bp.route("/avatars/<path:fn>")
def avatars(fn: str):
return send_from_directory(current_app.config["AVATAR_DIR"], fn)
@api_bp.route("/icons/<path:fn>")
def icons(fn: str):
return send_from_directory(current_app.config["ICON_DIR"], fn)
# ---------- State polling ----------
@api_bp.get("/api/state")
@require_token
def api_state():
since = 0
try:
since = int(request.args.get("since", "0"))
except ValueError:
since = 0
return jsonify(STATE.wait_for(since, timeout=25.0))
# ---------- Uploads ----------
@api_bp.post("/api/upload_avatar")
@require_token
def api_upload_avatar():
if "file" not in request.files:
abort(400, "missing file field")
f = request.files["file"]
if not f or f.filename == "":
abort(400, "empty file")
fn = secure_filename(f.filename)
ext = os.path.splitext(fn)[1].lower()
if ext not in ALLOWED_AVATAR_EXTS:
abort(415, "unsupported file type")
newname = f"{uuid.uuid4().hex}{ext}"
dest = os.path.join(current_app.config["AVATAR_DIR"], newname)
f.save(dest)
return jsonify({"ok": True, "filename": newname, "url": f"/avatars/{newname}"})
# ---------- Mutations ----------
@api_bp.post("/api/add")
@require_token
def api_add():
data = request.get_json(force=True, silent=True) or {}
t = str(data.get('type', 'pc'))
rev = data.get('reveal_ac', None)
reveal = bool(rev) if rev is not None else (t == 'pc')
a = Actor(
id=str(uuid.uuid4()),
name=str(data.get('name', 'Unknown'))[:64],
init=float(data.get('init', 0)),
hp=int(data.get('hp', 0)),
ac=int(data.get('ac', 0)),
type=t,
note=str(data.get('note', ''))[:120],
avatar=str(data.get('avatar',''))[:200],
reveal_ac=reveal,
)
STATE.actors.append(a)
STATE.normalize()
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'id': a.id})
@api_bp.post("/api/update")
@require_token
def api_update():
data = request.get_json(force=True, silent=True) or {}
aid = str(data.get('id', ''))
target = next((x for x in STATE.actors if x.id == aid), None)
if not target:
abort(404)
for k, v in data.items():
if k == 'id':
continue
if v == 'toggle':
if hasattr(target, k) and isinstance(getattr(target, k), bool):
setattr(target, k, not getattr(target, k))
else:
if k in ('name','type','note','avatar'):
setattr(target, k, str(v))
elif k in ('init','hp','ac'):
setattr(target, k, int(v) if k in ('hp','ac') else float(v))
elif k in ('active','visible','dead','conc','reveal_ac'):
setattr(target, k, bool(v))
STATE.normalize()
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True})
@api_bp.post("/api/remove")
@require_token
def api_remove():
data = request.get_json(force=True, silent=True) or {}
aid = str(data.get('id', ''))
before = len(STATE.actors)
STATE.actors = [x for x in STATE.actors if x.id != aid]
if len(STATE.actors) != before:
STATE.normalize()
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'removed': len(STATE.actors) != before})
@api_bp.post("/api/next")
@require_token
def api_next():
if STATE.actors:
STATE.turn_idx = (STATE.turn_idx + 1) % len(STATE.actors)
if STATE.turn_idx == 0:
STATE.round += 1
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'round': STATE.round, 'turn_idx': STATE.turn_idx})
@api_bp.post("/api/prev")
@require_token
def api_prev():
if STATE.actors:
STATE.turn_idx = (STATE.turn_idx - 1) % len(STATE.actors)
if STATE.turn_idx == len(STATE.actors) - 1:
STATE.round = max(1, STATE.round - 1)
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'round': STATE.round, 'turn_idx': STATE.turn_idx})
@api_bp.post("/api/clear")
@require_token
def api_clear():
STATE.actors.clear()
STATE.turn_idx = 0
STATE.round = 1
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True})
@api_bp.post("/api/toggle_visible")
@require_token
def api_toggle_visible():
STATE.visible = not STATE.visible
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'visible': STATE.visible})
@api_bp.post("/api/dead_mode")
@require_token
def api_dead_mode():
data = request.get_json(force=True, silent=True) or {}
mode = str(data.get('mode','normal')).lower()
if mode not in ('normal','shrink','hide'):
abort(400)
STATE.dead_mode = mode
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'dead_mode': STATE.dead_mode})
@api_bp.post("/api/toggle_effect")
@require_token
def api_toggle_effect():
data = request.get_json(force=True, silent=True) or {}
aid = str(data.get('id',''))
effect = str(data.get('effect','')).strip().lower()
if not effect:
abort(400)
target = next((x for x in STATE.actors if x.id == aid), None)
if not target:
abort(404)
if effect in target.effects:
target.effects = [e for e in target.effects if e != effect]
else:
target.effects.append(effect)
STATE.normalize()
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'effects': target.effects})
@api_bp.post("/api/clear_effects")
@require_token
def api_clear_effects():
data = request.get_json(force=True, silent=True) or {}
aid = str(data.get('id',''))
target = next((x for x in STATE.actors if x.id == aid), None)
if not target:
abort(404)
target.effects = []
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True})
# Presets
@api_bp.get("/api/presets")
@require_token
def api_presets():
items = load_presets()
return jsonify({'items':[x.__dict__ for x in items]})
@api_bp.post("/api/preset/add")
@require_token
def api_preset_add():
data = request.get_json(force=True, silent=True) or {}
p = Preset(
id=str(uuid.uuid4()),
name=str(data.get('name','Unnamed'))[:64],
hp=int(data.get('hp',0)),
ac=int(data.get('ac',0)),
type=str(data.get('type','pc')),
note=str(data.get('note',''))[:120],
avatar=str(data.get('avatar',''))[:200],
)
items = load_presets()
items.append(p)
save_presets(items)
return jsonify({'ok': True, 'id': p.id})
@api_bp.post("/api/preset/remove")
@require_token
def api_preset_remove():
data = request.get_json(force=True, silent=True) or {}
pid = str(data.get('id',''))
items = load_presets()
before = len(items)
items = [x for x in items if x.id != pid]
changed = len(items) != before
if changed:
save_presets(items)
return jsonify({'ok': True, 'removed': changed})
@api_bp.post("/api/preset/apply")
@require_token
def api_preset_apply():
data = request.get_json(force=True, silent=True) or {}
pid = str(data.get('id',''))
initv = float(data.get('init', 0))
items = load_presets()
src = next((x for x in items if x.id == pid), None)
if not src:
abort(404)
reveal = (src.type == 'pc')
a = Actor(
id=str(uuid.uuid4()), name=src.name, init=initv, hp=src.hp, ac=src.ac,
type=src.type, note=src.note, avatar=src.avatar, reveal_ac=reveal,
)
STATE.actors.append(a)
STATE.normalize()
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'id': a.id})
# Preset Groups
@api_bp.get("/api/preset_groups")
@require_token
def api_preset_groups():
items = load_groups()
return jsonify({'items':[x.__dict__ for x in items]})
@api_bp.post("/api/preset/group/add")
@require_token
def api_group_add():
data = request.get_json(force=True, silent=True) or {}
name = str(data.get('name','')).strip()
mids = list(data.get('member_ids') or [])
if not name or not mids:
abort(400)
g = PresetGroup(id=str(uuid.uuid4()), name=name, member_ids=mids)
items = load_groups()
items.append(g)
save_groups(items)
return jsonify({'ok': True, 'id': g.id})
@api_bp.post("/api/preset/group/remove")
@require_token
def api_group_remove():
data = request.get_json(force=True, silent=True) or {}
gid = str(data.get('id',''))
items = load_groups()
before = len(items)
items = [x for x in items if x.id != gid]
changed = len(items) != before
if changed:
save_groups(items)
return jsonify({'ok': True, 'removed': changed})
@api_bp.post("/api/preset/group/apply")
@require_token
def api_group_apply():
data = request.get_json(force=True, silent=True) or {}
gid = str(data.get('id',''))
inits = data.get('inits')
init_single = data.get('init', None)
groups = load_groups()
presets = load_presets()
g = next((x for x in groups if x.id == gid), None)
if not g:
abort(404)
added = []
for idx, pid in enumerate(g.member_ids):
src = next((x for x in presets if x.id == pid), None)
if not src: continue
if isinstance(inits, list) and idx < len(inits):
initv = float(inits[idx] or 0)
elif init_single is not None:
initv = float(init_single)
else:
initv = 10.0
reveal = (src.type == 'pc')
a = Actor(
id=str(uuid.uuid4()), name=src.name, init=initv, hp=src.hp, ac=src.ac,
type=src.type, note=src.note, avatar=src.avatar, reveal_ac=reveal,
)
STATE.actors.append(a)
added.append(a.id)
if added:
STATE.normalize()
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'added': added, 'count': len(added)})
# Initialize defaults on import (with proper app context)
@api_bp.record_once
def _init_defaults(setup_state):
app = setup_state.app
with app.app_context():
ensure_default_icons()