1253 lines
49 KiB
Plaintext
1253 lines
49 KiB
Plaintext
|
|
#!/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)
|