commit 45a4211502fd1a4189a7b3168c88df7282ae1cb7 Author: Peter van Arkel Date: Wed Sep 17 21:47:28 2025 +0200 cosmetic commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bda5e52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +initrack_data/presets.json +initrack_data/state.json +initrack_data/avatars/*.png \ No newline at end of file diff --git a/battleflow b/battleflow new file mode 100755 index 0000000..968f42f --- /dev/null +++ b/battleflow @@ -0,0 +1,1252 @@ +#!/usr/bin/env python3 +""" +Battleflow — OBS-friendly initiative board (single-file, offline) +by Aetryos Workshop + +What you get +------------ +• One-file Flask app, no external CDNs (offline after optional icon seeding) +• Persistent state + presets (JSON on disk) +• Avatar uploads (drag & drop), previews in Admin +• Conditions row with local SVG icons (curated Game-Icons set seeder) +• PCs reveal AC by default; NPC/monsters hidden until toggled (🛡) +• Landscape-first board layout with vertical cards (BG3-ish) +• Clearer "dead" visualization on Board (skull badge + grayscale + red accent) +• Preset **Groups** (create a party, add all at once or individually) +• Admin improvements: global controls top-left, clearer Remove button, clearer type chips in Order & Presets +• Favicon ✦ and branded titles (Battleflow) + +Run +--- + pip install flask + python initrack.py --host 0.0.0.0 --port 5050 --token YOURSECRET + +Endpoints +--------- +/admin?token=... — Admin panel +/board?token=... — Player board (use in OBS Browser Source) +/avatars/* — Uploaded avatars +/icons/* — Condition icons (SVG) + +API (good for Stream Deck HTTP) +------------------------------- +GET /api/state?since=N +POST /api/add { name, init, hp?, ac?, type?, note?, avatar? } +POST /api/update { id, field: value|'toggle' } +POST /api/remove { id } +POST /api/next +POST /api/prev +POST /api/clear +POST /api/toggle_visible +POST /api/toggle_effect { id, effect } +POST /api/clear_effects { id } +GET /api/presets +POST /api/preset/add { name, hp?, ac?, type?, note?, avatar? } +POST /api/preset/remove { id } +POST /api/preset/apply { id, init } +GET /api/preset_groups +POST /api/preset/group/add { name, member_ids: [preset_id, ...] } +POST /api/preset/group/remove { id } +POST /api/preset/group/apply { id, inits?: [n,...], init?: n } + +""" +from __future__ import annotations +import argparse +import json +import os +import threading +import time +import uuid +from dataclasses import dataclass, field, asdict +from typing import Any, Dict, List +from urllib.request import urlopen +from urllib.error import URLError, HTTPError + +from flask import Flask, abort, jsonify, redirect, render_template_string, request, send_from_directory, url_for +from werkzeug.utils import secure_filename + +# ------------------------------ +# App & data paths +# ------------------------------ +app = Flask(__name__) +app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret') +app.config['MAX_CONTENT_LENGTH'] = 8 * 1024 * 1024 # 8 MB upload cap + +PRODUCT_NAME = 'Battleflow' +PRODUCT_SUBTITLE = 'by Aetryos Workshop' + +DATA_DIR = os.path.join(os.getcwd(), 'initrack_data') +STATE_PATH = os.path.join(DATA_DIR, 'state.json') +PRESETS_PATH = os.path.join(DATA_DIR, 'presets.json') +PRESET_GROUPS_PATH = os.path.join(DATA_DIR, 'preset_groups.json') +AVATAR_DIR = os.path.join(DATA_DIR, 'avatars') +ICON_DIR = os.path.join(DATA_DIR, 'icons') +ALLOWED_AVATAR_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'} + +os.makedirs(DATA_DIR, exist_ok=True) +os.makedirs(AVATAR_DIR, exist_ok=True) +os.makedirs(ICON_DIR, exist_ok=True) + +# ------------------------------ +# Data model +# ------------------------------ + +def now_ms() -> int: + return int(time.time() * 1000) + +@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 + +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':'💤' +} + +PLACEHOLDER_BG = '#334155' +PLACEHOLDER_FG = '#e2e8f0' + +def ensure_default_icons() -> None: + for c, fn in CONDITION_ICON_FILES.items(): + path = os.path.join(ICON_DIR, fn) + if os.path.exists(path): + continue + letter = (c[:1] or '?').upper() + svg = f""" + +{letter} +""" + try: + with open(path, 'w', encoding='utf-8') as f: + f.write(svg) + except Exception: + pass + +ensure_default_icons() + +GAME_ICONS_BASE = 'https://raw.githubusercontent.com/game-icons/icons/master' + +def seed_curated_icons(overwrite: bool = True) -> List[Dict[str, str]]: + results: List[Dict[str, str]] = [] + for cond, (author, slug) in CURATED_ICON_SLUGS.items(): + dest = os.path.join(ICON_DIR, f'{cond}.svg') + url = f"{GAME_ICONS_BASE}/{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) + results.append({'condition': cond, 'status': 'downloaded'}) + else: + results.append({'condition': cond, 'status': 'exists'}) + except (URLError, HTTPError) as e: + ensure_default_icons() + results.append({'condition': cond, 'status': f'fallback ({e.__class__.__name__})'}) + return results + +class CombatState: + def __init__(self) -> None: + self.actors: List[Actor] = [] + self.turn_idx: int = 0 + self.round: int = 1 + self.visible: bool = True + 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, + '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() + +STATE = CombatState() +PRESETS: List[Preset] = [] +PRESET_GROUPS: List[PresetGroup] = [] + +# ------------------------------ +# Persistence +# ------------------------------ + +def save_state() -> None: + tmp = { + 'actors': [asdict(a) for a in STATE.actors], + 'turn_idx': STATE.turn_idx, + 'round': STATE.round, + 'visible': STATE.visible, + } + with open(STATE_PATH, 'w', encoding='utf-8') as f: + json.dump(tmp, f, ensure_ascii=False, indent=2) + + +def load_state() -> None: + if not os.path.exists(STATE_PATH): + return + try: + with open(STATE_PATH, '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.normalize() + STATE.touch() + except Exception: + pass + + +def save_presets() -> None: + with open(PRESETS_PATH, 'w', encoding='utf-8') as f: + json.dump([asdict(p) for p in PRESETS], f, ensure_ascii=False, indent=2) + +def load_presets() -> None: + global PRESETS + if not os.path.exists(PRESETS_PATH): + PRESETS = [] + return + try: + with open(PRESETS_PATH, 'r', encoding='utf-8') as f: + items = json.load(f) + PRESETS = [Preset(**p) for p in items] + except Exception: + PRESETS = [] + + +def save_preset_groups() -> None: + with open(PRESET_GROUPS_PATH, 'w', encoding='utf-8') as f: + json.dump([asdict(g) for g in PRESET_GROUPS], f, ensure_ascii=False, indent=2) + +def load_preset_groups() -> None: + global PRESET_GROUPS + if not os.path.exists(PRESET_GROUPS_PATH): + PRESET_GROUPS = [] + return + try: + with open(PRESET_GROUPS_PATH, 'r', encoding='utf-8') as f: + items = json.load(f) + PRESET_GROUPS = [PresetGroup(**g) for g in items] + except Exception: + PRESET_GROUPS = [] + +load_state() +load_presets() +load_preset_groups() + +# ------------------------------ +# Security +# ------------------------------ + +def require_token(func): + def wrapper(*args, **kwargs): + expected = 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 func(*args, **kwargs) + wrapper.__name__ = func.__name__ + return wrapper + +# ------------------------------ +# Templates +# ------------------------------ +BOARD_CSS = r""" +: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; } + +/* 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); } +.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: red accent, skull badge, grayscale portrait */ +.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 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; } + +.footer{ margin-top:6px; font-size:12px; color:var(--muted); display:flex; justify-content:space-between; } +.hideall{ display:none; } + +@media (max-width: 740px){ + .list{ flex-direction:column; } + .row{ width:auto; align-items:flex-start; } + .name{ align-items:flex-start; text-align:left; } +} +""" + +BOARD_HTML = r""" + + + + + + ✦ {{ name }} + + + + +
+
+
+
+
{{ name }}
+
{{ subtitle }}
+
+
Round 1 · Updated
+
+
+ +
+
+ + + +""" + +ADMIN_HTML = r""" + + + + + + ✦ {{ name }} – Admin + + + + +
+
+
{{ name|upper }} ADMIN
+
{{ subtitle }}
+
+ + + + +
+
+
+
+ +
+ +
+
+ +
+
+

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 initrack_data/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).

