diff --git a/.gitignore b/.gitignore deleted file mode 100644 index bda5e52..0000000 --- a/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -initrack_data/presets.json -initrack_data/state.json -initrack_data/avatars/*.png \ No newline at end of file diff --git a/battleflow b/battleflow deleted file mode 100755 index 968f42f..0000000 --- a/battleflow +++ /dev/null @@ -1,1252 +0,0 @@ -#!/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 deleted file mode 100644 index 5c50e05..0000000 --- a/initrack_data/icons/blinded.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/initrack_data/icons/charmed.svg b/initrack_data/icons/charmed.svg deleted file mode 100644 index 3990a0b..0000000 --- a/initrack_data/icons/charmed.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/initrack_data/icons/deafened.svg b/initrack_data/icons/deafened.svg deleted file mode 100644 index 84b10bc..0000000 --- a/initrack_data/icons/deafened.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/initrack_data/icons/frightened.svg b/initrack_data/icons/frightened.svg deleted file mode 100644 index f931513..0000000 --- a/initrack_data/icons/frightened.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/initrack_data/icons/grappled.svg b/initrack_data/icons/grappled.svg deleted file mode 100644 index 2935fe8..0000000 --- a/initrack_data/icons/grappled.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/initrack_data/icons/paralyzed.svg b/initrack_data/icons/paralyzed.svg deleted file mode 100644 index d2ac179..0000000 --- a/initrack_data/icons/paralyzed.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/initrack_data/icons/petrified.svg b/initrack_data/icons/petrified.svg deleted file mode 100644 index abb7d50..0000000 --- a/initrack_data/icons/petrified.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/initrack_data/icons/poisoned.svg b/initrack_data/icons/poisoned.svg deleted file mode 100644 index b9e4fe9..0000000 --- a/initrack_data/icons/poisoned.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/initrack_data/icons/prone.svg b/initrack_data/icons/prone.svg deleted file mode 100644 index 5cf7e88..0000000 --- a/initrack_data/icons/prone.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/initrack_data/icons/restrained.svg b/initrack_data/icons/restrained.svg deleted file mode 100644 index 925e7b9..0000000 --- a/initrack_data/icons/restrained.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/initrack_data/icons/stunned.svg b/initrack_data/icons/stunned.svg deleted file mode 100644 index aac5447..0000000 --- a/initrack_data/icons/stunned.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/initrack_data/icons/unconscious.svg b/initrack_data/icons/unconscious.svg deleted file mode 100644 index d2af3e7..0000000 --- a/initrack_data/icons/unconscious.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file