1253 lines
49 KiB
Python
Executable File
1253 lines
49 KiB
Python
Executable File
#!/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> (CC‑BY 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 (CC‑BY 3.0).
|
||
Allowed avatar types: {', '.join(sorted(ALLOWED_AVATAR_EXTS))}
|
||
""")
|
||
app.run(host=args.host, port=args.port, debug=False)
|