+ + + + +""" + +# ------------------------------ +# Favicon (inline SVG) +# ------------------------------ +FAVICON_SVG = """ + + +""" + +@app.route('/favicon.svg') +def favicon_svg(): + return FAVICON_SVG, 200, {'Content-Type': 'image/svg+xml'} + +# ------------------------------ +# Routes +# ------------------------------ +@app.route('/') +def root(): + t = app.config.get('COMBAT_TOKEN', 'changeme') + return redirect(url_for('admin') + f'?token={t}') + +@app.route('/avatars/') +def avatars(fn: str): + return send_from_directory(AVATAR_DIR, fn) + +@app.route('/icons/') +def icons(fn: str): + return send_from_directory(ICON_DIR, fn) + +@app.route('/board') +def board(): + return render_template_string(BOARD_HTML, css=BOARD_CSS, name=PRODUCT_NAME, subtitle=PRODUCT_SUBTITLE) + +@app.route('/admin') +@require_token +def admin(): + tok = request.args.get('token', '') + return render_template_string(ADMIN_HTML, token=tok, name=PRODUCT_NAME, subtitle=PRODUCT_SUBTITLE) + +@app.post('/admin/seed_icons') +@require_token +def admin_seed_icons(): + seed_curated_icons(overwrite=True) + tok = request.args.get('token', '') + return redirect(url_for('admin', token=tok)) + +# --- Long-poll state --- +@app.get('/api/state') +@require_token +def api_state(): + try: + since = int(request.args.get('since', '0')) + except ValueError: + since = 0 + data = STATE.wait_for(since, timeout=25.0) + return jsonify(data) + +# --- Uploads --- +@app.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(AVATAR_DIR, newname) + f.save(dest) + return jsonify({'ok': True, 'filename': newname, 'url': f'/avatars/{newname}'}) + +# ------------------------------ +# Mutations +# ------------------------------ +@app.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.broadcast() + return jsonify({'ok': True, 'id': a.id}) + +@app.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.broadcast() + return jsonify({'ok': True}) + +@app.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.broadcast() + return jsonify({'ok': True, 'removed': len(STATE.actors) != before}) + +@app.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.broadcast() + return jsonify({'ok': True, 'round': STATE.round, 'turn_idx': STATE.turn_idx}) + +@app.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.broadcast() + return jsonify({'ok': True, 'round': STATE.round, 'turn_idx': STATE.turn_idx}) + +@app.post('/api/clear') +@require_token +def api_clear(): + STATE.actors.clear() + STATE.turn_idx = 0 + STATE.round = 1 + save_state() + STATE.broadcast() + return jsonify({'ok': True}) + +@app.post('/api/toggle_visible') +@require_token +def api_toggle_visible(): + STATE.visible = not STATE.visible + save_state() + STATE.broadcast() + return jsonify({'ok': True, 'visible': STATE.visible}) + +@app.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.broadcast() + return jsonify({'ok': True, 'effects': target.effects}) + +@app.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.broadcast() + return jsonify({'ok': True}) + +# --- Presets --- +@app.get('/api/presets') +@require_token +def api_presets(): + return jsonify({ 'items': [asdict(p) for p in PRESETS] }) + +@app.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], + ) + PRESETS.append(p) + save_presets() + return jsonify({'ok': True, 'id': p.id}) + +@app.post('/api/preset/remove') +@require_token +def api_preset_remove(): + data = request.get_json(force=True, silent=True) or {} + pid = str(data.get('id','')) + before = len(PRESETS) + PRESETS[:] = [x for x in PRESETS if x.id != pid] + changed = len(PRESETS) != before + if changed: + save_presets() + return jsonify({'ok': True, 'removed': changed}) + +@app.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)) + src = next((x for x in PRESETS 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.broadcast() + return jsonify({'ok': True, 'id': a.id}) + +# --- Preset Groups --- +@app.get('/api/preset_groups') +@require_token +def api_preset_groups(): + return jsonify({ 'items': [asdict(g) for g in PRESET_GROUPS] }) + +@app.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) + PRESET_GROUPS.append(g) + save_preset_groups() + return jsonify({'ok': True, 'id': g.id}) + +@app.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','')) + before = len(PRESET_GROUPS) + PRESET_GROUPS[:] = [x for x in PRESET_GROUPS if x.id != gid] + changed = len(PRESET_GROUPS) != before + if changed: + save_preset_groups() + return jsonify({'ok': True, 'removed': changed}) + +@app.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) + g = next((x for x in PRESET_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 + # initiative logic + 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.broadcast() + return jsonify({'ok': True, 'added': added, 'count': len(added)}) + +# ------------------------------ +# Main +# ------------------------------ +if __name__ == '__main__': + parser = argparse.ArgumentParser() + 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('COMBAT_TOKEN','changeme')) + args = parser.parse_args() + app.config['COMBAT_TOKEN'] = args.token + print(f""" +Name: Battleflow — by Aetryos Workshop +Admin: http://{args.host}:{args.port}/admin?token={args.token} +Board: http://{args.host}:{args.port}/board?token={args.token} +Data dir: {DATA_DIR} +Avatars: put files in {AVATAR_DIR} (or upload via Admin) and refer by filename (e.g. goblin.png) +Icons: place SVGs in {ICON_DIR} named after conditions (e.g. poisoned.svg). Default placeholders are auto-created; use the Admin button to seed curated Game-Icons (CC‑BY 3.0). +Allowed avatar types: {', '.join(sorted(ALLOWED_AVATAR_EXTS))} +""") + app.run(host=args.host, port=args.port, debug=False) diff --git a/initrack_data/icons/blinded.svg b/initrack_data/icons/blinded.svg new file mode 100644 index 0000000..5c50e05 --- /dev/null +++ b/initrack_data/icons/blinded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/initrack_data/icons/charmed.svg b/initrack_data/icons/charmed.svg new file mode 100644 index 0000000..3990a0b --- /dev/null +++ b/initrack_data/icons/charmed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/initrack_data/icons/deafened.svg b/initrack_data/icons/deafened.svg new file mode 100644 index 0000000..84b10bc --- /dev/null +++ b/initrack_data/icons/deafened.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/initrack_data/icons/frightened.svg b/initrack_data/icons/frightened.svg new file mode 100644 index 0000000..f931513 --- /dev/null +++ b/initrack_data/icons/frightened.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/initrack_data/icons/grappled.svg b/initrack_data/icons/grappled.svg new file mode 100644 index 0000000..2935fe8 --- /dev/null +++ b/initrack_data/icons/grappled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/initrack_data/icons/paralyzed.svg b/initrack_data/icons/paralyzed.svg new file mode 100644 index 0000000..d2ac179 --- /dev/null +++ b/initrack_data/icons/paralyzed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/initrack_data/icons/petrified.svg b/initrack_data/icons/petrified.svg new file mode 100644 index 0000000..abb7d50 --- /dev/null +++ b/initrack_data/icons/petrified.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/initrack_data/icons/poisoned.svg b/initrack_data/icons/poisoned.svg new file mode 100644 index 0000000..b9e4fe9 --- /dev/null +++ b/initrack_data/icons/poisoned.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/initrack_data/icons/prone.svg b/initrack_data/icons/prone.svg new file mode 100644 index 0000000..5cf7e88 --- /dev/null +++ b/initrack_data/icons/prone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/initrack_data/icons/restrained.svg b/initrack_data/icons/restrained.svg new file mode 100644 index 0000000..925e7b9 --- /dev/null +++ b/initrack_data/icons/restrained.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/initrack_data/icons/stunned.svg b/initrack_data/icons/stunned.svg new file mode 100644 index 0000000..aac5447 --- /dev/null +++ b/initrack_data/icons/stunned.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/initrack_data/icons/unconscious.svg b/initrack_data/icons/unconscious.svg new file mode 100644 index 0000000..d2af3e7 --- /dev/null +++ b/initrack_data/icons/unconscious.svg @@ -0,0 +1 @@ + \ No newline at end of file