#!/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"""""" 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 | Init | HP | AC | Type | Flags |
|---|
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).