pre-push commit
27
Makefile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Simple helpers
|
||||||
|
PY ?= python
|
||||||
|
HOST ?= 0.0.0.0
|
||||||
|
PORT ?= 5050
|
||||||
|
TOKEN ?= changeme
|
||||||
|
DATA ?=
|
||||||
|
|
||||||
|
.PHONY: install dev run test seed clean
|
||||||
|
|
||||||
|
install:
|
||||||
|
$(PY) -m pip install -U pip
|
||||||
|
$(PY) -m pip install -e . -r requirements-dev.txt
|
||||||
|
|
||||||
|
dev:
|
||||||
|
$(PY) -m pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
run:
|
||||||
|
$(PY) -m battleflow.cli --host $(HOST) --port $(PORT) --token $(TOKEN) $(if $(DATA),--data-dir $(DATA),)
|
||||||
|
|
||||||
|
test:
|
||||||
|
pytest -q
|
||||||
|
|
||||||
|
seed:
|
||||||
|
curl -s -X POST "http://$(HOST):$(PORT)/admin/seed_icons?token=$(TOKEN)" >/dev/null || true
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf .pytest_cache .coverage build dist *.egg-info
|
||||||
28
README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Battleflow
|
||||||
|
|
||||||
|
Refactored project layout (same features, cleaner code).
|
||||||
|
|
||||||
|
## Install & Run
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
battleflow --host 0.0.0.0 --port 5050 --token YOURSECRET
|
||||||
|
```
|
||||||
|
Open:
|
||||||
|
- Admin: `http://HOST:PORT/admin?token=YOURSECRET`
|
||||||
|
- Board: `http://HOST:PORT/board?token=YOURSECRET`
|
||||||
|
|
||||||
|
## Data directories
|
||||||
|
Default data folder is `battleflow_data/`. If an older `initrack_data/` exists and `battleflow_data/` isn't present, it will be reused automatically (migration-by-reuse).
|
||||||
|
```
|
||||||
|
battleflow_data/
|
||||||
|
avatars/
|
||||||
|
icons/
|
||||||
|
state.json
|
||||||
|
presets.json
|
||||||
|
preset_groups.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Admin requires `?token=...`. Board is public (use OBS Browser Source).
|
||||||
|
- Use the Admin button to seed curated condition icons (Game-Icons.net).
|
||||||
|
- No breaking functional changes vs. single-file.
|
||||||
8
battleflow.egg-info/PKG-INFO
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: battleflow
|
||||||
|
Version: 0.1.0
|
||||||
|
Summary: Battleflow — OBS-friendly initiative board for tabletop encounters
|
||||||
|
Author: Aetryos Workshop
|
||||||
|
Requires-Python: >=3.9
|
||||||
|
Requires-Dist: flask>=3.0.0
|
||||||
|
Requires-Dist: werkzeug>=3.0.0
|
||||||
20
battleflow.egg-info/SOURCES.txt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
README.md
|
||||||
|
pyproject.toml
|
||||||
|
battleflow/__init__.py
|
||||||
|
battleflow/cli.py
|
||||||
|
battleflow/config.py
|
||||||
|
battleflow/models.py
|
||||||
|
battleflow/security.py
|
||||||
|
battleflow/state.py
|
||||||
|
battleflow/storage.py
|
||||||
|
battleflow.egg-info/PKG-INFO
|
||||||
|
battleflow.egg-info/SOURCES.txt
|
||||||
|
battleflow.egg-info/dependency_links.txt
|
||||||
|
battleflow.egg-info/entry_points.txt
|
||||||
|
battleflow.egg-info/requires.txt
|
||||||
|
battleflow.egg-info/top_level.txt
|
||||||
|
battleflow/routes/__init__.py
|
||||||
|
battleflow/routes/admin.py
|
||||||
|
battleflow/routes/api.py
|
||||||
|
battleflow/routes/board.py
|
||||||
|
tests/test_api_basic.py
|
||||||
1
battleflow.egg-info/dependency_links.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
2
battleflow.egg-info/entry_points.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[console_scripts]
|
||||||
|
battleflow = battleflow.cli:main
|
||||||
2
battleflow.egg-info/requires.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
werkzeug>=3.0.0
|
||||||
1
battleflow.egg-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
battleflow
|
||||||
16
battleflow/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from flask import Flask
|
||||||
|
from .config import load_config
|
||||||
|
from .routes.admin import admin_bp
|
||||||
|
from .routes.board import board_bp
|
||||||
|
from .routes.api import api_bp
|
||||||
|
|
||||||
|
def create_app(**overrides) -> Flask:
|
||||||
|
app = Flask(__name__, static_folder="static", template_folder="templates")
|
||||||
|
load_config(app, **overrides)
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
app.register_blueprint(board_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
|
||||||
|
return app
|
||||||
31
battleflow/cli.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import argparse, os
|
||||||
|
from . import create_app
|
||||||
|
from .storage import ensure_default_icons, load_state
|
||||||
|
from .state import STATE
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(prog="battleflow")
|
||||||
|
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('BATTLEFLOW_TOKEN','changeme'))
|
||||||
|
parser.add_argument('--data-dir', default=os.environ.get('BATTLEFLOW_DATA_DIR'))
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
app = create_app(token=args.token, data_dir=args.data_dir)
|
||||||
|
with app.app_context():
|
||||||
|
ensure_default_icons()
|
||||||
|
load_state(STATE)
|
||||||
|
|
||||||
|
print(f"""
|
||||||
|
Name: {app.config['PRODUCT_NAME']} — {app.config['PRODUCT_SUBTITLE']}
|
||||||
|
Admin: http://{args.host}:{args.port}/admin?token={args.token}
|
||||||
|
Board: http://{args.host}:{args.port}/board?token={args.token}
|
||||||
|
Data dir: {app.config['DATA_DIR']}
|
||||||
|
Avatars: {app.config['AVATAR_DIR']}
|
||||||
|
Icons: {app.config['ICON_DIR']}
|
||||||
|
""")
|
||||||
|
app.run(host=args.host, port=args.port, debug=False)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
38
battleflow/config.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# battleflow/config.py
|
||||||
|
import os
|
||||||
|
|
||||||
|
DEFAULT_DATA_DIR = os.path.join(os.getcwd(), "battleflow_data")
|
||||||
|
|
||||||
|
def load_config(app, **overrides):
|
||||||
|
app.config.setdefault("SECRET_KEY", os.environ.get("SECRET_KEY", "dev-secret"))
|
||||||
|
app.config.setdefault(
|
||||||
|
"COMBAT_TOKEN",
|
||||||
|
overrides.get("token") or os.environ.get("BATTLEFLOW_TOKEN", "changeme")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kies data-dir: override/env > default. Migreer legacy 'initrack_data' → 'battleflow_data' indien nodig.
|
||||||
|
data_dir = overrides.get("data_dir") or os.environ.get("BATTLEFLOW_DATA_DIR")
|
||||||
|
if not data_dir:
|
||||||
|
default_new = DEFAULT_DATA_DIR
|
||||||
|
legacy = os.path.join(os.getcwd(), "initrack_data")
|
||||||
|
# Als legacy bestaat en nieuwe nog niet: gebruik legacy (geen dataverlies).
|
||||||
|
data_dir = legacy if (os.path.isdir(legacy) and not os.path.exists(default_new)) else default_new
|
||||||
|
|
||||||
|
app.config["DATA_DIR"] = data_dir
|
||||||
|
app.config["STATE_PATH"] = os.path.join(data_dir, "state.json")
|
||||||
|
app.config["PRESETS_PATH"] = os.path.join(data_dir, "presets.json")
|
||||||
|
app.config["PRESET_GROUPS_PATH"] = os.path.join(data_dir, "preset_groups.json")
|
||||||
|
app.config["AVATAR_DIR"] = os.path.join(data_dir, "avatars")
|
||||||
|
app.config["ICON_DIR"] = os.path.join(data_dir, "icons")
|
||||||
|
app.config.setdefault("MAX_CONTENT_LENGTH", 8 * 1024 * 1024)
|
||||||
|
|
||||||
|
# Zorg dat mappen bestaan
|
||||||
|
os.makedirs(app.config["DATA_DIR"], exist_ok=True)
|
||||||
|
os.makedirs(app.config["AVATAR_DIR"], exist_ok=True)
|
||||||
|
os.makedirs(app.config["ICON_DIR"], exist_ok=True)
|
||||||
|
|
||||||
|
# Branding
|
||||||
|
app.config.setdefault("PRODUCT_NAME", "Battleflow")
|
||||||
|
app.config.setdefault("PRODUCT_SUBTITLE", "by Aetryos Workshop")
|
||||||
|
|
||||||
|
return app
|
||||||
64
battleflow/models.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
# Conditions + icons
|
||||||
|
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':'💤'
|
||||||
|
}
|
||||||
1
battleflow/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# namespace placeholder
|
||||||
22
battleflow/routes/admin.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, current_app
|
||||||
|
from ..security import require_token
|
||||||
|
from ..storage import seed_curated_icons
|
||||||
|
|
||||||
|
admin_bp = Blueprint("admin", __name__)
|
||||||
|
|
||||||
|
@admin_bp.route("/admin")
|
||||||
|
@require_token
|
||||||
|
def admin():
|
||||||
|
token = request.args.get("token", "")
|
||||||
|
return render_template("admin.html",
|
||||||
|
token=token,
|
||||||
|
name=current_app.config["PRODUCT_NAME"],
|
||||||
|
subtitle=current_app.config["PRODUCT_SUBTITLE"],
|
||||||
|
data_dir=current_app.config["DATA_DIR"])
|
||||||
|
|
||||||
|
@admin_bp.post("/admin/seed_icons")
|
||||||
|
@require_token
|
||||||
|
def admin_seed_icons():
|
||||||
|
seed_curated_icons(overwrite=True)
|
||||||
|
token = request.args.get("token", "")
|
||||||
|
return redirect(url_for("admin.admin", token=token))
|
||||||
357
battleflow/routes/api.py
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import os, uuid
|
||||||
|
from flask import Blueprint, jsonify, request, abort, send_from_directory, current_app
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from ..security import require_token
|
||||||
|
from ..models import Actor, Preset, PresetGroup
|
||||||
|
from ..state import STATE
|
||||||
|
from ..storage import save_state, load_state, save_presets, load_presets, save_groups, load_groups, ensure_default_icons
|
||||||
|
|
||||||
|
api_bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
ALLOWED_AVATAR_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'}
|
||||||
|
|
||||||
|
# ---------- Health (public) ----------
|
||||||
|
@api_bp.get("/health")
|
||||||
|
def health():
|
||||||
|
"""
|
||||||
|
Public health endpoint (no auth required).
|
||||||
|
Add token via ?token= or X-Token to see authed:true.
|
||||||
|
"""
|
||||||
|
supplied = request.args.get("token") or request.headers.get("X-Token")
|
||||||
|
expected = current_app.config.get("COMBAT_TOKEN")
|
||||||
|
authed = bool(expected and supplied == expected)
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"name": current_app.config.get("PRODUCT_NAME"),
|
||||||
|
"subtitle": current_app.config.get("PRODUCT_SUBTITLE"),
|
||||||
|
"authed": authed,
|
||||||
|
"actors": len(STATE.actors),
|
||||||
|
"visible": STATE.visible,
|
||||||
|
"round": STATE.round,
|
||||||
|
"dead_mode": STATE.dead_mode,
|
||||||
|
"data_dir": current_app.config.get("DATA_DIR"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---------- Assets ----------
|
||||||
|
@api_bp.route("/avatars/<path:fn>")
|
||||||
|
def avatars(fn: str):
|
||||||
|
return send_from_directory(current_app.config["AVATAR_DIR"], fn)
|
||||||
|
|
||||||
|
@api_bp.route("/icons/<path:fn>")
|
||||||
|
def icons(fn: str):
|
||||||
|
return send_from_directory(current_app.config["ICON_DIR"], fn)
|
||||||
|
|
||||||
|
# ---------- State polling ----------
|
||||||
|
@api_bp.get("/api/state")
|
||||||
|
@require_token
|
||||||
|
def api_state():
|
||||||
|
since = 0
|
||||||
|
try:
|
||||||
|
since = int(request.args.get("since", "0"))
|
||||||
|
except ValueError:
|
||||||
|
since = 0
|
||||||
|
return jsonify(STATE.wait_for(since, timeout=25.0))
|
||||||
|
|
||||||
|
# ---------- Uploads ----------
|
||||||
|
@api_bp.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(current_app.config["AVATAR_DIR"], newname)
|
||||||
|
f.save(dest)
|
||||||
|
return jsonify({"ok": True, "filename": newname, "url": f"/avatars/{newname}"})
|
||||||
|
|
||||||
|
# ---------- Mutations ----------
|
||||||
|
@api_bp.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)
|
||||||
|
STATE.broadcast()
|
||||||
|
return jsonify({'ok': True, 'id': a.id})
|
||||||
|
|
||||||
|
@api_bp.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)
|
||||||
|
STATE.broadcast()
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
@api_bp.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)
|
||||||
|
STATE.broadcast()
|
||||||
|
return jsonify({'ok': True, 'removed': len(STATE.actors) != before})
|
||||||
|
|
||||||
|
@api_bp.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)
|
||||||
|
STATE.broadcast()
|
||||||
|
return jsonify({'ok': True, 'round': STATE.round, 'turn_idx': STATE.turn_idx})
|
||||||
|
|
||||||
|
@api_bp.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)
|
||||||
|
STATE.broadcast()
|
||||||
|
return jsonify({'ok': True, 'round': STATE.round, 'turn_idx': STATE.turn_idx})
|
||||||
|
|
||||||
|
@api_bp.post("/api/clear")
|
||||||
|
@require_token
|
||||||
|
def api_clear():
|
||||||
|
STATE.actors.clear()
|
||||||
|
STATE.turn_idx = 0
|
||||||
|
STATE.round = 1
|
||||||
|
save_state(STATE)
|
||||||
|
STATE.broadcast()
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
@api_bp.post("/api/toggle_visible")
|
||||||
|
@require_token
|
||||||
|
def api_toggle_visible():
|
||||||
|
STATE.visible = not STATE.visible
|
||||||
|
save_state(STATE)
|
||||||
|
STATE.broadcast()
|
||||||
|
return jsonify({'ok': True, 'visible': STATE.visible})
|
||||||
|
|
||||||
|
@api_bp.post("/api/dead_mode")
|
||||||
|
@require_token
|
||||||
|
def api_dead_mode():
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
mode = str(data.get('mode','normal')).lower()
|
||||||
|
if mode not in ('normal','shrink','hide'):
|
||||||
|
abort(400)
|
||||||
|
STATE.dead_mode = mode
|
||||||
|
save_state(STATE)
|
||||||
|
STATE.broadcast()
|
||||||
|
return jsonify({'ok': True, 'dead_mode': STATE.dead_mode})
|
||||||
|
|
||||||
|
@api_bp.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)
|
||||||
|
STATE.broadcast()
|
||||||
|
return jsonify({'ok': True, 'effects': target.effects})
|
||||||
|
|
||||||
|
@api_bp.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)
|
||||||
|
STATE.broadcast()
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
# Presets
|
||||||
|
@api_bp.get("/api/presets")
|
||||||
|
@require_token
|
||||||
|
def api_presets():
|
||||||
|
items = load_presets()
|
||||||
|
return jsonify({'items':[x.__dict__ for x in items]})
|
||||||
|
|
||||||
|
@api_bp.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],
|
||||||
|
)
|
||||||
|
items = load_presets()
|
||||||
|
items.append(p)
|
||||||
|
save_presets(items)
|
||||||
|
return jsonify({'ok': True, 'id': p.id})
|
||||||
|
|
||||||
|
@api_bp.post("/api/preset/remove")
|
||||||
|
@require_token
|
||||||
|
def api_preset_remove():
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
pid = str(data.get('id',''))
|
||||||
|
items = load_presets()
|
||||||
|
before = len(items)
|
||||||
|
items = [x for x in items if x.id != pid]
|
||||||
|
changed = len(items) != before
|
||||||
|
if changed:
|
||||||
|
save_presets(items)
|
||||||
|
return jsonify({'ok': True, 'removed': changed})
|
||||||
|
|
||||||
|
@api_bp.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))
|
||||||
|
items = load_presets()
|
||||||
|
src = next((x for x in items 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)
|
||||||
|
STATE.broadcast()
|
||||||
|
return jsonify({'ok': True, 'id': a.id})
|
||||||
|
|
||||||
|
# Preset Groups
|
||||||
|
@api_bp.get("/api/preset_groups")
|
||||||
|
@require_token
|
||||||
|
def api_preset_groups():
|
||||||
|
items = load_groups()
|
||||||
|
return jsonify({'items':[x.__dict__ for x in items]})
|
||||||
|
|
||||||
|
@api_bp.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)
|
||||||
|
items = load_groups()
|
||||||
|
items.append(g)
|
||||||
|
save_groups(items)
|
||||||
|
return jsonify({'ok': True, 'id': g.id})
|
||||||
|
|
||||||
|
@api_bp.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',''))
|
||||||
|
items = load_groups()
|
||||||
|
before = len(items)
|
||||||
|
items = [x for x in items if x.id != gid]
|
||||||
|
changed = len(items) != before
|
||||||
|
if changed:
|
||||||
|
save_groups(items)
|
||||||
|
return jsonify({'ok': True, 'removed': changed})
|
||||||
|
|
||||||
|
@api_bp.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)
|
||||||
|
groups = load_groups()
|
||||||
|
presets = load_presets()
|
||||||
|
g = next((x for x in 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
|
||||||
|
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)
|
||||||
|
STATE.broadcast()
|
||||||
|
return jsonify({'ok': True, 'added': added, 'count': len(added)})
|
||||||
|
|
||||||
|
# Initialize defaults on import (with proper app context)
|
||||||
|
@api_bp.record_once
|
||||||
|
def _init_defaults(setup_state):
|
||||||
|
app = setup_state.app
|
||||||
|
with app.app_context():
|
||||||
|
ensure_default_icons()
|
||||||
22
battleflow/routes/board.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from flask import Blueprint, render_template, current_app, Response
|
||||||
|
|
||||||
|
board_bp = Blueprint("board", __name__)
|
||||||
|
|
||||||
|
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>"
|
||||||
|
|
||||||
|
@board_bp.route("/")
|
||||||
|
def root():
|
||||||
|
# convenience: redirect to admin with token if configured
|
||||||
|
from flask import redirect, url_for, current_app
|
||||||
|
t = current_app.config.get("COMBAT_TOKEN", "changeme")
|
||||||
|
return redirect(url_for("admin.admin", token=t))
|
||||||
|
|
||||||
|
@board_bp.route("/board")
|
||||||
|
def board():
|
||||||
|
return render_template("board.html",
|
||||||
|
name=current_app.config["PRODUCT_NAME"],
|
||||||
|
subtitle=current_app.config["PRODUCT_SUBTITLE"])
|
||||||
|
|
||||||
|
@board_bp.route("/favicon.svg")
|
||||||
|
def favicon_svg():
|
||||||
|
return Response(FAVICON_SVG, 200, {"Content-Type": "image/svg+xml"})
|
||||||
12
battleflow/security.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from flask import request, abort, current_app
|
||||||
|
|
||||||
|
def require_token(f):
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
expected = current_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 f(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
63
battleflow/state.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import threading, time
|
||||||
|
from dataclasses import asdict
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from .models import Actor, KNOWN_CONDITIONS, CONDITION_EMOJI, CONDITION_ICON_FILES
|
||||||
|
|
||||||
|
def now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
class CombatState:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.actors: List[Actor] = []
|
||||||
|
self.turn_idx: int = 0
|
||||||
|
self.round: int = 1
|
||||||
|
self.visible: bool = True
|
||||||
|
self.dead_mode: str = 'normal' # 'normal' | 'shrink' | 'hide'
|
||||||
|
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,
|
||||||
|
'dead_mode': self.dead_mode,
|
||||||
|
'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()
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
STATE = CombatState()
|
||||||
62
battleflow/static/css/board.css
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/* Extracted from single-file version */
|
||||||
|
: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; }
|
||||||
|
|
||||||
|
/* Status pill */
|
||||||
|
.statuspill{ font-size:12px; padding:2px 8px; border-radius:999px; border:1px solid rgba(255,255,255,0.18); background:rgba(255,255,255,0.06); }
|
||||||
|
.statuspill.ok{ border-color:#14532d; background:#052e16; color:#bbf7d0; }
|
||||||
|
.statuspill.warn{ border-color:#334155; background:#0b1220; color:#e5e7eb; }
|
||||||
|
.statuspill.bad{ border-color:#7f1d1d; background:#1a0b0b; color:#fecaca; }
|
||||||
|
|
||||||
|
/* 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); transition: width .15s ease, opacity .15s ease, transform .15s ease; }
|
||||||
|
.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 */
|
||||||
|
.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 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; }
|
||||||
|
/* Shrink mode for dead cards */
|
||||||
|
.shrunk{ width: clamp(120px, 11vw, 170px); opacity:.72; transform: scale(.95); }
|
||||||
|
|
||||||
|
.footer{ margin-top:6px; font-size:12px; color:var(--muted); display:flex; justify-content:space-between; }
|
||||||
|
.hideall{ display:none; }
|
||||||
|
|
||||||
|
/* --- overlay for auth/errors --- */
|
||||||
|
.overlay{ position:fixed; inset:0; display:none; align-items:center; justify-content:center; background:rgba(0,0,0,0.6); z-index:9999; }
|
||||||
|
.overlay.show{ display:flex; }
|
||||||
|
.overlay .box{ background:#0b0d12; color:#e9eef4; border:1px solid #293145; border-radius:12px; padding:16px; width:min(520px,92vw); }
|
||||||
|
.overlay .box h3{ margin:0 0 8px; font-size:18px; }
|
||||||
|
.overlay .box p{ margin:8px 0; color:#94a3b8; }
|
||||||
|
.overlay .row{ display:flex; gap:8px; align-items:center; margin-top:10px; }
|
||||||
|
.overlay input{ flex:1; padding:8px; border-radius:8px; border:1px solid #293145; background:#0d111a; color:#e9eef4; }
|
||||||
|
.overlay button{ padding:8px 10px; border-radius:10px; border:1px solid #334155; background:#111827; color:#e9eef4; cursor:pointer; }
|
||||||
|
|
||||||
|
@media (max-width: 740px){
|
||||||
|
.list{ flex-direction:column; }
|
||||||
|
.row{ width:auto; align-items:flex-start; }
|
||||||
|
.name{ align-items:flex-start; text-align:left; }
|
||||||
|
}
|
||||||
4
battleflow/static/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 241 B |
266
battleflow/static/js/admin.js
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
function initAdmin(token){
|
||||||
|
const overlay = document.getElementById('authOverlay');
|
||||||
|
const authMsg = document.getElementById('authMsg');
|
||||||
|
const tokenInput = document.getElementById('tokenInput');
|
||||||
|
const setTokenBtn = document.getElementById('setTokenBtn');
|
||||||
|
const statusEl = document.getElementById('statuspill');
|
||||||
|
function showOverlay(msg){
|
||||||
|
if(authMsg && msg){ authMsg.textContent = msg; }
|
||||||
|
if(overlay){ overlay.classList.add('show'); }
|
||||||
|
if(setTokenBtn && tokenInput){
|
||||||
|
setTokenBtn.onclick = () => {
|
||||||
|
const t = (tokenInput.value||'').trim();
|
||||||
|
if(!t) return;
|
||||||
|
const url = new URL(location.href);
|
||||||
|
url.searchParams.set('token', t);
|
||||||
|
location.href = url.toString();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hideOverlay(){ if(overlay){ overlay.classList.remove('show'); } }
|
||||||
|
function setStatus(txt, cls){ if(!statusEl) return; statusEl.textContent = txt; statusEl.className = 'statuspill ' + (cls||''); }
|
||||||
|
|
||||||
|
let state=null, since=0, presets=[], 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'); }
|
||||||
|
window.openBoard = openBoard;
|
||||||
|
|
||||||
|
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':''}">
|
||||||
|
<td>${idx+1}</td>
|
||||||
|
<td>${a.name}</td>
|
||||||
|
<td>${a.init}</td>
|
||||||
|
<td>${a.hp}</td>
|
||||||
|
<td>${a.ac}${a.reveal_ac?'':' (hidden)'} </td>
|
||||||
|
<td>${typeChip(a.type)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn" title="Concentration" onclick=toggle('${a.id}','conc')>✦</button>
|
||||||
|
<button class="btn" title="Dead" onclick=toggle('${a.id}','dead')>☠</button>
|
||||||
|
<button class="btn" title="Visible to players" onclick=toggle('${a.id}','visible')>👁</button>
|
||||||
|
<button class="btn" title="Reveal AC" onclick=toggle('${a.id}','reveal_ac')>🛡</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-danger" onclick=removeA('${a.id}')>🗑 Remove</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
const active = (a.effects||[]);
|
||||||
|
const KNOWN_CONDITIONS = ['poisoned','prone','grappled','restrained','stunned','blinded','deafened','charmed','frightened','paralyzed','petrified','unconscious'];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
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.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid. Fix the token and reload.'); return false; }
|
||||||
|
if(!r.ok){ const t = await r.text(); alert('Error '+r.status+' '+t); setStatus('Reconnecting…','warn'); }
|
||||||
|
return r.ok;
|
||||||
|
}
|
||||||
|
async function get(p){
|
||||||
|
const r = await fetch(p+`?token=${encodeURIComponent(token)}`);
|
||||||
|
if(r.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid. Fix the token and reload.'); return null; }
|
||||||
|
if(!r.ok){ const t = await r.text(); alert('Error '+r.status+' '+t); setStatus('Reconnecting…','warn'); 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.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid.'); return; }
|
||||||
|
if(!r.ok){ const t = await r.text(); alert('Upload error '+r.status+' '+t); setStatus('Reconnecting…','warn'); 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 }); }
|
||||||
|
async function setDeadMode(mode){ await post('/api/dead_mode', { mode }); updateDeadSeg(mode); }
|
||||||
|
function updateDeadSeg(mode){ const seg = document.getElementById('deadseg'); if(!seg) return; seg.querySelectorAll('button[data-mode]').forEach(b=>{ b.classList.toggle('on', b.getAttribute('data-mode')===mode); }); }
|
||||||
|
|
||||||
|
// 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 r = await fetch('/api/preset_groups?token='+encodeURIComponent(token));
|
||||||
|
if(r.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid.'); return; }
|
||||||
|
const all = await r.json(); groups = all.items||[];
|
||||||
|
const g = groups.find(x=>x.id===id); if(!g){ alert('Group not found'); return; }
|
||||||
|
const mode = (prompt('Group add mode: single | list | per | step','single')||'single').trim().toLowerCase();
|
||||||
|
let payload = { id };
|
||||||
|
if(mode==='list'){
|
||||||
|
const raw = prompt('Comma-separated initiatives in group order','12,11,10');
|
||||||
|
if(raw && raw.trim().length){ payload.inits = raw.split(',').map(s=>parseFloat((s||'0').trim())); }
|
||||||
|
} else if(mode==='per'){
|
||||||
|
const arr = [];
|
||||||
|
g.member_ids.forEach((pid, i)=>{
|
||||||
|
const p = presets.find(x=>x.id===pid); const lbl = p? p.name : ('Member '+(i+1));
|
||||||
|
const v = prompt('Initiative for '+lbl,'10'); arr.push(parseFloat(v||'0'));
|
||||||
|
});
|
||||||
|
payload.inits = arr;
|
||||||
|
} else if(mode==='step'){
|
||||||
|
const base = parseFloat(prompt('Base initiative for first member','12')||'12');
|
||||||
|
const step = parseFloat(prompt('Step per next member (e.g. -1 for descending)','-1')||'-1');
|
||||||
|
payload.inits = g.member_ids.map((_,i)=> base + i*step);
|
||||||
|
} else {
|
||||||
|
const v = prompt('Single initiative value for all members','12');
|
||||||
|
payload.init = parseFloat(v||'12');
|
||||||
|
}
|
||||||
|
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.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid. Fix the token and reload.'); setTimeout(poll, 1500); return; }
|
||||||
|
if(r.ok){ hideOverlay(); setStatus('Connected','ok'); state = await r.json(); since = state.version; render(); updateDeadSeg(state.dead_mode||'normal'); }
|
||||||
|
else { setStatus('Reconnecting…','warn'); }
|
||||||
|
} catch(e){ setStatus('Reconnecting…','warn'); }
|
||||||
|
setTimeout(poll, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// init
|
||||||
|
window.clearAll = clearAll;
|
||||||
|
window.toggleVisible = toggleVisible;
|
||||||
|
window.next = next;
|
||||||
|
window.prev = prev;
|
||||||
|
window.toggle = toggle;
|
||||||
|
window.toggleEffect = toggleEffect;
|
||||||
|
window.clearEffects = clearEffects;
|
||||||
|
window.removeA = removeA;
|
||||||
|
window.setDeadMode = setDeadMode;
|
||||||
|
window.presetAdd = presetAdd;
|
||||||
|
window.presetRemove = presetRemove;
|
||||||
|
window.presetApply = presetApply;
|
||||||
|
window.groupAdd = groupAdd;
|
||||||
|
window.groupRemove = groupRemove;
|
||||||
|
window.groupApply = groupApply;
|
||||||
|
|
||||||
|
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();
|
||||||
|
setStatus('Connecting…','warn');
|
||||||
|
poll();
|
||||||
|
}
|
||||||
74
battleflow/static/js/board.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
(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||[];
|
||||||
|
const deadMode = state.dead_mode || 'normal';
|
||||||
|
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;
|
||||||
|
if(a.dead && deadMode==='hide') return;
|
||||||
|
const cls = ['row','type-'+(a.type||'pc')];
|
||||||
|
if(idx===state.turn_idx) cls.push('active');
|
||||||
|
if(a.dead){ cls.push('dead'); if(deadMode==='shrink') cls.push('shrunk'); }
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = cls.join(' ');
|
||||||
|
|
||||||
|
// 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='•'; } 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();
|
||||||
|
})();
|
||||||
122
battleflow/storage.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import json, os, uuid
|
||||||
|
from typing import List, Dict
|
||||||
|
from flask import current_app
|
||||||
|
from .models import Actor, Preset, PresetGroup, CONDITION_ICON_FILES, CURATED_ICON_SLUGS
|
||||||
|
|
||||||
|
PLACEHOLDER_BG = '#334155'
|
||||||
|
PLACEHOLDER_FG = '#e2e8f0'
|
||||||
|
GAME_ICONS_BASE = 'https://raw.githubusercontent.com/game-icons/icons/master'
|
||||||
|
|
||||||
|
def paths():
|
||||||
|
return dict(
|
||||||
|
STATE=current_app.config["STATE_PATH"],
|
||||||
|
PRESETS=current_app.config["PRESETS_PATH"],
|
||||||
|
GROUPS=current_app.config["PRESET_GROUPS_PATH"],
|
||||||
|
AVATARS=current_app.config["AVATAR_DIR"],
|
||||||
|
ICONS=current_app.config["ICON_DIR"],
|
||||||
|
DATA=current_app.config["DATA_DIR"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_state(state) -> None:
|
||||||
|
p = paths()
|
||||||
|
tmp = {
|
||||||
|
'actors': [a.__dict__ for a in state.actors],
|
||||||
|
'turn_idx': state.turn_idx,
|
||||||
|
'round': state.round,
|
||||||
|
'visible': state.visible,
|
||||||
|
'dead_mode': state.dead_mode,
|
||||||
|
}
|
||||||
|
with open(p["STATE"], 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(tmp, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def load_state(state) -> None:
|
||||||
|
p = paths()
|
||||||
|
if not os.path.exists(p["STATE"]):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(p["STATE"], '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.dead_mode = str(data.get('dead_mode', 'normal'))
|
||||||
|
state.normalize()
|
||||||
|
state.touch()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def save_presets(items: List[Preset]) -> None:
|
||||||
|
p = paths()
|
||||||
|
with open(p["PRESETS"], 'w', encoding='utf-8') as f:
|
||||||
|
json.dump([x.__dict__ for x in items], f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def load_presets() -> List[Preset]:
|
||||||
|
p = paths()
|
||||||
|
if not os.path.exists(p["PRESETS"]):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(p["PRESETS"], 'r', encoding='utf-8') as f:
|
||||||
|
arr = json.load(f)
|
||||||
|
return [Preset(**x) for x in arr]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def save_groups(items: List[PresetGroup]) -> None:
|
||||||
|
p = paths()
|
||||||
|
with open(p["GROUPS"], 'w', encoding='utf-8') as f:
|
||||||
|
json.dump([x.__dict__ for x in items], f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def load_groups() -> List[PresetGroup]:
|
||||||
|
p = paths()
|
||||||
|
if not os.path.exists(p["GROUPS"]):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(p["GROUPS"], 'r', encoding='utf-8') as f:
|
||||||
|
arr = json.load(f)
|
||||||
|
return [PresetGroup(**x) for x in arr]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def ensure_default_icons() -> None:
|
||||||
|
p = paths()
|
||||||
|
os.makedirs(p["ICONS"], exist_ok=True)
|
||||||
|
for c, fn in CONDITION_ICON_FILES.items():
|
||||||
|
dest = os.path.join(p["ICONS"], fn)
|
||||||
|
if os.path.exists(dest):
|
||||||
|
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(dest, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(svg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def seed_curated_icons(overwrite: bool = True) -> list[dict]:
|
||||||
|
ensure_default_icons()
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
from urllib.request import urlopen
|
||||||
|
from urllib.error import URLError, HTTPError
|
||||||
|
p = paths()
|
||||||
|
for cond, (author, slug) in CURATED_ICON_SLUGS.items():
|
||||||
|
dest = os.path.join(p["ICONS"], f'{cond}.svg')
|
||||||
|
url = f"https://raw.githubusercontent.com/game-icons/icons/master/{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)
|
||||||
|
out.append({'condition': cond, 'status': 'downloaded'})
|
||||||
|
else:
|
||||||
|
out.append({'condition': cond, 'status': 'exists'})
|
||||||
|
except (URLError, HTTPError) as e:
|
||||||
|
out.append({'condition': cond, 'status': f'fallback ({e.__class__.__name__})'})
|
||||||
|
finally:
|
||||||
|
return out
|
||||||
235
battleflow/templates/admin.html
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<!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; }
|
||||||
|
.seg{ display:inline-flex; gap:6px; align-items:center; }
|
||||||
|
.seg .btn{ padding:4px 8px; font-size:12px; }
|
||||||
|
.seg .on{ outline:2px solid #334155; }
|
||||||
|
.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 class="seg" id="deadseg">
|
||||||
|
<span class="muted">Dead cards:</span>
|
||||||
|
<button class="btn" data-mode="normal" onclick="setDeadMode('normal')">Normal</button>
|
||||||
|
<button class="btn" data-mode="shrink" onclick="setDeadMode('shrink')">Shrink</button>
|
||||||
|
<button class="btn" data-mode="hide" onclick="setDeadMode('hide')">Hide</button>
|
||||||
|
</div>
|
||||||
|
</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>{{ data_dir }}/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 src="{{ url_for('static', filename='js/admin.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
// initialise toggles/preview/dropzones and start polling
|
||||||
|
initAdmin('{{ token }}');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
battleflow/templates/board.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<!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" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/board.css') }}">
|
||||||
|
</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 style="display:flex; gap:10px; align-items:center;">
|
||||||
|
<div class="h-sub">Round <span id="round">1</span> · Updated <span id="updated">–</span></div>
|
||||||
|
<div id="statuspill" class="statuspill">Connecting…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="list" class="list"></div>
|
||||||
|
<div class="footer"><span id="count"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Auth/Error overlay -->
|
||||||
|
<div id="authOverlay" class="overlay">
|
||||||
|
<div class="box">
|
||||||
|
<h3>Authorization needed</h3>
|
||||||
|
<p id="authMsg">This board couldn't authenticate. Check the URL token.</p>
|
||||||
|
<div class="row">
|
||||||
|
<input id="tokenInput" placeholder="Enter token (will be saved locally)" />
|
||||||
|
<button id="setTokenBtn">Use token</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="{{ url_for('static', filename='js/board.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
battleflow_data/icons/blinded.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z"/><path fill="#fff" d="M295.568 31.755c-88.873 1.013-164.237 83.15-146.14 154.222 3.112 1.68 6.114 3.713 8.976 6.012 94.364-20.635 186.207-37.25 274.717-69.38-4.396-11.362-8.926-26.62-15.104-32.857-38.564-42.043-81.91-58.46-122.448-57.998zm162.787 100.527c-92.984 36.365-188.555 54.132-285.513 75.08 3 4.306 5.436 8.95 6.91 13.865 16.698.56 33.29.95 49.81 1.188 2.315-11.524 9.915-22.267 22.616-27.496l.338-.14.347-.11c4.037-1.292 8.075-1.804 11.944-1.66 3.87.14 7.57.94 10.93 2.268 6.725 2.66 12.12 7.126 16.605 12.01 4.4 4.79 8.038 10.1 11.054 15.06 56.644-.994 112.656-4.228 168.79-10.304-.018-3.805-.042-7.543-.096-11.22-16.977-1.565-36.94-.35-64.217 7.667 22.82-11.948 39.826-19.518 60.78-19.31 1.03.01 2.07.038 3.122.086-.45-10.747-1.432-20.984-3.654-30.824-33.557 19.84-62.436 23.53-105.98 26.362 50.238-10.525 79.007-24.07 102.546-38.356-1.695-4.802-3.77-9.52-6.33-14.166zM132.56 199.17c-.682-.004-1.15.09-1.45.194-4.756 2.414-9.748 9.214-12.018 17.453-2.215 8.037-1.57 16.664.984 21.662 4.615 4.572 14.302 6.43 24.166 4.493 9.68-1.9 17.22-7.725 18.862-10.728.035-5.966-4.99-16.103-12.74-23.406-4.08-3.848-8.656-6.877-12.417-8.417-1.88-.77-3.444-1.11-4.63-1.217-.277-.025-.53-.036-.756-.037zm131.753 11.76c-1.675-.076-3.475.16-5.56.786-8.19 3.47-11.016 8.43-11.85 16.082-.843 7.75 1.63 18.15 6.663 27.836 5.034 9.685 12.528 18.6 20.133 23.953 7.604 5.353 14.49 6.963 20.238 5.017l5.77 17.05c-12.697 4.3-25.788.1-36.37-7.348-10.582-7.45-19.485-18.33-25.744-30.372-3.893-7.49-6.8-15.45-8.108-23.474-16.447-.24-32.96-.625-49.57-1.178-2.164 5.224-5.78 9.34-10.246 12.565 5.82 11.84 12.81 22.992 21.11 33.396l2.597 3.252-.795 4.084c-6.046 31.008-13.87 62.623-36.97 82.58 31.778 52.62 70.812 94.726 150.777 102.636 7.516-26.908 14.15-57.853 60.483-89.71l2.422-1.663 2.937.084c40.79 1.18 61.765-5.75 71.61-18.506 4.322-5.6 7.014-13.152 8.17-22.847l-39.04-.797.366-17.996 39.19.8c-.368-8.815-1.513-18.807-3.42-30.08l-1.745-10.327 36.203-.586c-1.14-6.856-3.99-16.375-8.29-25.238-6.218-12.83-15.555-24.903-19.124-27.382l-2.123-1.477c-50.237 4.848-100.406 7.483-151.02 8.347-7.65 3.924-5.706 2.888-7.813 4.068-4.162-7.43-9.574-17.904-16.11-25.02-3.27-3.56-6.693-6.154-9.968-7.45-1.584-.625-3.133-1.01-4.807-1.086zm-157.125 40.21c-6.954 14.03-14.456 30.194-22.5 46.296-9.06 18.146-18.786 36.2-29.49 51.268-8.14 11.457-16.796 21.348-26.764 27.975 9.864 13.877 17.987 25.48 24.654 35.674 4.344-12.038 9.388-24.587 14.734-37.382 11.19-26.778 23.637-54.487 33.354-79.553 5.43-14.012 9.954-27.268 12.98-38.853-2.502-1.455-4.845-3.25-6.97-5.428zm38.093 9.92c-4.485.71-9.156.97-13.766.61-3.28 12.524-8.04 26.025-13.555 40.255-9.972 25.724-22.472 53.52-33.53 79.986-11.06 26.467-20.645 51.69-24.836 71.397-2.096 9.855-2.788 18.303-2.033 24.456.114.927.3 1.68.463 2.492 3.097-2.28 6.465-4.24 10.29-5.897 10.15-4.394 22.763-7.508 35.332-9.756 12.568-2.247 24.964-3.555 34.462-3.857.97-.03 1.77-.006 2.674-.018-10.392-58.63-2.174-142.745 4.5-199.666z"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
1
battleflow_data/icons/charmed.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z"/><path fill="#fff" d="M146.47 21.594c-19.843.39-40.255 13.992-46.94 38.937-36.28-36.277-90.65-8.066-79 41.595 11.826 50.403 99.55 64.537 114.25 90 0-32.133 66.5-82.522 54.19-135.125-5.728-24.468-23.862-35.773-42.5-35.406zM237 154.47c-35.243.73-68.834 22.932-79.688 69.31C133.202 326.807 263.438 425.5 263.438 488.44c28.8-49.877 200.592-77.563 223.75-176.282 22.82-97.274-83.624-152.5-154.687-81.437-13.49-50.343-55.558-77.08-95.5-76.25z"/></svg>
|
||||||
|
After Width: | Height: | Size: 533 B |
1
battleflow_data/icons/deafened.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z"/><path fill="#fff" d="M453.395 34.029l24.582 24.582L58.605 477.983 34.023 453.4zM212.917 243.597c-10.164-11.756-24.058-17.25-37.995-15.305q-.268 14.407-.642 28.451-.416 17.101-.748 32.951l42.335-42.335a46.754 46.754 0 0 0-2.929-3.762zM189.735 415.31c7.236 3.773 19.591-3.42 28.686-10.11l10.132 13.765c-13.167 9.694-24.507 14.514-34.276 14.514a26.517 26.517 0 0 1-12.44-3.003c-9.62-5.034-15.882-15.647-19.773-31.786l-48.33 48.299c25.864 71.523 159.463 42.816 159.463-70.647 0-65.581 98.82-69.343 122.365-155.06 6.296-22.915 8.423-43.906 6.958-63.059L176.59 384.154c3.41 21.418 8.753 28.878 13.146 31.155zM157.18 256.337c.609-25.116 1.24-51.088 1.304-77.872.064-26.207 5.43-47.903 15.946-64.48a74.58 74.58 0 0 1 39.641-31.24c40.005-13.777 91.393 3.302 110.61 27.788l-13.446 10.56c-15.038-19.153-59.243-33.304-91.595-22.167-20.991 7.214-34.768 23.887-40.71 48.8a101.16 101.16 0 0 1 21.525-15.294c22.07-11.393 44.056-10.549 63.593 2.448l-9.47 14.236c-12.387-8.24-25.843-9.694-39.983-4.275-22.22 8.454-37.408 29.563-39.01 34.415q0 16.117-.321 31.797c18.789-1.785 37.15 5.824 50.607 21.375.77.898 1.518 1.807 2.234 2.726L376.078 87.169c-14.011-18.79-32.95-34.607-55.577-47.668-54.465-31.55-211.043-12.665-211.043 108.856V353.79l46.77-46.813c.15-15.444.514-32.33.952-50.639z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
battleflow_data/icons/frightened.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z"/><path fill="#fff" d="M250.594 20.906c-45.425.318-89.65 20.975-112.78 61.282-22.594 39.374-34.23 82.722-31.314 115.406 1.458 16.34 6.393 29.793 14.72 39.5 8.325 9.706 20.104 16.173 37.53 18.03l11 1.19-3 10.655c-2.337 8.272-3.75 16.256-3.75 24.905 0 27.038 4.292 79.342 18.5 123.563 7.104 22.11 16.715 42.157 28.78 56.093 12.068 13.938 25.855 21.845 43.814 21.845 17.96 0 31.777-7.907 43.844-21.844 12.066-13.935 21.677-33.982 28.78-56.092 14.21-44.22 18.5-96.525 18.5-123.563 0-8.65-1.41-16.635-3.75-24.906l-2.968-10.533 10.875-1.28c17.146-2.04 29.05-8.367 37.47-17.72 8.417-9.352 13.49-22.17 15-38 3.02-31.66-8.958-74.675-34.814-117.03-25.5-41.774-70.927-61.8-116.374-61.5h-.062zM173.406 145.47c24.447 0 44.063 19.58 44.063 44.03 0 24.446-19.617 44.063-44.064 44.063-24.446 0-44.03-19.617-44.03-44.063s19.584-44.03 44.03-44.03zm161.438 0c24.447 0 44.062 19.58 44.062 44.03 0 24.446-19.616 44.063-44.062 44.063-24.447 0-44.03-19.617-44.03-44.063-.002-24.446 19.583-44.03 44.03-44.03zm-162.47 35.093c-6.623 0-11.78 5.188-11.78 11.812s5.157 11.78 11.78 11.78c6.625 0 11.814-5.156 11.814-11.78 0-6.627-5.188-11.813-11.813-11.813zm164.22 0c-6.624 0-11.78 5.188-11.78 11.812-.002 6.624 5.156 11.78 11.78 11.78s11.812-5.156 11.812-11.78c0-6.627-5.187-11.813-11.812-11.813zm-81.406 51.906c38.762 0 68.875 36.01 68.875 78.593 0 19.938-2.457 56.192-11.532 88.687-4.536 16.247-10.655 31.58-19.686 43.563-9.03 11.98-21.96 20.812-37.656 20.812-15.696 0-28.626-8.83-37.657-20.813-9.03-11.98-15.15-27.315-19.686-43.562-9.075-32.495-11.563-68.75-11.563-88.688 0-42.584 30.145-78.593 68.907-78.593zm0 18.686c-17.93 0-34.16 11.453-43.063 29.063h86.094c-8.895-17.61-25.103-29.064-43.033-29.064zm-27.282 173.938c1.45 2.532 2.956 4.878 4.53 6.97 6.78 8.99 13.692 13.373 22.75 13.373 9.06 0 15.943-4.383 22.72-13.375 1.576-2.09 3.08-4.436 4.53-6.968h-54.53z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
1
battleflow_data/icons/grappled.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z"/><path fill="#fff" d="M243.512 23.29c-27.105 18.337-53.533 32.92-82.274 45.337-2.843 17.364-3.948 34.497-4.05 51.584 28.913 15.41 56.096 32.85 83.33 49.634l7.045 4.344-3.432 7.482c-12.12 26.572-24.33 47.087-46.245 70.3l-5.184 5.512-6.46-3.904c-32.974-19.974-74.472-38.724-113.373-53.95l6.826-17.374c36.79 14.4 75.11 32.32 108.153 51.504 15.396-17.198 25.326-33.354 34.713-52.89-43.44-26.91-86.13-53.51-134.69-70.632-23.012 20.357-37.705 45.243-51.942 70.74 8.324 25.495 6.596 53.376-6.596 77.46 48.58-.593 97.994 2.23 150.666 10.26l5.658.837 1.787 5.44c8.85 26.46 11.79 54.41 8.325 83.588l-.987 8.432-8.466-.187c-40.508-.864-80.175-2.138-118.17.234 1.634 15.94-2.31 30.972-7.724 45.025 13.427 28.54 27.38 55.8 48.29 79.39 41.27-19.05 73.564-31.288 115.93-42.85-3.407-13.72-6.918-26.36-11.097-33.62-5.122-8.9-10.207-13.057-17.85-15.256-15.284-4.4-44.533 2.293-92.894 19.454l-6.243-17.594c48.907-17.354 79.702-26.894 104.283-19.82 9.133 2.628 16.884 8.004 23.066 15.46 14.487-7.627 28.415-16.79 42.053-26.996 12.34-45.92 37.29-81.42 66.626-112.107-7.226-13.52-13.208-27.204-20.563-40.613l-3.394-6.168 5-4.965c23.275-23.13 47.34-40.157 71.87-52.487l8.395 16.716c-20.952 10.53-41.503 25.913-61.795 45.152 12.41 23.91 22.263 45.5 39.457 64.826 37.488-27.124 74.943-51.39 116.84-74.938-13.96-30.473-31.345-58.357-56.286-79.462-32.2 13.38-62.527 17.39-92.61 12.29-14.223 13.25-30.094 22.23-48.756 23.337-29.017 1.722-60.74-15.74-99.174-57.672l6.858-6.295.017-.028.006.006 6.88-6.314c36.702 40.043 63.74 52.87 84.32 51.65 18.514-1.1 35.03-14.95 51.684-35.406-28.827-31.81-64.174-59.94-97.822-84.465zM39.324 277.884c-6.06.022-12.104.098-18.142.223 1.673 26.288 5.512 51.288 14.052 73.732 45.88-5.82 93.308-4.96 141.15-3.87 1.518-21.27-.253-41.69-6.058-61.212-45.528-6.565-88.59-9.03-131.002-8.873z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
1
battleflow_data/icons/paralyzed.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z"/><path fill="#fff" d="M380.7 29.4l-244.9.66c-18.2.63-25.1 16.17-25 40.81l-3.2 195.53-25.05 3.6 12.02-30.1-16.72-6.6-12 30.1-20.11-25.5-14.14 11.2 20.03 25.4-32.02 4.6 2.56 17.8 32.16-4.6-12.06 30.2 16.72 6.6 12-30.1 20.1 25.5 14.11-11.2-20.08-25.5 22.18-3.2-2.7 168c1.7 23.9 6 33.4 18 34.7l253.6 3.6c21.8 2.7 28.8-12.5 29.5-35.1l3.8-385.47c.3-19.47 1.2-39.36-28.8-40.93zm-24.4 20.77c26.3 1.35 31.9 39.46 31.6 57.13-5.6 104.2-3.9 209.5-5 314.3-.6 20.4-19.5 44.3-38.7 44.7-61.8 1.3-125.4 2.8-189.9-.8-10.7-.6-30.7-11.6-30.4-33.2l5.4-344.72c-.1-22.23 23.6-34.1 39.5-33.68 67.6 1.77 131.8 1.54 187.5-3.73zm98.4 7.4l-17 5.89 5.4 15.5-16.1-3.1-3.4 17.69 16.1 3.09L429 109l13.6 11.9 10.7-12.4 5.4 15.5 17-5.9-5.4-15.5 16.1 3.1 3.4-17.74-16.1-3.1 10.7-12.4-13.6-11.79-10.7 12.39-5.4-15.49zm-254 10.81c-15.8.12-41.6 10.71-48.8 30.02-16.2 43.3 5.1 132.8 18.6 144.5 4 3.5-3.1-100.9 39.7-159.47 7.5-10.3 1.3-15.13-9.5-15.05zm55.5 84.32c-17.2 0-32.5 18.4-32.5 42.5 0 12.4 4.1 23.4 10.3 31l6.2 7.8-9.9 1.5c-9.4 1.5-15.8 6-21.1 13.1-5.3 7.1-9.2 16.9-11.6 28.4-4.7 20.9-4.8 46.6-4.8 69h25.9l6.3 98h59.7l7.2-98h27.3c-.1-22.1-1.2-47.5-6.3-68.3-2.8-11.3-6.7-21.2-12-28.4-5.1-7.1-11.4-11.6-19.9-13.2l-9.7-1.8 6.4-7.5c6.4-7.8 10.8-18.9 10.8-31.6 0-22.7-13.7-40.4-29.1-42.5h-3.2zm104.3 189.2c-1.8.9-24.5 78.7-35.2 96.4-6.9 11.4 26 3.8 34.7-6.3 11.4-13.5 6.4-82.3.6-90.1h-.1zm95.6 32.2l-17.8 3 2.5 15.2-13.5-9.1-10 15 19.1 12.9-12.8 7.8 9.4 15.4 13.4-8.2 3.8 22.7 17.8-3-2.5-15.2 13.5 9.1 10-15-19.1-12.9 12.8-7.8-9.4-15.4-13.4 8.2-3.8-22.7z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
1
battleflow_data/icons/petrified.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z"/><path fill="#fff" d="M256 25c14.33 0 27.676 7.905 37.977 22.068C304.277 61.232 311 81.468 311 104c0 22.532-6.723 42.768-17.023 56.932C283.676 175.095 270.329 183 256 183c-14.33 0-27.676-7.905-37.977-22.068C207.723 146.768 201 126.532 201 104c0-22.532 6.722-42.768 17.023-56.932C228.324 32.905 241.671 25 256 25zm40 78h-80v18h31v23h18v-23h31zm4.777 77.732c22.269 3.505 48.815 9.312 84.93 17.334-18.385 31.94-30.507 71.784-36.947 105.024-30.784 3.249-71.261 9.48-92.76-11.819-23.106 21.245-68.115 17.842-92.838 11.424-6.459-33.161-18.556-72.814-36.869-104.629 36.115-8.022 62.661-13.829 84.93-17.334C223.35 193.18 238.89 201 256 201c17.11 0 32.65-7.82 44.777-20.268zM265 224h-18v48h18zm-8.992 91.117c25.254 11.781 65.5 11.202 89.556 7.113-1.059 7.611-1.768 14.623-2.12 20.77H168.556c-.358-6.232-1.08-13.351-2.164-21.084 29 2.217 65.796 6.81 89.615-6.799zM315 361v94h-18v-64h-82v64h-18v-94zm-36 48v46h-46v-46zm69.271 64l14 18H149.73l14-18z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
battleflow_data/icons/poisoned.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z"/><path fill="#fff" d="M256 106c-33.81 0-61.887 22.69-71.25 53.438C174.532 154.22 163.258 151 151 151c-41.42 0-75 33.58-75 75 0 1.784.346 3.405.468 5.157C41.284 243.387 16 276.65 16 316c0 49.706 40.294 90 90 90h300c49.706 0 90-40.294 90-90 0-39.35-25.284-72.614-60.468-84.843.123-1.752.468-3.374.468-5.157 0-41.42-33.58-75-75-75-12.258 0-23.532 3.222-33.75 8.437C317.887 128.69 289.81 106 256 106zm-60 90l60 60 60-60 30 30-60 60 60 60-30 30-60-60-60 60-30-30 60-60-60-60z"/></svg>
|
||||||
|
After Width: | Height: | Size: 567 B |
1
battleflow_data/icons/prone.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z"/><path fill="#fff" d="M384.932 45.57c-3.286.244-7.88 2.403-15.094 14.546-5.056 15.957-.322 25.086 5.06 38.496l2.307 5.744-55.96 51.87c4.376 8.594 7.407 18.226 8.78 28.44l80.254-80.214c-4.114-10.653-8.672-18.525-12.147-27.168-3.263-8.116-4.76-17.495-2.795-28.32-4.347-2.066-8.086-3.564-10.406-3.393zm-119.604 91.15c-25.092.105-47.134 26.142-46.957 60.414.178 34.27 22.487 60.12 47.58 60.013 25.092-.105 47.133-26.14 46.956-60.412-.177-34.272-22.485-60.12-47.578-60.015zm190.053 84.296c-5.97-.085-11.825.86-16.946 2.87-10.125 15.2-8.244 19.567-11.067 36.418l-.71 4.25-3.758 2.11c-21.674 12.172-42.448 22.542-62.93 39.315l-3.632 2.974-4.516-1.275s-12.793-3.613-25.804-7.423c-6.506-1.905-13.063-3.858-18.168-5.455-2.553-.8-4.73-1.505-6.45-2.106-.86-.3-1.59-.567-2.318-.867-.363-.15-.72-.302-1.197-.544-.47-.238-.912-.218-2.463-1.732l-.096.1-12.922-17.024c-5.195 1.613-10.67 2.493-16.36 2.517-21.26.09-39.657-11.704-51.53-29.73-56.886 37.057-116.74 79.386-150.313 123.28l8.283 24.558 55.025-15.826 20.713 46.717c42.768-26.075 84.4-51.742 116.833-74.634-6.47-2-12.324-4.36-17.36-7.163l8.754-15.726c9.89 5.505 29.343 10.33 51.204 12.707 20.935 2.277 44.212 2.546 64.754.84 24.303-20.896 54.028-46.405 72.838-65.997 1.26-7.008 3.54-13.69 7.895-19.768l.44-.617.538-.533c3.732-3.7 8.657-6.304 13.737-6.272 5.08.032 9.018 2.307 11.968 4.506 2.687 2.002 4.914 4.12 6.993 6.09l8.677-13.134c-3.495-8.958-11.785-16.096-22.45-20.12-5.596-2.11-11.687-3.225-17.66-3.31zM36.79 381.1l-2.56 17.82c-.555-.08-.808-.126-1.085-.173.112.03.233.054.32.092.617.265 1.608.72 2.838 1.303 2.46 1.168 5.905 2.864 9.95 4.89 3.966 1.987 8.656 4.375 13.52 6.86L51.57 387.58c-2.886-1.436-5.518-2.733-7.546-3.696-1.338-.635-2.458-1.152-3.418-1.567-.96-.415-.327-.715-3.817-1.217zm68.374 21.485l-40.7 11.707.026.014-15.095 13.234c-4.943-2.555-9.69-4.996-13.698-7.024-3.356-1.698-6.226-3.125-8.427-4.18-1.1-.53-2.026-.962-2.84-1.318-.815-.356-.077-.615-3.537-1.125L18.27 431.7c-.503-.074-.715-.114-.996-.162.475.21 1.24.56 2.21 1.025 1.987.953 4.79 2.35 8.086 4.016 2.155 1.09 4.764 2.433 7.272 3.72L20.78 452.628l11.867 13.535 19.37-16.982c16.705 8.704 32.9 17.262 32.9 17.262l8.413-15.912s-12.692-6.693-26.802-14.07l15.158-13.29c18.2 9.415 34.89 18.137 34.89 18.137l8.352-15.947s-13.362-6.973-28.71-14.93zm-87.89 28.953l-.053-.025c-.395-.173-1.407-.226.054.025z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
1
battleflow_data/icons/restrained.svg
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
1
battleflow_data/icons/stunned.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z"/><path fill="#fff" d="M358.5 283.23c-22.89 3.1-52 5.23-88.72 6.48-23.3.79-49.43 1.19-77.68 1.19-35.57 0-67.27-.63-86.89-1.09a208.69 208.69 0 0 0 8.9 58.51c22.42 74.88 81.29 125.55 139.88 125.55a99 99 0 0 0 28.48-4.16c65-19.46 98.09-101.96 76.03-186.48zm-162.38 87.28l-13.58-8.25-6 10.53-15.74-9 6.27-10.93-14-8.5 9.42-15.5 13.58 8.25 6-10.53 15.74 9-6.27 10.93 14 8.5zm98.3-25.82l-13.58-8.25-6 10.53-15.74-9 6.24-10.97-14-8.49 9.45-15.51 13.58 8.25 6-10.53 15.74 9-6.27 10.93 14 8.5zm121.79-227.27l22 18.33 24.32-15.08-10.64 26.57 21.86 18.47-28.55-1.91-10.84 26.5-7-27.75-28.54-2.1 24.17-15.23zm-.22-78.84l2.08 17.88 17.62 3.67-16.36 7.5 2 17.89-12.21-13.24-16.41 7.39L401.53 64l-12.1-13.33 17.65 3.55zm-353.07-.45L81.35 60l26.59-10.58-15.13 24.32 18.28 22-27.78-6.87-15.32 24.19-2-28.54-27.74-7.07 26.52-10.76zm353.07 205.31c-4.56 12.66-25.56 26.15-146.72 30.27-25.88.88-52.47 1.18-77.14 1.18-41.91 0-121.2-1.21-121.2-1.21v-16s79.47 1.21 121.21 1.21c24.14 0 50.12-.29 75.43-1.14 38.77-1.29 69.93-3.69 92.62-7.11 34.07-5.15 39.81-11.23 40.63-12.44-.24-.57-1.22-2.35-4.86-5.23-10.14-8-28.53-16-53.3-23.44a202.41 202.41 0 0 0-16.56-21.22c2 .51 4 1 5.88 1.53 35.17 9.36 60 19.64 73.88 30.56 6.51 5.18 13.58 13.36 10.13 23.04zm-304.81-1.51c1.5-7.33 8.84-26.5 12.41-31.92 56.35 3.86 150.85-15.72 176.38-25.16 15.21 13.25 32.71 35.84 40.61 52.19-57.31 6.52-159.43 6.65-229.43 4.9zm19.4-72.09c-10.08-.6-33.73-2.07-42.65 2 11.87 11.21 75 12.46 128.23 4.92 57.06-8.08 110-21.46 141.07-42.63 12.94-8.82 19.78-21.71 18.54-27.43-6.3-29.16-174.12-39.46-174.12-39.46s178.29 3.69 179.61 39.45c1.42 38.36-82.14 67.8-162.44 80.33-76.27 11.9-149.39 12.73-145.6-18.73 2.2-18.28 51.33-14.87 72.59-12.45-4.22 2.91-11.95 10.56-15.26 14.01zm75.88-19.13a106.28 106.28 0 0 1 42.58 4.6c-12.73 3.12-58.29 9.31-85.16 10 21.21-12.93 38.79-14.14 42.55-14.59z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
1
battleflow_data/icons/unconscious.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z"/><path fill="#fff" d="M264.438 17.094c-65.792 0-122.042 41.78-145.625 93.406-20.03 34.082-31.844 74.992-31.844 118 0 73.852 20.417 140.625 52.81 188.406 32.395 47.78 76.155 76.28 123.845 76.28 47.69.002 91.45-28.5 123.844-76.28 32.393-47.78 52.81-114.555 52.81-188.406 0-41.823-10.428-80.8-28.31-113.53-22.305-53.66-79.854-97.876-147.533-97.876zm0 18.687c72.458 0 132.256 60.305 138.25 117.564H267.063l-6.22-22.625-9-32.782-9 32.78-10.5 38.22-16.843-61.282-9.03-32.78-9 32.78L185 152.97h-58.813c6.22-57.147 65.95-117.19 138.25-117.19zm-57.97 109.657l16.845 61.25 9.03 32.782 9-32.782 10.5-38.25 1 3.593h149.812c-5.967 55.655-64.63 101.032-138.22 101.032-73.752 0-132.567-45.58-138.31-101.406h73.124l1.906-6.875 5.313-19.343zm-71.78 139.407c18.355 18.096 37.527 26.734 55.718 27.53 18.192.798 35.928-6.095 52.125-21.5l12.876 13.563c-19.213 18.273-42.28 27.657-65.844 26.625-23.562-1.03-47.1-12.333-68-32.937l13.126-13.28zm264.75 0l13.125 13.28c-20.898 20.605-44.438 31.907-68 32.938-23.563 1.032-46.63-8.352-65.844-26.625l12.874-13.562c16.198 15.404 33.965 22.297 52.156 21.5 18.19-.797 37.333-9.435 55.688-27.53zM266.53 419.594c26.456-.068 52.92 3.186 79.345 10.03l-4.688 18.095c-49.808-12.902-99.402-12.4-149.375.03l-4.53-18.125c26.343-6.552 52.795-9.964 79.25-10.03z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
22
battleflow_data/preset_groups.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "d55af6d8-222b-40f6-b9c3-c6062807b2cb",
|
||||||
|
"name": "Full Party - Spelljammer",
|
||||||
|
"member_ids": [
|
||||||
|
"f925ef5f-caad-4eb7-85fb-c5c430f8cb1a",
|
||||||
|
"b31a5165-c21f-47de-8465-e2260374240d",
|
||||||
|
"010f1066-3976-4f00-af3f-abe20b2a8817",
|
||||||
|
"6bf50981-1374-4741-8c9e-8601745db64f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "83d7a8a0-22e4-4482-b945-d89510b078ee",
|
||||||
|
"name": "Full Party - Erian",
|
||||||
|
"member_ids": [
|
||||||
|
"ef1832d6-17e7-49e1-803b-21c1ad434d8d",
|
||||||
|
"844d5b93-aa6d-471e-8474-32219d08bd0d",
|
||||||
|
"9717f97d-4f4f-4021-8f42-965de9b70f0c",
|
||||||
|
"237b8b79-6c32-4f33-b2c0-39e001e8c4ac"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
17
pyproject.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "battleflow"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Battleflow — OBS-friendly initiative board for tabletop encounters"
|
||||||
|
authors = [{name="Aetryos Workshop"}]
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = [
|
||||||
|
"flask>=3.0.0",
|
||||||
|
"werkzeug>=3.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
battleflow = "battleflow.cli:main"
|
||||||
3
requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0.0
|
||||||
|
invoke>=2.2.0
|
||||||
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
werkzeug>=3.0.0
|
||||||
29
tasks.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from invoke import task
|
||||||
|
import webbrowser, os
|
||||||
|
|
||||||
|
@task
|
||||||
|
def install(c):
|
||||||
|
c.run("python -m pip install -U pip")
|
||||||
|
c.run("python -m pip install -e . -r requirements-dev.txt")
|
||||||
|
|
||||||
|
@task
|
||||||
|
def run(c, host="0.0.0.0", port=5050, token="changeme", data_dir=""):
|
||||||
|
extra = f"--data-dir {data_dir}" if data_dir else ""
|
||||||
|
c.run(f"python -m battleflow.cli --host {host} --port {port} --token {token} {extra}")
|
||||||
|
|
||||||
|
@task
|
||||||
|
def seed_icons(c, host="127.0.0.1", port=5050, token="changeme"):
|
||||||
|
url = f"http://{host}:{port}/admin/seed_icons?token={token}"
|
||||||
|
c.run(f"python - <<'PY'\nimport urllib.request; urllib.request.urlopen('{url}', data=b'').read()\nprint('Seeded icons via', '{url}')\nPY")
|
||||||
|
|
||||||
|
@task
|
||||||
|
def test(c):
|
||||||
|
c.run("pytest -q")
|
||||||
|
|
||||||
|
@task
|
||||||
|
def open_admin(c, host="127.0.0.1", port=5050, token="changeme"):
|
||||||
|
webbrowser.open(f"http://{host}:{port}/admin?token={token}")
|
||||||
|
|
||||||
|
@task
|
||||||
|
def open_board(c, host="127.0.0.1", port=5050, token="changeme"):
|
||||||
|
webbrowser.open(f"http://{host}:{port}/board?token={token}")
|
||||||
78
tests/test_api_basic.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import os, json, tempfile, shutil
|
||||||
|
import pytest
|
||||||
|
from battleflow import create_app
|
||||||
|
from battleflow.state import STATE
|
||||||
|
from battleflow.storage import ensure_default_icons, load_state
|
||||||
|
|
||||||
|
TOKEN = "testtoken"
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolated_data(tmp_path, monkeypatch):
|
||||||
|
# fresh state for each test
|
||||||
|
data_dir = tmp_path / "bfdata"
|
||||||
|
data_dir.mkdir()
|
||||||
|
monkeypatch.setenv("BATTLEFLOW_DATA_DIR", str(data_dir))
|
||||||
|
monkeypatch.setenv("BATTLEFLOW_TOKEN", TOKEN)
|
||||||
|
# reset STATE
|
||||||
|
STATE.actors.clear(); STATE.turn_idx=0; STATE.round=1; STATE.visible=True; STATE.dead_mode='normal'
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
ensure_default_icons()
|
||||||
|
load_state(STATE)
|
||||||
|
yield app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(isolated_data):
|
||||||
|
isolated_data.testing = True
|
||||||
|
return isolated_data.test_client()
|
||||||
|
|
||||||
|
def url(p):
|
||||||
|
sep = "&" if "?" in p else "?"
|
||||||
|
return f"{p}{sep}token={TOKEN}"
|
||||||
|
|
||||||
|
def test_state_empty(client):
|
||||||
|
r = client.get(url("/api/state?since=0"))
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["actors"] == []
|
||||||
|
assert data["round"] == 1
|
||||||
|
|
||||||
|
def test_add_and_cycle(client):
|
||||||
|
# add one actor
|
||||||
|
r = client.post(url("/api/add"), json={"name":"A","init":15,"type":"pc","hp":10,"ac":14})
|
||||||
|
assert r.status_code == 200
|
||||||
|
# state should include it
|
||||||
|
r = client.get(url("/api/state?since=0"))
|
||||||
|
s = r.get_json()
|
||||||
|
assert len(s["actors"]) == 1
|
||||||
|
assert s["actors"][0]["name"] == "A"
|
||||||
|
# next/prev/visible
|
||||||
|
assert client.post(url("/api/next")).status_code == 200
|
||||||
|
assert client.post(url("/api/prev")).status_code == 200
|
||||||
|
assert client.post(url("/api/toggle_visible")).status_code == 200
|
||||||
|
|
||||||
|
def test_effects_and_deadmode(client):
|
||||||
|
# add
|
||||||
|
r = client.post(url("/api/add"), json={"name":"B","init":12,"type":"npc"})
|
||||||
|
aid = r.get_json()["id"]
|
||||||
|
# toggle effect
|
||||||
|
assert client.post(url("/api/toggle_effect"), json={"id":aid,"effect":"poisoned"}).status_code == 200
|
||||||
|
# clear effects
|
||||||
|
assert client.post(url("/api/clear_effects"), json={"id":aid}).status_code == 200
|
||||||
|
# dead mode
|
||||||
|
assert client.post(url("/api/dead_mode"), json={"mode":"shrink"}).status_code == 200
|
||||||
|
assert client.post(url("/api/dead_mode"), json={"mode":"hide"}).status_code == 200
|
||||||
|
|
||||||
|
def test_presets_and_groups(client):
|
||||||
|
# add presets
|
||||||
|
p1 = client.post(url("/api/preset/add"), json={"name":"Fighter","type":"pc","hp":30,"ac":18}).get_json()["id"]
|
||||||
|
p2 = client.post(url("/api/preset/add"), json={"name":"Goblin","type":"monster","hp":7,"ac":15}).get_json()["id"]
|
||||||
|
# apply preset (asks for init)
|
||||||
|
client.post(url("/api/preset/apply"), json={"id":p1,"init":14})
|
||||||
|
# create group
|
||||||
|
g = client.post(url("/api/preset/group/add"), json={"name":"Party","member_ids":[p1,p2]}).get_json()["id"]
|
||||||
|
# apply group with single init
|
||||||
|
client.post(url("/api/preset/group/apply"), json={"id":g,"init":10})
|
||||||
|
# state should have at least 3 actors now
|
||||||
|
s = client.get(url("/api/state?since=0")).get_json()
|
||||||
|
assert len(s["actors"]) >= 3
|
||||||