pre-push commit
This commit is contained in:
357
battleflow/routes/api.py
Normal file
357
battleflow/routes/api.py
Normal file
@@ -0,0 +1,357 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user