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/") def avatars(fn: str): return send_from_directory(current_app.config["AVATAR_DIR"], fn) @api_bp.route("/icons/") 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()