diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..db63dcc --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +# Simple helpers +PY ?= python +HOST ?= 0.0.0.0 +PORT ?= 5050 +TOKEN ?= changeme +DATA ?= + +.PHONY: install dev run test seed clean + +install: + $(PY) -m pip install -U pip + $(PY) -m pip install -e . -r requirements-dev.txt + +dev: + $(PY) -m pip install -r requirements-dev.txt + +run: + $(PY) -m battleflow.cli --host $(HOST) --port $(PORT) --token $(TOKEN) $(if $(DATA),--data-dir $(DATA),) + +test: + pytest -q + +seed: + curl -s -X POST "http://$(HOST):$(PORT)/admin/seed_icons?token=$(TOKEN)" >/dev/null || true + +clean: + rm -rf .pytest_cache .coverage build dist *.egg-info diff --git a/README.md b/README.md new file mode 100644 index 0000000..84603ea --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Battleflow + +Refactored project layout (same features, cleaner code). + +## Install & Run +```bash +pip install -e . +battleflow --host 0.0.0.0 --port 5050 --token YOURSECRET +``` +Open: +- Admin: `http://HOST:PORT/admin?token=YOURSECRET` +- Board: `http://HOST:PORT/board?token=YOURSECRET` + +## Data directories +Default data folder is `battleflow_data/`. If an older `initrack_data/` exists and `battleflow_data/` isn't present, it will be reused automatically (migration-by-reuse). +``` +battleflow_data/ + avatars/ + icons/ + state.json + presets.json + preset_groups.json +``` + +## Notes +- Admin requires `?token=...`. Board is public (use OBS Browser Source). +- Use the Admin button to seed curated condition icons (Game-Icons.net). +- No breaking functional changes vs. single-file. diff --git a/battleflow.egg-info/PKG-INFO b/battleflow.egg-info/PKG-INFO new file mode 100644 index 0000000..fe84866 --- /dev/null +++ b/battleflow.egg-info/PKG-INFO @@ -0,0 +1,8 @@ +Metadata-Version: 2.4 +Name: battleflow +Version: 0.1.0 +Summary: Battleflow — OBS-friendly initiative board for tabletop encounters +Author: Aetryos Workshop +Requires-Python: >=3.9 +Requires-Dist: flask>=3.0.0 +Requires-Dist: werkzeug>=3.0.0 diff --git a/battleflow.egg-info/SOURCES.txt b/battleflow.egg-info/SOURCES.txt new file mode 100644 index 0000000..f7efb9c --- /dev/null +++ b/battleflow.egg-info/SOURCES.txt @@ -0,0 +1,20 @@ +README.md +pyproject.toml +battleflow/__init__.py +battleflow/cli.py +battleflow/config.py +battleflow/models.py +battleflow/security.py +battleflow/state.py +battleflow/storage.py +battleflow.egg-info/PKG-INFO +battleflow.egg-info/SOURCES.txt +battleflow.egg-info/dependency_links.txt +battleflow.egg-info/entry_points.txt +battleflow.egg-info/requires.txt +battleflow.egg-info/top_level.txt +battleflow/routes/__init__.py +battleflow/routes/admin.py +battleflow/routes/api.py +battleflow/routes/board.py +tests/test_api_basic.py \ No newline at end of file diff --git a/battleflow.egg-info/dependency_links.txt b/battleflow.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/battleflow.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/battleflow.egg-info/entry_points.txt b/battleflow.egg-info/entry_points.txt new file mode 100644 index 0000000..aeee0df --- /dev/null +++ b/battleflow.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +battleflow = battleflow.cli:main diff --git a/battleflow.egg-info/requires.txt b/battleflow.egg-info/requires.txt new file mode 100644 index 0000000..6213353 --- /dev/null +++ b/battleflow.egg-info/requires.txt @@ -0,0 +1,2 @@ +flask>=3.0.0 +werkzeug>=3.0.0 diff --git a/battleflow.egg-info/top_level.txt b/battleflow.egg-info/top_level.txt new file mode 100644 index 0000000..a115679 --- /dev/null +++ b/battleflow.egg-info/top_level.txt @@ -0,0 +1 @@ +battleflow diff --git a/battleflow/__init__.py b/battleflow/__init__.py new file mode 100644 index 0000000..8e0b3d2 --- /dev/null +++ b/battleflow/__init__.py @@ -0,0 +1,16 @@ +from flask import Flask +from .config import load_config +from .routes.admin import admin_bp +from .routes.board import board_bp +from .routes.api import api_bp + +def create_app(**overrides) -> Flask: + app = Flask(__name__, static_folder="static", template_folder="templates") + load_config(app, **overrides) + + # Register blueprints + app.register_blueprint(board_bp) + app.register_blueprint(admin_bp) + app.register_blueprint(api_bp) + + return app diff --git a/battleflow/cli.py b/battleflow/cli.py new file mode 100644 index 0000000..6e20300 --- /dev/null +++ b/battleflow/cli.py @@ -0,0 +1,31 @@ +from __future__ import annotations +import argparse, os +from . import create_app +from .storage import ensure_default_icons, load_state +from .state import STATE + +def main(): + parser = argparse.ArgumentParser(prog="battleflow") + parser.add_argument('--host', default='127.0.0.1') + parser.add_argument('--port', type=int, default=5050) + parser.add_argument('--token', default=os.environ.get('BATTLEFLOW_TOKEN','changeme')) + parser.add_argument('--data-dir', default=os.environ.get('BATTLEFLOW_DATA_DIR')) + args = parser.parse_args() + + app = create_app(token=args.token, data_dir=args.data_dir) + with app.app_context(): + ensure_default_icons() + load_state(STATE) + + print(f""" +Name: {app.config['PRODUCT_NAME']} — {app.config['PRODUCT_SUBTITLE']} +Admin: http://{args.host}:{args.port}/admin?token={args.token} +Board: http://{args.host}:{args.port}/board?token={args.token} +Data dir: {app.config['DATA_DIR']} +Avatars: {app.config['AVATAR_DIR']} +Icons: {app.config['ICON_DIR']} +""") + app.run(host=args.host, port=args.port, debug=False) + +if __name__ == "__main__": + main() diff --git a/battleflow/config.py b/battleflow/config.py new file mode 100644 index 0000000..8e94f00 --- /dev/null +++ b/battleflow/config.py @@ -0,0 +1,38 @@ +# battleflow/config.py +import os + +DEFAULT_DATA_DIR = os.path.join(os.getcwd(), "battleflow_data") + +def load_config(app, **overrides): + app.config.setdefault("SECRET_KEY", os.environ.get("SECRET_KEY", "dev-secret")) + app.config.setdefault( + "COMBAT_TOKEN", + overrides.get("token") or os.environ.get("BATTLEFLOW_TOKEN", "changeme") + ) + + # Kies data-dir: override/env > default. Migreer legacy 'initrack_data' → 'battleflow_data' indien nodig. + data_dir = overrides.get("data_dir") or os.environ.get("BATTLEFLOW_DATA_DIR") + if not data_dir: + default_new = DEFAULT_DATA_DIR + legacy = os.path.join(os.getcwd(), "initrack_data") + # Als legacy bestaat en nieuwe nog niet: gebruik legacy (geen dataverlies). + data_dir = legacy if (os.path.isdir(legacy) and not os.path.exists(default_new)) else default_new + + app.config["DATA_DIR"] = data_dir + app.config["STATE_PATH"] = os.path.join(data_dir, "state.json") + app.config["PRESETS_PATH"] = os.path.join(data_dir, "presets.json") + app.config["PRESET_GROUPS_PATH"] = os.path.join(data_dir, "preset_groups.json") + app.config["AVATAR_DIR"] = os.path.join(data_dir, "avatars") + app.config["ICON_DIR"] = os.path.join(data_dir, "icons") + app.config.setdefault("MAX_CONTENT_LENGTH", 8 * 1024 * 1024) + + # Zorg dat mappen bestaan + os.makedirs(app.config["DATA_DIR"], exist_ok=True) + os.makedirs(app.config["AVATAR_DIR"], exist_ok=True) + os.makedirs(app.config["ICON_DIR"], exist_ok=True) + + # Branding + app.config.setdefault("PRODUCT_NAME", "Battleflow") + app.config.setdefault("PRODUCT_SUBTITLE", "by Aetryos Workshop") + + return app diff --git a/battleflow/models.py b/battleflow/models.py new file mode 100644 index 0000000..0be5c0f --- /dev/null +++ b/battleflow/models.py @@ -0,0 +1,64 @@ +from __future__ import annotations +from dataclasses import dataclass, field, asdict +from typing import List, Dict + +@dataclass +class Actor: + id: str + name: str + init: float + hp: int = 0 + ac: int = 0 + type: str = 'pc' # 'pc' | 'npc' | 'monster' + note: str = '' + avatar: str = '' # URL or local filename under /avatars + active: bool = True + visible: bool = True + dead: bool = False + conc: bool = False + reveal_ac: bool = False + effects: List[str] = field(default_factory=list) + +@dataclass +class Preset: + id: str + name: str + hp: int = 0 + ac: int = 0 + type: str = 'pc' + note: str = '' + avatar: str = '' + +@dataclass +class PresetGroup: + id: str + name: str + member_ids: List[str] = field(default_factory=list) # preset ids + +# Conditions + icons +KNOWN_CONDITIONS = [ + 'poisoned','prone','grappled','restrained','stunned','blinded','deafened', + 'charmed','frightened','paralyzed','petrified','unconscious' +] +CONDITION_ICON_FILES: Dict[str, str] = { c: f"{c}.svg" for c in KNOWN_CONDITIONS } + +CURATED_ICON_SLUGS: Dict[str, tuple] = { + 'blinded': ('delapouite', 'blindfold'), + 'deafened': ('skoll', 'hearing-disabled'), + 'charmed': ('lorc', 'charm'), + 'frightened': ('lorc', 'terror'), + 'poisoned': ('sbed', 'poison-cloud'), + 'prone': ('delapouite', 'half-body-crawling'), + 'grappled': ('lorc', 'grab'), + 'restrained': ('lorc', 'manacles'), + 'stunned': ('skoll', 'knockout'), + 'paralyzed': ('delapouite', 'frozen-body'), + 'petrified': ('delapouite', 'stone-bust'), + 'unconscious':('lorc', 'coma'), +} + +CONDITION_EMOJI = { + 'poisoned':'☠️','prone':'🧎','grappled':'🤝','restrained':'🪢','stunned':'💫', + 'blinded':'🙈','deafened':'🧏','charmed':'💖','frightened':'😱','paralyzed':'🧍', + 'petrified':'🪨','unconscious':'💤' +} diff --git a/battleflow/routes/__init__.py b/battleflow/routes/__init__.py new file mode 100644 index 0000000..6ef4272 --- /dev/null +++ b/battleflow/routes/__init__.py @@ -0,0 +1 @@ +# namespace placeholder \ No newline at end of file diff --git a/battleflow/routes/admin.py b/battleflow/routes/admin.py new file mode 100644 index 0000000..cc2a092 --- /dev/null +++ b/battleflow/routes/admin.py @@ -0,0 +1,22 @@ +from flask import Blueprint, render_template, request, redirect, url_for, current_app +from ..security import require_token +from ..storage import seed_curated_icons + +admin_bp = Blueprint("admin", __name__) + +@admin_bp.route("/admin") +@require_token +def admin(): + token = request.args.get("token", "") + return render_template("admin.html", + token=token, + name=current_app.config["PRODUCT_NAME"], + subtitle=current_app.config["PRODUCT_SUBTITLE"], + data_dir=current_app.config["DATA_DIR"]) + +@admin_bp.post("/admin/seed_icons") +@require_token +def admin_seed_icons(): + seed_curated_icons(overwrite=True) + token = request.args.get("token", "") + return redirect(url_for("admin.admin", token=token)) diff --git a/battleflow/routes/api.py b/battleflow/routes/api.py new file mode 100644 index 0000000..346bfef --- /dev/null +++ b/battleflow/routes/api.py @@ -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/") +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() diff --git a/battleflow/routes/board.py b/battleflow/routes/board.py new file mode 100644 index 0000000..e8283eb --- /dev/null +++ b/battleflow/routes/board.py @@ -0,0 +1,22 @@ +from flask import Blueprint, render_template, current_app, Response + +board_bp = Blueprint("board", __name__) + +FAVICON_SVG = "" + +@board_bp.route("/") +def root(): + # convenience: redirect to admin with token if configured + from flask import redirect, url_for, current_app + t = current_app.config.get("COMBAT_TOKEN", "changeme") + return redirect(url_for("admin.admin", token=t)) + +@board_bp.route("/board") +def board(): + return render_template("board.html", + name=current_app.config["PRODUCT_NAME"], + subtitle=current_app.config["PRODUCT_SUBTITLE"]) + +@board_bp.route("/favicon.svg") +def favicon_svg(): + return Response(FAVICON_SVG, 200, {"Content-Type": "image/svg+xml"}) diff --git a/battleflow/security.py b/battleflow/security.py new file mode 100644 index 0000000..d1df472 --- /dev/null +++ b/battleflow/security.py @@ -0,0 +1,12 @@ +from functools import wraps +from flask import request, abort, current_app + +def require_token(f): + @wraps(f) + def wrapper(*args, **kwargs): + expected = current_app.config.get("COMBAT_TOKEN") + supplied = request.args.get("token") or request.headers.get("X-Token") + if not expected or supplied != expected: + abort(401) + return f(*args, **kwargs) + return wrapper diff --git a/battleflow/state.py b/battleflow/state.py new file mode 100644 index 0000000..ae86e5b --- /dev/null +++ b/battleflow/state.py @@ -0,0 +1,63 @@ +from __future__ import annotations +import threading, time +from dataclasses import asdict +from typing import Any, Dict, List +from .models import Actor, KNOWN_CONDITIONS, CONDITION_EMOJI, CONDITION_ICON_FILES + +def now_ms() -> int: + return int(time.time() * 1000) + +class CombatState: + def __init__(self) -> None: + self.actors: List[Actor] = [] + self.turn_idx: int = 0 + self.round: int = 1 + self.visible: bool = True + self.dead_mode: str = 'normal' # 'normal' | 'shrink' | 'hide' + self.updated_at: int = now_ms() + self.version: int = 1 + self._cv = threading.Condition() + + def touch(self) -> None: + self.updated_at = now_ms() + self.version += 1 + + def sorted_actors(self) -> List[Actor]: + return sorted(self.actors, key=lambda a: a.init, reverse=True) + + def normalize(self) -> None: + self.actors = self.sorted_actors() + if self.turn_idx >= len(self.actors): + self.turn_idx = 0 + + def to_public(self) -> Dict[str, Any]: + return { + 'actors': [asdict(a) for a in self.sorted_actors()], + 'turn_idx': self.turn_idx, + 'round': self.round, + 'visible': self.visible, + 'dead_mode': self.dead_mode, + 'updated_at': self.updated_at, + 'version': self.version, + 'condition_icons': CONDITION_ICON_FILES, + 'condition_emoji': CONDITION_EMOJI, + 'known_conditions': KNOWN_CONDITIONS, + } + + def broadcast(self) -> None: + with self._cv: + self.touch() + self._cv.notify_all() + + def wait_for(self, since: int, timeout: float = 25.0) -> Dict[str, Any]: + end = time.time() + timeout + with self._cv: + while self.version <= since: + remaining = end - time.time() + if remaining <= 0: + break + self._cv.wait(timeout=remaining) + return self.to_public() + +# Singleton +STATE = CombatState() diff --git a/battleflow/static/css/board.css b/battleflow/static/css/board.css new file mode 100644 index 0000000..08461a0 --- /dev/null +++ b/battleflow/static/css/board.css @@ -0,0 +1,62 @@ +/* Extracted from single-file version */ +:root{ --panel: rgba(20,22,30,0.88); --text:#e7eaf1; --muted:#9aa0aa; --pc:#7dd3fc; --npc:#a78bfa; --monster:#fca5a5; --hl:#fde047; --dead:#ef4444; } +*{ box-sizing:border-box; } +html,body{ margin:0; padding:0; background:transparent; color:var(--text); font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Helvetica Neue,Arial,sans-serif; } +.wrapper{ width:100%; display:flex; justify-content:center; } +.panel{ margin:8px; padding:10px 12px; border-radius:14px; background:var(--panel); backdrop-filter: blur(6px); width: calc(100vw - 16px); max-width:none; } +.header{ display:flex; align-items:flex-end; justify-content:space-between; margin-bottom:6px; gap:8px; } +.h-title{ font-weight:700; letter-spacing:0.5px; } +.h-sub{ font-size:12px; color:var(--muted); } +.list{ display:flex; flex-wrap:wrap; gap:10px; align-items:stretch; justify-content:flex-start; } + +/* Status pill */ +.statuspill{ font-size:12px; padding:2px 8px; border-radius:999px; border:1px solid rgba(255,255,255,0.18); background:rgba(255,255,255,0.06); } +.statuspill.ok{ border-color:#14532d; background:#052e16; color:#bbf7d0; } +.statuspill.warn{ border-color:#334155; background:#0b1220; color:#e5e7eb; } +.statuspill.bad{ border-color:#7f1d1d; background:#1a0b0b; color:#fecaca; } + +/* Vertical cards that flow left→right */ +.row{ position:relative; display:flex; flex-direction:column; align-items:center; gap:8px; padding:10px; border-radius:12px; background: rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.06); width: clamp(150px, 14vw, 220px); transition: width .15s ease, opacity .15s ease, transform .15s ease; } +.row.active{ outline:2px solid var(--hl); box-shadow:0 0 16px rgba(253,224,71,0.35) inset; } +.type-pc{ border-top:3px solid var(--pc); } +.type-npc{ border-top:3px solid var(--npc); } +.type-monster{ border-top:3px solid var(--monster); } + +.portrait{ width:96px; height:96px; border-radius:12px; object-fit:cover; background:rgba(255,255,255,0.06); } +.noavatar{ width:96px; height:96px; border-radius:12px; background:rgba(255,255,255,0.06); } +.name{ display:flex; flex-direction:column; align-items:center; text-align:center; min-width:0; } +.name .n{ font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width: 100%; } +.name .meta{ font-size:12px; color:var(--muted); } +.tags{ display:flex; gap:6px; flex-wrap:wrap; justify-content:center; margin-top:2px; font-size:12px; } +.tag{ display:inline-flex; align-items:center; gap:6px; padding:2px 8px; border-radius:10px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.06); } +.tag .ico_wrap{ width:16px; height:16px; border-radius:5px; overflow:hidden; background:#0b1220; display:inline-flex; } +.tag img.ico{ width:100%; height:100%; display:block; } +.concStar{ position:absolute; top:8px; right:8px; font-weight:700; filter: drop-shadow(0 0 6px rgba(253,224,71,0.6)); } + +/* DEAD state */ +.dead{ border-color: rgba(239,68,68,0.7); box-shadow: inset 0 0 0 2px rgba(239,68,68,0.35); } +.dead .portrait{ filter: grayscale(1) brightness(0.55); } +.deathBadge{ position:absolute; top:8px; left:8px; width:22px; height:22px; border-radius:999px; background:#7f1d1d; color:#fff; display:flex; align-items:center; justify-content:center; font-size:13px; box-shadow:0 0 2px rgba(239,68,68,0.7), 0 2px 6px rgba(0,0,0,0.4); } +.dead .name .n{ text-decoration: line-through; opacity:0.7; } +.dead .tags{ opacity:0.7; } +/* Shrink mode for dead cards */ +.shrunk{ width: clamp(120px, 11vw, 170px); opacity:.72; transform: scale(.95); } + +.footer{ margin-top:6px; font-size:12px; color:var(--muted); display:flex; justify-content:space-between; } +.hideall{ display:none; } + +/* --- overlay for auth/errors --- */ +.overlay{ position:fixed; inset:0; display:none; align-items:center; justify-content:center; background:rgba(0,0,0,0.6); z-index:9999; } +.overlay.show{ display:flex; } +.overlay .box{ background:#0b0d12; color:#e9eef4; border:1px solid #293145; border-radius:12px; padding:16px; width:min(520px,92vw); } +.overlay .box h3{ margin:0 0 8px; font-size:18px; } +.overlay .box p{ margin:8px 0; color:#94a3b8; } +.overlay .row{ display:flex; gap:8px; align-items:center; margin-top:10px; } +.overlay input{ flex:1; padding:8px; border-radius:8px; border:1px solid #293145; background:#0d111a; color:#e9eef4; } +.overlay button{ padding:8px 10px; border-radius:10px; border:1px solid #334155; background:#111827; color:#e9eef4; cursor:pointer; } + +@media (max-width: 740px){ + .list{ flex-direction:column; } + .row{ width:auto; align-items:flex-start; } + .name{ align-items:flex-start; text-align:left; } +} diff --git a/battleflow/static/favicon.svg b/battleflow/static/favicon.svg new file mode 100644 index 0000000..3e72c9c --- /dev/null +++ b/battleflow/static/favicon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/battleflow/static/js/admin.js b/battleflow/static/js/admin.js new file mode 100644 index 0000000..3b68fc7 --- /dev/null +++ b/battleflow/static/js/admin.js @@ -0,0 +1,266 @@ +function initAdmin(token){ +const overlay = document.getElementById('authOverlay'); +const authMsg = document.getElementById('authMsg'); +const tokenInput = document.getElementById('tokenInput'); +const setTokenBtn = document.getElementById('setTokenBtn'); +const statusEl = document.getElementById('statuspill'); +function showOverlay(msg){ + if(authMsg && msg){ authMsg.textContent = msg; } + if(overlay){ overlay.classList.add('show'); } + if(setTokenBtn && tokenInput){ + setTokenBtn.onclick = () => { + const t = (tokenInput.value||'').trim(); + if(!t) return; + const url = new URL(location.href); + url.searchParams.set('token', t); + location.href = url.toString(); + }; + } +} +function hideOverlay(){ if(overlay){ overlay.classList.remove('show'); } } +function setStatus(txt, cls){ if(!statusEl) return; statusEl.textContent = txt; statusEl.className = 'statuspill ' + (cls||''); } + +let state=null, since=0, presets=[], groups=[]; +const rows = document.getElementById('rows'); +const presetlist = document.getElementById('presetlist'); +const pg_list = document.getElementById('pg_list'); +const g_checks = document.getElementById('g_checks'); + +function openBoard(){ window.open('/board?token='+encodeURIComponent(token), '_blank'); } +window.openBoard = openBoard; + +function iconImg(name){ return `${name}`; } +function typeChip(t){ return `${t}`; } + +function row(a, idx){ + const main = ` +${idx+1} +${a.name} +${a.init} +${a.hp} +${a.ac}${a.reveal_ac?'':' (hidden)'} +${typeChip(a.type)} + + + + + + + + + +`; + const active = (a.effects||[]); + const KNOWN_CONDITIONS = ['poisoned','prone','grappled','restrained','stunned','blinded','deafened','charmed','frightened','paralyzed','petrified','unconscious']; + const cond = `
${ + KNOWN_CONDITIONS.map(c=>``).join(' ') + }
`; + return main + cond; +} + +function render(){ + document.getElementById('round').textContent = state.round; + rows.innerHTML = ''; + (state.actors||[]).forEach((a, idx)=>{ rows.insertAdjacentHTML('beforeend', row(a, idx)); }); +} + +function renderPresets(){ + presetlist.innerHTML = ''; + presets.forEach(p=>{ + const div = document.createElement('div'); div.className='preset'; + if(p.avatar){ const img=document.createElement('img'); img.className='pav'; img.alt=p.name; img.src=(p.avatar.startsWith('http')||p.avatar.startsWith('/'))?p.avatar:('/avatars/'+encodeURIComponent(p.avatar)); div.appendChild(img); } + const span = document.createElement('span'); span.innerHTML = `${typeChip(p.type)} ${p.name}${p.ac? ' · AC '+p.ac: ''}${p.hp? ' · HP '+p.hp: ''}`; div.appendChild(span); + const addb = document.createElement('button'); addb.className='btn'; addb.textContent='➕ Add'; addb.onclick=()=>presetApply(p.id); div.appendChild(addb); + const delb = document.createElement('button'); delb.className='btn btn-danger'; delb.textContent='🗑'; delb.style.marginLeft='6px'; delb.onclick=()=>presetRemove(p.id); div.appendChild(delb); + presetlist.appendChild(div); + }); + renderGroupChecks(); +} + +function renderGroupChecks(){ + g_checks.innerHTML = ''; + if(!presets.length){ g_checks.innerHTML = '
No presets yet.
'; return; } + presets.forEach(p=>{ + const id = 'chk_'+p.id; const w=document.createElement('div'); + w.innerHTML = ``; + g_checks.appendChild(w); + }); +} + +function renderGroups(){ + pg_list.innerHTML = ''; + groups.forEach(g=>{ + const div = document.createElement('div'); div.className='preset'; + const names = g.member_ids.map(id=>{ const p=presets.find(x=>x.id===id); return p? p.name : 'unknown'; }).join(', '); + const span = document.createElement('span'); span.innerHTML = `${g.name} (${g.member_ids.length} members) — ${names}`; + div.appendChild(span); + const addb = document.createElement('button'); addb.className='btn'; addb.textContent='➕ Add Group'; addb.onclick=()=>groupApply(g.id); div.appendChild(addb); + const delb = document.createElement('button'); delb.className='btn btn-danger'; delb.textContent='🗑'; delb.style.marginLeft='6px'; delb.onclick=()=>groupRemove(g.id); div.appendChild(delb); + pg_list.appendChild(div); + }); +} + +async function post(p, body){ + const r = await fetch(p+`?token=${encodeURIComponent(token)}`, { method:'POST', headers:{'Content-Type':'application/json'}, body: body?JSON.stringify(body):null }); + if(r.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid. Fix the token and reload.'); return false; } + if(!r.ok){ const t = await r.text(); alert('Error '+r.status+' '+t); setStatus('Reconnecting…','warn'); } + return r.ok; +} +async function get(p){ + const r = await fetch(p+`?token=${encodeURIComponent(token)}`); + if(r.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid. Fix the token and reload.'); return null; } + if(!r.ok){ const t = await r.text(); alert('Error '+r.status+' '+t); setStatus('Reconnecting…','warn'); return null; } + return r.json(); +} + +// Upload helpers +async function uploadAvatar(fileInputId, targetInputId, previewId){ + const fileEl = document.getElementById(fileInputId); + if(fileEl && fileEl.files && fileEl.files.length){ + await uploadAvatarFile(fileEl.files[0], targetInputId, previewId); + fileEl.value = ''; + } else { alert('Select or drop a file first.'); } +} +async function uploadAvatarFile(file, targetInputId, previewId){ + const fd = new FormData(); fd.append('file', file); + const r = await fetch('/api/upload_avatar?token='+encodeURIComponent(token), { method: 'POST', body: fd }); + if(r.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid.'); return; } + if(!r.ok){ const t = await r.text(); alert('Upload error '+r.status+' '+t); setStatus('Reconnecting…','warn'); return; } + const j = await r.json(); + document.getElementById(targetInputId).value = j.filename; + if(previewId){ setPreview(j.filename, previewId); } +} +function setPreview(val, previewId){ + const img = document.getElementById(previewId); + if(!img) return; if(!val){ img.src=''; img.style.opacity=.3; return; } + const src = (val.startsWith('http')||val.startsWith('/')) ? val : ('/avatars/'+encodeURIComponent(val)); + img.src = src; img.style.opacity=1; +} +function bindPreview(inputId, previewId){ const el = document.getElementById(inputId); el.addEventListener('input', ()=> setPreview(el.value.trim(), previewId)); setPreview(el.value.trim(), previewId); } +function makeDropzone(zoneId, targetInputId, previewId){ + const dz = document.getElementById(zoneId); + ['dragenter','dragover'].forEach(ev=> dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.add('drag'); })); + ['dragleave','drop'].forEach(ev=> dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.remove('drag'); })); + dz.addEventListener('drop', async (e)=>{ const files = e.dataTransfer.files; if(!files || !files.length) return; await uploadAvatarFile(files[0], targetInputId, previewId); }); +} + +// Mutations +async function add(){ + const name = document.getElementById('name').value.trim(); + const init = parseFloat(document.getElementById('init').value||'0'); + const hp = parseInt(document.getElementById('hp').value||'0'); + const ac = parseInt(document.getElementById('ac').value||'0'); + const type = document.getElementById('type').value; + const note = document.getElementById('note').value.trim(); + const avatar = document.getElementById('avatar').value.trim(); + await post('/api/add', { name, init, hp, ac, type, note, avatar }); +} +async function clearAll(){ await post('/api/clear'); } +async function toggleVisible(){ await post('/api/toggle_visible'); } +async function next(){ await post('/api/next'); } +async function prev(){ await post('/api/prev'); } +async function toggle(id, field){ await post('/api/update', { id, [field]: 'toggle' }); } +async function toggleEffect(id, effect){ await post('/api/toggle_effect', { id, effect }); } +async function clearEffects(id){ await post('/api/clear_effects', { id }); } +async function removeA(id){ await post('/api/remove', { id }); } +async function setDeadMode(mode){ await post('/api/dead_mode', { mode }); updateDeadSeg(mode); } +function updateDeadSeg(mode){ const seg = document.getElementById('deadseg'); if(!seg) return; seg.querySelectorAll('button[data-mode]').forEach(b=>{ b.classList.toggle('on', b.getAttribute('data-mode')===mode); }); } + +// Presets +async function presetAdd(){ + const name = document.getElementById('p_name').value.trim(); + const type = document.getElementById('p_type').value; + const hp = parseInt(document.getElementById('p_hp').value||'0'); + const ac = parseInt(document.getElementById('p_ac').value||'0'); + const note = document.getElementById('p_note').value.trim(); + const avatar = document.getElementById('p_avatar').value.trim(); + await post('/api/preset/add', { name, type, hp, ac, note, avatar }); + await loadPresets(); await loadPresetGroups(); +} +async function presetRemove(id){ await post('/api/preset/remove', { id }); await loadPresets(); await loadPresetGroups(); } +async function presetApply(id){ + const init = prompt('Initiative roll for this actor?', '10'); + const p = parseFloat(init||'0'); + await post('/api/preset/apply', { id, init: p }); +} + +// Groups +async function groupAdd(){ + const name = document.getElementById('g_name').value.trim(); + const member_ids = Array.from(g_checks.querySelectorAll('input[type=checkbox]:checked')).map(x=>x.getAttribute('data-id')); + if(!name){ alert('Name required'); return; } + if(!member_ids.length){ alert('Select at least one preset'); return; } + await post('/api/preset/group/add', { name, member_ids }); + document.getElementById('g_name').value=''; + g_checks.querySelectorAll('input[type=checkbox]').forEach(c=> c.checked=false); + await loadPresetGroups(); +} +async function groupRemove(id){ await post('/api/preset/group/remove', { id }); await loadPresetGroups(); } +async function groupApply(id){ + const r = await fetch('/api/preset_groups?token='+encodeURIComponent(token)); + if(r.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid.'); return; } + const all = await r.json(); groups = all.items||[]; + const g = groups.find(x=>x.id===id); if(!g){ alert('Group not found'); return; } + const mode = (prompt('Group add mode: single | list | per | step','single')||'single').trim().toLowerCase(); + let payload = { id }; + if(mode==='list'){ + const raw = prompt('Comma-separated initiatives in group order','12,11,10'); + if(raw && raw.trim().length){ payload.inits = raw.split(',').map(s=>parseFloat((s||'0').trim())); } + } else if(mode==='per'){ + const arr = []; + g.member_ids.forEach((pid, i)=>{ + const p = presets.find(x=>x.id===pid); const lbl = p? p.name : ('Member '+(i+1)); + const v = prompt('Initiative for '+lbl,'10'); arr.push(parseFloat(v||'0')); + }); + payload.inits = arr; + } else if(mode==='step'){ + const base = parseFloat(prompt('Base initiative for first member','12')||'12'); + const step = parseFloat(prompt('Step per next member (e.g. -1 for descending)','-1')||'-1'); + payload.inits = g.member_ids.map((_,i)=> base + i*step); + } else { + const v = prompt('Single initiative value for all members','12'); + payload.init = parseFloat(v||'12'); + } + await post('/api/preset/group/apply', payload); +} + +async function loadPresets(){ const r = await get('/api/presets'); if(r) { presets = r.items||[]; renderPresets(); } } +async function loadPresetGroups(){ const r = await get('/api/preset_groups'); if(r){ groups = r.items||[]; renderGroups(); } } + +async function poll(){ + try{ + const r = await fetch(`/api/state?since=${since}&token=${encodeURIComponent(token)}`); + if(r.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid. Fix the token and reload.'); setTimeout(poll, 1500); return; } + if(r.ok){ hideOverlay(); setStatus('Connected','ok'); state = await r.json(); since = state.version; render(); updateDeadSeg(state.dead_mode||'normal'); } + else { setStatus('Reconnecting…','warn'); } + } catch(e){ setStatus('Reconnecting…','warn'); } + setTimeout(poll, 300); +} + +// init +window.clearAll = clearAll; +window.toggleVisible = toggleVisible; +window.next = next; +window.prev = prev; +window.toggle = toggle; +window.toggleEffect = toggleEffect; +window.clearEffects = clearEffects; +window.removeA = removeA; +window.setDeadMode = setDeadMode; +window.presetAdd = presetAdd; +window.presetRemove = presetRemove; +window.presetApply = presetApply; +window.groupAdd = groupAdd; +window.groupRemove = groupRemove; +window.groupApply = groupApply; + +bindPreview('avatar','avatar_preview'); +bindPreview('p_avatar','p_avatar_preview'); +makeDropzone('avatar_drop','avatar','avatar_preview'); +makeDropzone('p_avatar_drop','p_avatar','p_avatar_preview'); + +loadPresets(); +loadPresetGroups(); +setStatus('Connecting…','warn'); +poll(); +} diff --git a/battleflow/static/js/board.js b/battleflow/static/js/board.js new file mode 100644 index 0000000..68057ea --- /dev/null +++ b/battleflow/static/js/board.js @@ -0,0 +1,74 @@ +(function(){ + const qs = new URLSearchParams(window.location.search); + const token = qs.get('token'); + const panel = document.getElementById('panel'); + const list = document.getElementById('list'); + const roundEl = document.getElementById('round'); + const updatedEl = document.getElementById('updated'); + const countEl = document.getElementById('count'); + let ICONS = {}; let EMOJI = {}; let KNOWN = []; + function fmtAgo(ms){ const d=Date.now()-ms; if(d<1500)return 'just now'; const s=Math.floor(d/1000); if(s<60) return s+'s ago'; const m=Math.floor(s/60); if(m<60) return m+'m ago'; const h=Math.floor(m/60); return h+'h ago'; } + function avatarSrc(a){ if(!a.avatar) return ''; if(a.avatar.startsWith('http') || a.avatar.startsWith('/')) return a.avatar; return '/avatars/' + encodeURIComponent(a.avatar); } + function iconSrc(key){ const f = ICONS[key]; return f ? ('/icons/' + encodeURIComponent(f)) : '' } + function render(state){ + ICONS = state.condition_icons||{}; EMOJI = state.condition_emoji||{}; KNOWN = state.known_conditions||[]; + const deadMode = state.dead_mode || 'normal'; + if(!state.visible){ panel.classList.add('hideall'); return; } else { panel.classList.remove('hideall'); } + roundEl.textContent = state.round; + updatedEl.textContent = fmtAgo(state.updated_at); + list.innerHTML = ''; + (state.actors||[]).forEach((a, idx)=>{ + if(!a.visible) return; + if(a.dead && deadMode==='hide') return; + const cls = ['row','type-'+(a.type||'pc')]; + if(idx===state.turn_idx) cls.push('active'); + if(a.dead){ cls.push('dead'); if(deadMode==='shrink') cls.push('shrunk'); } + const card = document.createElement('div'); + card.className = cls.join(' '); + + // Portrait + const portrait = document.createElement(a.avatar? 'img':'div'); + if(a.avatar){ portrait.src = avatarSrc(a); portrait.className='portrait'; portrait.alt=a.name; } + else{ portrait.className='noavatar'; } + card.appendChild(portrait); + + // Death & Concentration badges + if(a.dead){ const db = document.createElement('div'); db.className='deathBadge'; db.textContent='☠'; card.appendChild(db); } + if(a.conc){ const cs = document.createElement('div'); cs.className='concStar'; cs.textContent='✦'; card.appendChild(cs); } + + // Name + meta + const name = document.createElement('div'); name.className='name'; + const n = document.createElement('div'); n.className='n'; n.textContent=a.name; name.appendChild(n); + const meta = document.createElement('div'); meta.className='meta'; + const bits = []; + if(a.hp){ bits.push('HP '+a.hp); } + if(a.reveal_ac && a.ac){ bits.push('AC '+a.ac); } + bits.push('Init '+Math.floor(a.init)); + if(a.note){ bits.push(a.note); } + meta.textContent = bits.join(' · '); + name.appendChild(meta); + card.appendChild(name); + + // Conditions row + if (Array.isArray(a.effects) && a.effects.length){ + const tags = document.createElement('div'); tags.className='tags'; + a.effects.forEach(e=>{ const t=document.createElement('span'); t.className='tag'; const wrap=document.createElement('span'); wrap.className='ico_wrap'; const src=iconSrc(e); if(src){ const i=document.createElement('img'); i.className='ico'; i.alt=e; i.src=src; wrap.appendChild(i); } else { wrap.textContent='•'; } t.appendChild(wrap); const lbl=document.createElement('span'); lbl.textContent=e; t.appendChild(lbl); tags.appendChild(t); }); + card.appendChild(tags); + } + + list.appendChild(card); + }); + countEl.textContent = list.childElementCount + ' shown'; + } + let last = null; let since = 0; + async function poll(){ + try{ + const r = await fetch(`/api/state?since=${since}&token=${encodeURIComponent(token)}`); + if(!r.ok){ throw new Error('state '+r.status); } + const s = await r.json(); last = s; render(s); since = s.version; + }catch(e){ } + setTimeout(poll, 200); + } + setInterval(()=>{ if(last) updatedEl.textContent = fmtAgo(last.updated_at); }, 3000); + poll(); +})(); \ No newline at end of file diff --git a/battleflow/storage.py b/battleflow/storage.py new file mode 100644 index 0000000..ac03853 --- /dev/null +++ b/battleflow/storage.py @@ -0,0 +1,122 @@ +from __future__ import annotations +import json, os, uuid +from typing import List, Dict +from flask import current_app +from .models import Actor, Preset, PresetGroup, CONDITION_ICON_FILES, CURATED_ICON_SLUGS + +PLACEHOLDER_BG = '#334155' +PLACEHOLDER_FG = '#e2e8f0' +GAME_ICONS_BASE = 'https://raw.githubusercontent.com/game-icons/icons/master' + +def paths(): + return dict( + STATE=current_app.config["STATE_PATH"], + PRESETS=current_app.config["PRESETS_PATH"], + GROUPS=current_app.config["PRESET_GROUPS_PATH"], + AVATARS=current_app.config["AVATAR_DIR"], + ICONS=current_app.config["ICON_DIR"], + DATA=current_app.config["DATA_DIR"], + ) + +def save_state(state) -> None: + p = paths() + tmp = { + 'actors': [a.__dict__ for a in state.actors], + 'turn_idx': state.turn_idx, + 'round': state.round, + 'visible': state.visible, + 'dead_mode': state.dead_mode, + } + with open(p["STATE"], 'w', encoding='utf-8') as f: + json.dump(tmp, f, ensure_ascii=False, indent=2) + +def load_state(state) -> None: + p = paths() + if not os.path.exists(p["STATE"]): + return + try: + with open(p["STATE"], 'r', encoding='utf-8') as f: + data = json.load(f) + state.actors = [Actor(**a) for a in data.get('actors', [])] + state.turn_idx = int(data.get('turn_idx', 0)) + state.round = int(data.get('round', 1)) + state.visible = bool(data.get('visible', True)) + state.dead_mode = str(data.get('dead_mode', 'normal')) + state.normalize() + state.touch() + except Exception: + pass + +def save_presets(items: List[Preset]) -> None: + p = paths() + with open(p["PRESETS"], 'w', encoding='utf-8') as f: + json.dump([x.__dict__ for x in items], f, ensure_ascii=False, indent=2) + +def load_presets() -> List[Preset]: + p = paths() + if not os.path.exists(p["PRESETS"]): + return [] + try: + with open(p["PRESETS"], 'r', encoding='utf-8') as f: + arr = json.load(f) + return [Preset(**x) for x in arr] + except Exception: + return [] + +def save_groups(items: List[PresetGroup]) -> None: + p = paths() + with open(p["GROUPS"], 'w', encoding='utf-8') as f: + json.dump([x.__dict__ for x in items], f, ensure_ascii=False, indent=2) + +def load_groups() -> List[PresetGroup]: + p = paths() + if not os.path.exists(p["GROUPS"]): + return [] + try: + with open(p["GROUPS"], 'r', encoding='utf-8') as f: + arr = json.load(f) + return [PresetGroup(**x) for x in arr] + except Exception: + return [] + +def ensure_default_icons() -> None: + p = paths() + os.makedirs(p["ICONS"], exist_ok=True) + for c, fn in CONDITION_ICON_FILES.items(): + dest = os.path.join(p["ICONS"], fn) + if os.path.exists(dest): + continue + letter = (c[:1] or '?').upper() + svg = f""" + +{letter} +""" + try: + with open(dest, 'w', encoding='utf-8') as f: + f.write(svg) + except Exception: + pass + +def seed_curated_icons(overwrite: bool = True) -> list[dict]: + ensure_default_icons() + out = [] + try: + from urllib.request import urlopen + from urllib.error import URLError, HTTPError + p = paths() + for cond, (author, slug) in CURATED_ICON_SLUGS.items(): + dest = os.path.join(p["ICONS"], f'{cond}.svg') + url = f"https://raw.githubusercontent.com/game-icons/icons/master/{author}/{slug}.svg" + try: + with urlopen(url, timeout=10) as r: + svg = r.read() + if (not os.path.exists(dest)) or overwrite: + with open(dest, 'wb') as f: + f.write(svg) + out.append({'condition': cond, 'status': 'downloaded'}) + else: + out.append({'condition': cond, 'status': 'exists'}) + except (URLError, HTTPError) as e: + out.append({'condition': cond, 'status': f'fallback ({e.__class__.__name__})'}) + finally: + return out diff --git a/battleflow/templates/admin.html b/battleflow/templates/admin.html new file mode 100644 index 0000000..c58c679 --- /dev/null +++ b/battleflow/templates/admin.html @@ -0,0 +1,235 @@ + + + + + + ✦ {{ name }} – Admin + + + + +
+
+
{{ name|upper }} ADMIN
+
{{ subtitle }}
+
+ + + + +
+ Dead cards: + + + +
+
+
+
+
+ +
+ +
+
+ +
+
+

Add Actor

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
Drop image here
+ + preview +
+
+ +
or drag & drop ↑
+
+
+
+
+ +
+
+
+

Order (desc by init) — Round 1

+ + + + + +
#NameInitHPACTypeFlags
+
+
+ +
+
+

Add Preset

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
Drop image here
+ + preview +
+
+
+ +
or drag & drop ↑
+
+
+
+
+ +
+
+
+

Presets

+
+
+
+ +
+
+

Create Preset Group (party)

+
+
+ + +
+
+
+ +
+
+
+
+
+

Preset Groups

+
+
+
+ +

Icons live in {{ data_dir }}/icons. Curated set from Game-Icons.net (CC‑BY 3.0). Authors: Lorc, Delapouite, Skoll, sbed. Replace any icon by dropping an SVG with the same name (e.g., poisoned.svg).

+ + + + + diff --git a/battleflow/templates/board.html b/battleflow/templates/board.html new file mode 100644 index 0000000..ea7465c --- /dev/null +++ b/battleflow/templates/board.html @@ -0,0 +1,40 @@ + + + + + + ✦ {{ name }} + + + + +
+
+
+
+
{{ name }}
+
{{ subtitle }}
+
+
+
Round 1 · Updated
+
Connecting…
+
+
+
+ +
+
+ +
+
+

Authorization needed

+

This board couldn't authenticate. Check the URL token.

+
+ + +
+
+
+ + + diff --git a/battleflow_data/icons/blinded.svg b/battleflow_data/icons/blinded.svg new file mode 100644 index 0000000..5c50e05 --- /dev/null +++ b/battleflow_data/icons/blinded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/battleflow_data/icons/charmed.svg b/battleflow_data/icons/charmed.svg new file mode 100644 index 0000000..3990a0b --- /dev/null +++ b/battleflow_data/icons/charmed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/battleflow_data/icons/deafened.svg b/battleflow_data/icons/deafened.svg new file mode 100644 index 0000000..84b10bc --- /dev/null +++ b/battleflow_data/icons/deafened.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/battleflow_data/icons/frightened.svg b/battleflow_data/icons/frightened.svg new file mode 100644 index 0000000..f931513 --- /dev/null +++ b/battleflow_data/icons/frightened.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/battleflow_data/icons/grappled.svg b/battleflow_data/icons/grappled.svg new file mode 100644 index 0000000..2935fe8 --- /dev/null +++ b/battleflow_data/icons/grappled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/battleflow_data/icons/paralyzed.svg b/battleflow_data/icons/paralyzed.svg new file mode 100644 index 0000000..d2ac179 --- /dev/null +++ b/battleflow_data/icons/paralyzed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/battleflow_data/icons/petrified.svg b/battleflow_data/icons/petrified.svg new file mode 100644 index 0000000..abb7d50 --- /dev/null +++ b/battleflow_data/icons/petrified.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/battleflow_data/icons/poisoned.svg b/battleflow_data/icons/poisoned.svg new file mode 100644 index 0000000..b9e4fe9 --- /dev/null +++ b/battleflow_data/icons/poisoned.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/battleflow_data/icons/prone.svg b/battleflow_data/icons/prone.svg new file mode 100644 index 0000000..5cf7e88 --- /dev/null +++ b/battleflow_data/icons/prone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/battleflow_data/icons/restrained.svg b/battleflow_data/icons/restrained.svg new file mode 100644 index 0000000..925e7b9 --- /dev/null +++ b/battleflow_data/icons/restrained.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/battleflow_data/icons/stunned.svg b/battleflow_data/icons/stunned.svg new file mode 100644 index 0000000..aac5447 --- /dev/null +++ b/battleflow_data/icons/stunned.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/battleflow_data/icons/unconscious.svg b/battleflow_data/icons/unconscious.svg new file mode 100644 index 0000000..d2af3e7 --- /dev/null +++ b/battleflow_data/icons/unconscious.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/battleflow_data/preset_groups.json b/battleflow_data/preset_groups.json new file mode 100644 index 0000000..2050c03 --- /dev/null +++ b/battleflow_data/preset_groups.json @@ -0,0 +1,22 @@ +[ + { + "id": "d55af6d8-222b-40f6-b9c3-c6062807b2cb", + "name": "Full Party - Spelljammer", + "member_ids": [ + "f925ef5f-caad-4eb7-85fb-c5c430f8cb1a", + "b31a5165-c21f-47de-8465-e2260374240d", + "010f1066-3976-4f00-af3f-abe20b2a8817", + "6bf50981-1374-4741-8c9e-8601745db64f" + ] + }, + { + "id": "83d7a8a0-22e4-4482-b945-d89510b078ee", + "name": "Full Party - Erian", + "member_ids": [ + "ef1832d6-17e7-49e1-803b-21c1ad434d8d", + "844d5b93-aa6d-471e-8474-32219d08bd0d", + "9717f97d-4f4f-4021-8f42-965de9b70f0c", + "237b8b79-6c32-4f33-b2c0-39e001e8c4ac" + ] + } +] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e4f7762 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "battleflow" +version = "0.1.0" +description = "Battleflow — OBS-friendly initiative board for tabletop encounters" +authors = [{name="Aetryos Workshop"}] +requires-python = ">=3.9" +dependencies = [ + "flask>=3.0.0", + "werkzeug>=3.0.0", +] + +[project.scripts] +battleflow = "battleflow.cli:main" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b5a0613 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0.0 +invoke>=2.2.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6213353 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0.0 +werkzeug>=3.0.0 diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..a88f1cd --- /dev/null +++ b/tasks.py @@ -0,0 +1,29 @@ +from invoke import task +import webbrowser, os + +@task +def install(c): + c.run("python -m pip install -U pip") + c.run("python -m pip install -e . -r requirements-dev.txt") + +@task +def run(c, host="0.0.0.0", port=5050, token="changeme", data_dir=""): + extra = f"--data-dir {data_dir}" if data_dir else "" + c.run(f"python -m battleflow.cli --host {host} --port {port} --token {token} {extra}") + +@task +def seed_icons(c, host="127.0.0.1", port=5050, token="changeme"): + url = f"http://{host}:{port}/admin/seed_icons?token={token}" + c.run(f"python - <<'PY'\nimport urllib.request; urllib.request.urlopen('{url}', data=b'').read()\nprint('Seeded icons via', '{url}')\nPY") + +@task +def test(c): + c.run("pytest -q") + +@task +def open_admin(c, host="127.0.0.1", port=5050, token="changeme"): + webbrowser.open(f"http://{host}:{port}/admin?token={token}") + +@task +def open_board(c, host="127.0.0.1", port=5050, token="changeme"): + webbrowser.open(f"http://{host}:{port}/board?token={token}") diff --git a/tests/test_api_basic.py b/tests/test_api_basic.py new file mode 100644 index 0000000..326929a --- /dev/null +++ b/tests/test_api_basic.py @@ -0,0 +1,78 @@ +import os, json, tempfile, shutil +import pytest +from battleflow import create_app +from battleflow.state import STATE +from battleflow.storage import ensure_default_icons, load_state + +TOKEN = "testtoken" + +@pytest.fixture(autouse=True) +def isolated_data(tmp_path, monkeypatch): + # fresh state for each test + data_dir = tmp_path / "bfdata" + data_dir.mkdir() + monkeypatch.setenv("BATTLEFLOW_DATA_DIR", str(data_dir)) + monkeypatch.setenv("BATTLEFLOW_TOKEN", TOKEN) + # reset STATE + STATE.actors.clear(); STATE.turn_idx=0; STATE.round=1; STATE.visible=True; STATE.dead_mode='normal' + app = create_app() + with app.app_context(): + ensure_default_icons() + load_state(STATE) + yield app + +@pytest.fixture +def client(isolated_data): + isolated_data.testing = True + return isolated_data.test_client() + +def url(p): + sep = "&" if "?" in p else "?" + return f"{p}{sep}token={TOKEN}" + +def test_state_empty(client): + r = client.get(url("/api/state?since=0")) + assert r.status_code == 200 + data = r.get_json() + assert data["actors"] == [] + assert data["round"] == 1 + +def test_add_and_cycle(client): + # add one actor + r = client.post(url("/api/add"), json={"name":"A","init":15,"type":"pc","hp":10,"ac":14}) + assert r.status_code == 200 + # state should include it + r = client.get(url("/api/state?since=0")) + s = r.get_json() + assert len(s["actors"]) == 1 + assert s["actors"][0]["name"] == "A" + # next/prev/visible + assert client.post(url("/api/next")).status_code == 200 + assert client.post(url("/api/prev")).status_code == 200 + assert client.post(url("/api/toggle_visible")).status_code == 200 + +def test_effects_and_deadmode(client): + # add + r = client.post(url("/api/add"), json={"name":"B","init":12,"type":"npc"}) + aid = r.get_json()["id"] + # toggle effect + assert client.post(url("/api/toggle_effect"), json={"id":aid,"effect":"poisoned"}).status_code == 200 + # clear effects + assert client.post(url("/api/clear_effects"), json={"id":aid}).status_code == 200 + # dead mode + assert client.post(url("/api/dead_mode"), json={"mode":"shrink"}).status_code == 200 + assert client.post(url("/api/dead_mode"), json={"mode":"hide"}).status_code == 200 + +def test_presets_and_groups(client): + # add presets + p1 = client.post(url("/api/preset/add"), json={"name":"Fighter","type":"pc","hp":30,"ac":18}).get_json()["id"] + p2 = client.post(url("/api/preset/add"), json={"name":"Goblin","type":"monster","hp":7,"ac":15}).get_json()["id"] + # apply preset (asks for init) + client.post(url("/api/preset/apply"), json={"id":p1,"init":14}) + # create group + g = client.post(url("/api/preset/group/add"), json={"name":"Party","member_ids":[p1,p2]}).get_json()["id"] + # apply group with single init + client.post(url("/api/preset/group/apply"), json={"id":g,"init":10}) + # state should have at least 3 actors now + s = client.get(url("/api/state?since=0")).get_json() + assert len(s["actors"]) >= 3