#!/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)