358 lines
11 KiB
Python
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()
|