feat(themes): whitelist governance, synergy cap, docs + tests; feat(random): laid roadwork for random implementation, testing in headless confirmed

This commit is contained in:
matt 2025-09-17 13:23:27 -07:00
parent 03e839fb87
commit 16261bbf09
34 changed files with 12594 additions and 23 deletions

View file

@ -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(

View file

@ -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"):

View file

@ -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(){

View 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>