mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
feat(themes): whitelist governance, synergy cap, docs + tests; feat(random): laid roadwork for random implementation, testing in headless confirmed
This commit is contained in:
parent
03e839fb87
commit
16261bbf09
34 changed files with 12594 additions and 23 deletions
274
code/web/app.py
274
code/web/app.py
|
|
@ -78,6 +78,15 @@ ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), False)
|
|||
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
||||
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
||||
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), False)
|
||||
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), False)
|
||||
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), False)
|
||||
def _as_int(val: str | None, default: int) -> int:
|
||||
try:
|
||||
return int(val) if val is not None and str(val).strip() != "" else default
|
||||
except Exception:
|
||||
return default
|
||||
RANDOM_MAX_ATTEMPTS = _as_int(os.getenv("RANDOM_MAX_ATTEMPTS"), 5)
|
||||
RANDOM_TIMEOUT_MS = _as_int(os.getenv("RANDOM_TIMEOUT_MS"), 5000)
|
||||
|
||||
# Theme default from environment: THEME=light|dark|system (case-insensitive). Defaults to system.
|
||||
_THEME_ENV = (os.getenv("THEME") or "").strip().lower()
|
||||
|
|
@ -96,6 +105,10 @@ templates.env.globals.update({
|
|||
"enable_presets": ENABLE_PRESETS,
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"default_theme": DEFAULT_THEME,
|
||||
"random_modes": RANDOM_MODES,
|
||||
"random_ui": RANDOM_UI,
|
||||
"random_max_attempts": RANDOM_MAX_ATTEMPTS,
|
||||
"random_timeout_ms": RANDOM_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
# --- Simple fragment cache for template partials (low-risk, TTL-based) ---
|
||||
|
|
@ -178,11 +191,272 @@ async def status_sys():
|
|||
"ENABLE_PRESETS": bool(ENABLE_PRESETS),
|
||||
"ALLOW_MUST_HAVES": bool(ALLOW_MUST_HAVES),
|
||||
"DEFAULT_THEME": DEFAULT_THEME,
|
||||
"RANDOM_MODES": bool(RANDOM_MODES),
|
||||
"RANDOM_UI": bool(RANDOM_UI),
|
||||
"RANDOM_MAX_ATTEMPTS": int(RANDOM_MAX_ATTEMPTS),
|
||||
"RANDOM_TIMEOUT_MS": int(RANDOM_TIMEOUT_MS),
|
||||
},
|
||||
}
|
||||
except Exception:
|
||||
return {"version": "unknown", "uptime_seconds": 0, "flags": {}}
|
||||
|
||||
# --- Random Modes API ---
|
||||
@app.post("/api/random_build")
|
||||
async def api_random_build(request: Request):
|
||||
# Gate behind feature flag
|
||||
if not RANDOM_MODES:
|
||||
raise HTTPException(status_code=404, detail="Random Modes disabled")
|
||||
try:
|
||||
body = {}
|
||||
try:
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
except Exception:
|
||||
body = {}
|
||||
theme = body.get("theme")
|
||||
constraints = body.get("constraints")
|
||||
seed = body.get("seed")
|
||||
attempts = body.get("attempts", int(RANDOM_MAX_ATTEMPTS))
|
||||
timeout_ms = body.get("timeout_ms", int(RANDOM_TIMEOUT_MS))
|
||||
# Convert ms -> seconds, clamp minimal
|
||||
try:
|
||||
timeout_s = max(0.1, float(timeout_ms) / 1000.0)
|
||||
except Exception:
|
||||
timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0)
|
||||
# Import on-demand to avoid heavy costs at module import time
|
||||
from deck_builder.random_entrypoint import build_random_deck # type: ignore
|
||||
res = build_random_deck(
|
||||
theme=theme,
|
||||
constraints=constraints,
|
||||
seed=seed,
|
||||
attempts=int(attempts),
|
||||
timeout_s=float(timeout_s),
|
||||
)
|
||||
rid = getattr(request.state, "request_id", None)
|
||||
return {
|
||||
"seed": int(res.seed),
|
||||
"commander": res.commander,
|
||||
"theme": res.theme,
|
||||
"constraints": res.constraints or {},
|
||||
"attempts": int(attempts),
|
||||
"timeout_ms": int(timeout_ms),
|
||||
"request_id": rid,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as ex:
|
||||
logging.getLogger("web").error(f"random_build failed: {ex}")
|
||||
raise HTTPException(status_code=500, detail="random_build failed")
|
||||
|
||||
|
||||
@app.post("/api/random_full_build")
|
||||
async def api_random_full_build(request: Request):
|
||||
# Gate behind feature flag
|
||||
if not RANDOM_MODES:
|
||||
raise HTTPException(status_code=404, detail="Random Modes disabled")
|
||||
try:
|
||||
body = {}
|
||||
try:
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
except Exception:
|
||||
body = {}
|
||||
theme = body.get("theme")
|
||||
constraints = body.get("constraints")
|
||||
seed = body.get("seed")
|
||||
attempts = body.get("attempts", int(RANDOM_MAX_ATTEMPTS))
|
||||
timeout_ms = body.get("timeout_ms", int(RANDOM_TIMEOUT_MS))
|
||||
# Convert ms -> seconds, clamp minimal
|
||||
try:
|
||||
timeout_s = max(0.1, float(timeout_ms) / 1000.0)
|
||||
except Exception:
|
||||
timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0)
|
||||
|
||||
# Build a full deck deterministically
|
||||
from deck_builder.random_entrypoint import build_random_full_deck # type: ignore
|
||||
res = build_random_full_deck(
|
||||
theme=theme,
|
||||
constraints=constraints,
|
||||
seed=seed,
|
||||
attempts=int(attempts),
|
||||
timeout_s=float(timeout_s),
|
||||
)
|
||||
|
||||
# Create a permalink token reusing the existing format from /build/permalink
|
||||
payload = {
|
||||
"commander": res.commander,
|
||||
# Note: tags/bracket/ideals omitted; random modes focuses on seed replay
|
||||
"random": {
|
||||
"seed": int(res.seed),
|
||||
"theme": res.theme,
|
||||
"constraints": res.constraints or {},
|
||||
},
|
||||
}
|
||||
try:
|
||||
import base64
|
||||
raw = _json.dumps(payload, separators=(",", ":"))
|
||||
token = base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii").rstrip("=")
|
||||
permalink = f"/build/from?state={token}"
|
||||
except Exception:
|
||||
permalink = None
|
||||
|
||||
rid = getattr(request.state, "request_id", None)
|
||||
return {
|
||||
"seed": int(res.seed),
|
||||
"commander": res.commander,
|
||||
"decklist": res.decklist or [],
|
||||
"theme": res.theme,
|
||||
"constraints": res.constraints or {},
|
||||
"permalink": permalink,
|
||||
"attempts": int(attempts),
|
||||
"timeout_ms": int(timeout_ms),
|
||||
"request_id": rid,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as ex:
|
||||
logging.getLogger("web").error(f"random_full_build failed: {ex}")
|
||||
raise HTTPException(status_code=500, detail="random_full_build failed")
|
||||
|
||||
|
||||
@app.post("/api/random_reroll")
|
||||
async def api_random_reroll(request: Request):
|
||||
# Gate behind feature flag
|
||||
if not RANDOM_MODES:
|
||||
raise HTTPException(status_code=404, detail="Random Modes disabled")
|
||||
try:
|
||||
body = {}
|
||||
try:
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
except Exception:
|
||||
body = {}
|
||||
theme = body.get("theme")
|
||||
constraints = body.get("constraints")
|
||||
last_seed = body.get("seed")
|
||||
# Simple deterministic reroll policy: increment prior seed when provided; else generate fresh
|
||||
try:
|
||||
new_seed = int(last_seed) + 1 if last_seed is not None else None
|
||||
except Exception:
|
||||
new_seed = None
|
||||
if new_seed is None:
|
||||
from random_util import generate_seed # type: ignore
|
||||
new_seed = int(generate_seed())
|
||||
|
||||
# Build with the new seed
|
||||
timeout_ms = body.get("timeout_ms", int(RANDOM_TIMEOUT_MS))
|
||||
try:
|
||||
timeout_s = max(0.1, float(timeout_ms) / 1000.0)
|
||||
except Exception:
|
||||
timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0)
|
||||
attempts = body.get("attempts", int(RANDOM_MAX_ATTEMPTS))
|
||||
|
||||
from deck_builder.random_entrypoint import build_random_full_deck # type: ignore
|
||||
res = build_random_full_deck(
|
||||
theme=theme,
|
||||
constraints=constraints,
|
||||
seed=new_seed,
|
||||
attempts=int(attempts),
|
||||
timeout_s=float(timeout_s),
|
||||
)
|
||||
|
||||
payload = {
|
||||
"commander": res.commander,
|
||||
"random": {
|
||||
"seed": int(res.seed),
|
||||
"theme": res.theme,
|
||||
"constraints": res.constraints or {},
|
||||
},
|
||||
}
|
||||
try:
|
||||
import base64
|
||||
raw = _json.dumps(payload, separators=(",", ":"))
|
||||
token = base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii").rstrip("=")
|
||||
permalink = f"/build/from?state={token}"
|
||||
except Exception:
|
||||
permalink = None
|
||||
|
||||
rid = getattr(request.state, "request_id", None)
|
||||
return {
|
||||
"previous_seed": (int(last_seed) if isinstance(last_seed, int) or (isinstance(last_seed, str) and str(last_seed).isdigit()) else None),
|
||||
"seed": int(res.seed),
|
||||
"commander": res.commander,
|
||||
"decklist": res.decklist or [],
|
||||
"theme": res.theme,
|
||||
"constraints": res.constraints or {},
|
||||
"permalink": permalink,
|
||||
"attempts": int(attempts),
|
||||
"timeout_ms": int(timeout_ms),
|
||||
"request_id": rid,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as ex:
|
||||
logging.getLogger("web").error(f"random_reroll failed: {ex}")
|
||||
raise HTTPException(status_code=500, detail="random_reroll failed")
|
||||
|
||||
|
||||
@app.post("/hx/random_reroll")
|
||||
async def hx_random_reroll(request: Request):
|
||||
# Small HTMX endpoint returning a partial HTML fragment for in-page updates
|
||||
if not RANDOM_UI or not RANDOM_MODES:
|
||||
raise HTTPException(status_code=404, detail="Random UI disabled")
|
||||
body = {}
|
||||
try:
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
except Exception:
|
||||
body = {}
|
||||
last_seed = body.get("seed")
|
||||
theme = body.get("theme")
|
||||
constraints = body.get("constraints")
|
||||
try:
|
||||
new_seed = int(last_seed) + 1 if last_seed is not None else None
|
||||
except Exception:
|
||||
new_seed = None
|
||||
if new_seed is None:
|
||||
from random_util import generate_seed # type: ignore
|
||||
new_seed = int(generate_seed())
|
||||
|
||||
from deck_builder.random_entrypoint import build_random_full_deck # type: ignore
|
||||
res = build_random_full_deck(
|
||||
theme=theme,
|
||||
constraints=constraints,
|
||||
seed=new_seed,
|
||||
attempts=int(RANDOM_MAX_ATTEMPTS),
|
||||
timeout_s=float(RANDOM_TIMEOUT_MS) / 1000.0,
|
||||
)
|
||||
|
||||
# Render minimal fragment via Jinja2
|
||||
try:
|
||||
return templates.TemplateResponse(
|
||||
"partials/random_result.html", # type: ignore
|
||||
{
|
||||
"request": request,
|
||||
"seed": int(res.seed),
|
||||
"commander": res.commander,
|
||||
"decklist": res.decklist or [],
|
||||
"theme": res.theme,
|
||||
"constraints": res.constraints or {},
|
||||
},
|
||||
)
|
||||
except Exception as ex:
|
||||
logging.getLogger("web").error(f"hx_random_reroll template error: {ex}")
|
||||
# Fallback to JSON to avoid total failure
|
||||
return JSONResponse(
|
||||
{
|
||||
"seed": int(res.seed),
|
||||
"commander": res.commander,
|
||||
"decklist": res.decklist or [],
|
||||
"theme": res.theme,
|
||||
"constraints": res.constraints or {},
|
||||
}
|
||||
)
|
||||
|
||||
# Logs tail endpoint (read-only)
|
||||
@app.get("/status/logs")
|
||||
async def status_logs(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from html import escape as _esc
|
|||
from deck_builder.builder import DeckBuilder
|
||||
from deck_builder import builder_utils as bu
|
||||
from ..services.combo_utils import detect_all as _detect_all
|
||||
from path_util import csv_dir as _csv_dir
|
||||
from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
|
||||
|
||||
# Cache for available card names used by validation endpoints
|
||||
|
|
@ -39,7 +40,7 @@ def _available_cards() -> set[str]:
|
|||
return _AVAILABLE_CARDS_CACHE
|
||||
try:
|
||||
import csv
|
||||
path = 'csv_files/cards.csv'
|
||||
path = f"{_csv_dir()}/cards.csv"
|
||||
with open(path, 'r', encoding='utf-8', newline='') as f:
|
||||
reader = csv.DictReader(f)
|
||||
fields = reader.fieldnames or []
|
||||
|
|
@ -2853,6 +2854,16 @@ async def build_permalink(request: Request):
|
|||
},
|
||||
"locks": list(sess.get("locks", [])),
|
||||
}
|
||||
# Optional: random build fields (if present in session)
|
||||
try:
|
||||
rb = sess.get("random_build") or {}
|
||||
if rb:
|
||||
# Only include known keys to avoid leaking unrelated session data
|
||||
inc = {k: rb.get(k) for k in ("seed", "theme", "constraints") if k in rb}
|
||||
if inc:
|
||||
payload["random"] = inc
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add include/exclude cards and advanced options if feature is enabled
|
||||
if ALLOW_MUST_HAVES:
|
||||
|
|
@ -2899,6 +2910,15 @@ async def build_from(request: Request, state: str | None = None) -> HTMLResponse
|
|||
sess["use_owned_only"] = bool(flags.get("owned_only"))
|
||||
sess["prefer_owned"] = bool(flags.get("prefer_owned"))
|
||||
sess["locks"] = list(data.get("locks", []))
|
||||
# Optional random build rehydration
|
||||
try:
|
||||
r = data.get("random") or {}
|
||||
if r:
|
||||
sess["random_build"] = {
|
||||
k: r.get(k) for k in ("seed", "theme", "constraints") if k in r
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Import exclude_cards if feature is enabled and present
|
||||
if ALLOW_MUST_HAVES and data.get("exclude_cards"):
|
||||
|
|
|
|||
|
|
@ -61,7 +61,15 @@
|
|||
el.innerHTML = '<div><strong>Version:</strong> '+String(v)+'</div>'+
|
||||
(st ? '<div><strong>Server time (UTC):</strong> '+String(st)+'</div>' : '')+
|
||||
'<div><strong>Uptime:</strong> '+String(up)+'s</div>'+
|
||||
'<div><strong>Flags:</strong> SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0') +', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0') +', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0') +'</div>';
|
||||
'<div><strong>Flags:</strong> '
|
||||
+ 'SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0')
|
||||
+ ', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0')
|
||||
+ ', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0')
|
||||
+ ', RANDOM_MODES='+ (flags.RANDOM_MODES? '1':'0')
|
||||
+ ', RANDOM_UI='+ (flags.RANDOM_UI? '1':'0')
|
||||
+ ', RANDOM_MAX_ATTEMPTS='+ String(flags.RANDOM_MAX_ATTEMPTS ?? '')
|
||||
+ ', RANDOM_TIMEOUT_MS='+ String(flags.RANDOM_TIMEOUT_MS ?? '')
|
||||
+ '</div>';
|
||||
} catch(_){ el.textContent = 'Unavailable'; }
|
||||
}
|
||||
function load(){
|
||||
|
|
|
|||
12
code/web/templates/partials/random_result.html
Normal file
12
code/web/templates/partials/random_result.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<div class="random-result" hx-swap-oob="true" id="random-result">
|
||||
<div class="random-meta">
|
||||
<span class="seed">Seed: {{ seed }}</span>
|
||||
{% if theme %}<span class="theme">Theme: {{ theme }}</span>{% endif %}
|
||||
</div>
|
||||
<h3 class="commander">{{ commander }}</h3>
|
||||
<ul class="decklist">
|
||||
{% for card in decklist %}
|
||||
<li>{{ card }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue