Files
encounterflow/battleflow
2025-09-17 21:47:28 +02:00

1253 lines
49 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"""<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>
<rect rx='6' width='32' height='32' fill='{PLACEHOLDER_BG}' />
<text x='16' y='21' font-family='sans-serif' font-size='16' text-anchor='middle' fill='{PLACEHOLDER_FG}'>{letter}</text>
</svg>"""
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"""
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>✦ {{ name }}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<style>{{ css }}</style>
</head>
<body>
<div id="root" class="wrapper">
<div id="panel" class="panel">
<div class="header">
<div>
<div class="h-title">{{ name }}</div>
<div class="h-sub">{{ subtitle }}</div>
</div>
<div class="h-sub">Round <span id="round">1</span> · Updated <span id="updated"></span></div>
</div>
<div id="list" class="list"></div>
<div class="footer"><span id="count"></span></div>
</div>
</div>
<script>
(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||[];
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;
const card = document.createElement('div');
card.className = 'row type-'+(a.type||'pc') + (idx===state.turn_idx?' active':'') + (a.dead?' dead':'');
// 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=(EMOJI[e]||''); } 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();
})();
</script>
</body>
</html>
"""
ADMIN_HTML = r"""
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>✦ {{ name }} Admin</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<style>
body{ font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#0b0d12; color:#e9eef4; margin:0; }
.topbar{ display:flex; justify-content:space-between; align-items:flex-start; gap:12px; padding:10px 16px 0; }
.left{ display:flex; flex-direction:column; gap:6px; }
.title{ opacity:.65;font-size:12px }
.subtitle{ opacity:.55; font-size:11px; margin-top:-2px; }
.globalbar{ display:flex; gap:8px; flex-wrap:wrap; }
.wrap{ display:grid; grid-template-columns: 520px 1fr; gap:16px; padding:16px; }
.card{ background:#121621; border:1px solid #1f2535; border-radius:12px; padding:14px; }
h2{ margin:0 0 10px; font-size:16px; letter-spacing:0.4px; }
label{ font-size:12px; color:#9aa0aa; }
input, select{ width:100%; padding:8px; border-radius:8px; border:1px solid #293145; background:#0d111a; color:#e9eef4; }
.row{ display:grid; grid-template-columns:1fr 1fr; gap:8px; }
table{ width:100%; border-collapse:collapse; }
th,td{ border-bottom:1px solid #1f2535; padding:8px; font-size:14px; vertical-align:top; }
tr.active{ outline:2px solid #fde047; }
button{ padding:6px 8px; border-radius:10px; border:1px solid #293145; background:#0d111a; color:#e9eef4; cursor:pointer; }
.btn{ padding:6px 10px; border-radius:10px; border:1px solid #293145; background:#0d111a; color:#e9eef4; cursor:pointer; }
.btn-danger{ border-color:#7f1d1d; background:#1a0b0b; color:#fecaca; }
.btn-outline{ background:transparent; }
.pill{ display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid #293145; }
.tagType{ display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid transparent; font-weight:600; }
.tagType.pc{ background:#082f35; color:#7dd3fc; border-color:#0c4a59; }
.tagType.npc{ background:#1d1533; color:#c4b5fd; border-color:#352a5f; }
.tagType.monster{ background:#3b1111; color:#fca5a5; border-color:#5b1a1a; }
.bar{ display:flex; gap:8px; margin-top:8px; flex-wrap: wrap; }
.condbar button{ margin:2px; font-size:12px; display:inline-flex; align-items:center; gap:6px; }
.condbar button.on{ background:#1e293b; border-color:#334155; }
.presetlist{ max-height: 260px; overflow:auto; border:1px solid #1f2535; border-radius:10px; padding:6px; }
.preset{ display:flex; align-items:center; gap:8px; padding:6px; border-bottom:1px dashed #1f2535; }
.preset:last-child{ border-bottom:0; }
.pav{ width:22px; height:22px; border-radius:6px; object-fit:cover; background:#0d111a; }
.uploadrow{ display:grid; grid-template-columns: 1fr auto auto 68px; gap:8px; align-items:center; }
.preview{ width:64px; height:64px; border-radius:10px; background:#0d111a; border:1px solid #293145; object-fit:cover; }
.dropzone{ border:2px dashed #293145; border-radius:10px; padding:12px; text-align:center; color:#9aa0aa; }
.dropzone.drag{ background:#0f172a; border-color:#334155; color:#cbd5e1; }
.icoimg{ width:14px; height:14px; display:inline-block; vertical-align:-2px; }
.credit{ font-size:11px; color:#94a3b8; opacity:0.8; margin: 16px; text-align:center; }
.twocol{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.scroll{ max-height:180px; overflow:auto; border:1px solid #1f2535; border-radius:10px; padding:8px; }
.muted{ color:#9aa0aa; font-size:12px; }
</style>
</head>
<body>
<div class="topbar">
<div class="left">
<div class="title">{{ name|upper }} ADMIN</div>
<div class="subtitle">{{ subtitle }}</div>
<div class="globalbar">
<button class="btn" onclick="clearAll()">Clear</button>
<button class="btn" onclick="toggleVisible()">Toggle Board</button>
<button class="btn" onclick="prev()">Prev</button>
<button class="btn" onclick="next()">Next</button>
</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<form action="/admin/seed_icons?token={{ token }}" method="post" onsubmit="return confirm('Download curated icons from Game-Icons.net now?');" style="margin:0">
<button class="btn">Seed curated icon set</button>
</form>
<button class="btn" onclick="openBoard()">Open Player Board ↗</button>
</div>
</div>
<div class="wrap">
<div class="card">
<h2>Add Actor</h2>
<div class="row">
<div>
<label>Name</label>
<input id="name"/>
</div>
<div>
<label>Init</label>
<input id="init" type="number" step="0.1" />
</div>
</div>
<div class="row" style="margin-top:6px;">
<div>
<label>HP</label>
<input id="hp" type="number"/>
</div>
<div>
<label>AC</label>
<input id="ac" type="number"/>
</div>
</div>
<div class="row" style="margin-top:6px;">
<div>
<label>Type</label>
<select id="type">
<option value="pc">PC</option>
<option value="npc">NPC</option>
<option value="monster">Monster</option>
</select>
</div>
<div>
<label>Note</label>
<input id="note"/>
</div>
</div>
<div class="row" style="margin-top:6px;align-items:center;grid-template-columns:1fr auto auto 68px;">
<div>
<label>Avatar (filename in /avatars or full URL)</label>
<input id="avatar" placeholder="goblin.png or https://..."/>
</div>
<div class="dropzone" id="avatar_drop">Drop image here</div>
<button class="btn" onclick="uploadAvatar('avatar_file','avatar','avatar_preview')">Upload</button>
<img id="avatar_preview" class="preview" alt="preview"/>
</div>
<div class="uploadrow" style="margin-top:6px;">
<input type="file" id="avatar_file" accept="image/*" />
<div class="muted">or drag & drop ↑</div>
<div></div>
<div></div>
</div>
<div class="bar">
<button class="btn" onclick="add()">Add</button>
</div>
</div>
<div class="card">
<h2>Order (desc by init) — Round <span id="round">1</span></h2>
<table>
<thead>
<tr><th>#</th><th>Name</th><th>Init</th><th>HP</th><th>AC</th><th>Type</th><th>Flags</th><th></th></tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
</div>
<div class="wrap">
<div class="card">
<h2>Add Preset</h2>
<div class="row">
<div>
<label>Name</label>
<input id="p_name"/>
</div>
<div>
<label>Type</label>
<select id="p_type">
<option value="pc">PC</option>
<option value="npc">NPC</option>
<option value="monster">Monster</option>
</select>
</div>
</div>
<div class="row" style="margin-top:6px;">
<div>
<label>HP</label>
<input id="p_hp" type="number"/>
</div>
<div>
<label>AC</label>
<input id="p_ac" type="number"/>
</div>
</div>
<div class="row" style="margin-top:6px;">
<div>
<label>Note</label>
<input id="p_note"/>
</div>
<div>
<label>Avatar</label>
<input id="p_avatar" placeholder="filename in /avatars or URL"/>
</div>
</div>
<div class="row" style="margin-top:6px;align-items:center;grid-template-columns:1fr auto auto 68px;">
<div class="dropzone" id="p_avatar_drop">Drop image here</div>
<button class="btn" onclick="uploadAvatar('p_avatar_file','p_avatar','p_avatar_preview')">Upload</button>
<img id="p_avatar_preview" class="preview" alt="preview"/>
<div></div>
</div>
<div class="uploadrow" style="margin-top:6px;">
<input type="file" id="p_avatar_file" accept="image/*" />
<div class="muted">or drag & drop ↑</div>
<div></div>
<div></div>
</div>
<div class="bar">
<button class="btn" onclick="presetAdd()">Save Preset</button>
</div>
</div>
<div class="card">
<h2>Presets</h2>
<div class="presetlist" id="presetlist"></div>
</div>
</div>
<div class="wrap">
<div class="card">
<h2>Create Preset Group (party)</h2>
<div class="twocol">
<div>
<label>Group name</label>
<input id="g_name" placeholder="Party Alpha" />
<div class="bar"><button class="btn" onclick="groupAdd()">Save Group</button></div>
</div>
<div>
<label>Select presets</label>
<div id="g_checks" class="scroll"></div>
</div>
</div>
</div>
<div class="card">
<h2>Preset Groups</h2>
<div class="presetlist" id="pg_list"></div>
</div>
</div>
<p class="credit">Icons live in <code>initrack_data/icons</code>. Curated set from <strong>Game-Icons.net</strong> (CCBY 3.0). Authors: Lorc, Delapouite, Skoll, sbed. Replace any icon by dropping an SVG with the same name (e.g., <code>poisoned.svg</code>).</p>
<script>
const qs = new URLSearchParams(window.location.search);
const token = qs.get('token');
let state = null; let since = 0; let presets = []; let 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'); }
function iconImg(name){ return `<span class=\"ico_wrap\"><img class=\"icoimg\" src=\"/icons/${encodeURIComponent(name)}.svg\" alt=\"${name}\"></span>`; }
function typeChip(t){ return `<span class=\"tagType ${t}\">${t}</span>`; }
function row(a, idx){
const main = `<tr class="${idx===state.turn_idx? 'active':''}">\n<td>${idx+1}</td>\n<td>${a.name}</td>\n<td>${a.init}</td>\n<td>${a.hp}</td>\n<td>${a.ac}${a.reveal_ac?'':' (hidden)'} </td>\n<td>${typeChip(a.type)}</td>\n<td>\n <button class="btn" title="Concentration" onclick=toggle('${a.id}','conc')>✦</button>\n <button class="btn" title="Dead" onclick=toggle('${a.id}','dead')>☠</button>\n <button class="btn" title="Visible to players" onclick=toggle('${a.id}','visible')>👁</button>\n <button class="btn" title="Reveal AC" onclick=toggle('${a.id}','reveal_ac')>🛡</button>\n</td>\n<td>\n <button class="btn btn-danger" onclick=removeA('${a.id}')>🗑 Remove</button>\n</td>\n</tr>`;
const active = (a.effects||[]);
const cond = `<tr class="condrow"><td colspan="8"><div class="condbar">${
KNOWN_CONDITIONS.map(c=>`<button class="${active.includes(c)?'on':''}" onclick=toggleEffect('${a.id}','${c}')>${iconImg(c)} ${c}</button>`).join(' ')
} <button class="btn" style="margin-left:8px" onclick=clearEffects('${a.id}')>Clear conditions</button></div></td></tr>`;
return main + cond;
}
const KNOWN_CONDITIONS = [
'poisoned','prone','grappled','restrained','stunned','blinded','deafened','charmed','frightened','paralyzed','petrified','unconscious'
];
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);
});
// Refresh group checkbox list
renderGroupChecks();
}
function renderGroupChecks(){
g_checks.innerHTML = '';
if(!presets.length){ g_checks.innerHTML = '<div class="muted">No presets yet.</div>'; return; }
presets.forEach(p=>{
const id = 'chk_'+p.id; const w=document.createElement('div');
w.innerHTML = `<label><input type="checkbox" id="${id}" data-id="${p.id}"/> ${p.name} <span class="muted">(${p.type}${p.ac? ' · AC '+p.ac:''}${p.hp? ' · HP '+p.hp:''})</span></label>`;
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 = `<strong>${g.name}</strong> <span class="muted">(${g.member_ids.length} members)</span> — ${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.ok){ const t = await r.text(); alert('Error '+r.status+' '+t); }
}
async function get(p){
const r = await fetch(p+`?token=${encodeURIComponent(token)}`);
if(!r.ok){ const t = await r.text(); alert('Error '+r.status+' '+t); 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.ok){ const t = await r.text(); alert('Upload error '+r.status+' '+t); 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 }); }
// 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 g = groups.find(x=>x.id===id); if(!g){ alert('Group not found'); return; }
const hint = 'Enter initiatives for '+g.member_ids.length+' members (comma-separated) or a single value for all';
const raw = prompt(hint, '12,11,10');
let payload = { id };
if(raw && raw.trim().length){
const parts = raw.split(',').map(s=>s.trim()).filter(Boolean);
if(parts.length===1){ payload.init = parseFloat(parts[0]||'10'); }
else { payload.inits = parts.map(x=>parseFloat(x||'10')); }
}
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.ok){ state = await r.json(); since = state.version; render(); } }
catch(e){}
setTimeout(poll, 300);
}
// init bindings
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();
poll();
</script>
</body>
</html>
"""
# ------------------------------
# Favicon (inline SVG)
# ------------------------------
FAVICON_SVG = """<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
<rect x='4' y='4' width='56' height='56' rx='12' fill='#0b1220'/>
<polygon points='32,9 38.5,23 54,23 41,32.5 46,50 32,40.5 18,50 23,32.5 10,23 25.5,23' fill='#fde047'/>
</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/<path:fn>')
def avatars(fn: str):
return send_from_directory(AVATAR_DIR, fn)
@app.route('/icons/<path:fn>')
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 (CCBY 3.0).
Allowed avatar types: {', '.join(sorted(ALLOWED_AVATAR_EXTS))}
""")
app.run(host=args.host, port=args.port, debug=False)