feat(random): multi-theme groundwork, locked reroll export parity, duplicate export fix, expanded diagnostics and test coverage

This commit is contained in:
matt 2025-09-25 15:14:15 -07:00
parent a029d430c5
commit 73685f22c8
39 changed files with 2671 additions and 271 deletions

View file

@ -12,10 +12,11 @@ import uuid
import logging
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.gzip import GZipMiddleware
from typing import Any
from typing import Any, Optional, Dict
from contextlib import asynccontextmanager
from .services.combo_utils import detect_all as _detect_all
from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
from .services.tasks import get_session, new_sid, set_session_value # type: ignore
# Resolve template/static dirs relative to this file
_THIS_DIR = Path(__file__).resolve().parent
@ -116,6 +117,41 @@ def _as_int(val: str | None, default: int) -> int:
return default
RANDOM_MAX_ATTEMPTS = _as_int(os.getenv("RANDOM_MAX_ATTEMPTS"), 5)
RANDOM_TIMEOUT_MS = _as_int(os.getenv("RANDOM_TIMEOUT_MS"), 5000)
RANDOM_TELEMETRY = _as_bool(os.getenv("RANDOM_TELEMETRY"), False)
RATE_LIMIT_ENABLED = _as_bool(os.getenv("RANDOM_RATE_LIMIT"), False)
RATE_LIMIT_WINDOW_S = _as_int(os.getenv("RATE_LIMIT_WINDOW_S"), 10)
RATE_LIMIT_RANDOM = _as_int(os.getenv("RANDOM_RATE_LIMIT_RANDOM"), 10)
RATE_LIMIT_BUILD = _as_int(os.getenv("RANDOM_RATE_LIMIT_BUILD"), 10)
RATE_LIMIT_SUGGEST = _as_int(os.getenv("RANDOM_RATE_LIMIT_SUGGEST"), 30)
RANDOM_STRUCTURED_LOGS = _as_bool(os.getenv("RANDOM_STRUCTURED_LOGS"), False)
# Simple theme input validation constraints
_THEME_MAX_LEN = 60
_THEME_ALLOWED_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -'_")
def _sanitize_theme(raw: Optional[str]) -> Optional[str]:
"""Return a sanitized theme string or None if invalid.
Rules (minimal by design):
- Strip leading/trailing whitespace
- Reject if empty after strip
- Reject if length > _THEME_MAX_LEN
- Reject if any disallowed character present
"""
if raw is None:
return None
try:
s = str(raw).strip()
except Exception:
return None
if not s:
return None
if len(s) > _THEME_MAX_LEN:
return None
for ch in s:
if ch not in _THEME_ALLOWED_CHARS:
return None
return s
# Theme default from environment: THEME=light|dark|system (case-insensitive). Defaults to system.
_THEME_ENV = (os.getenv("THEME") or "").strip().lower()
@ -157,6 +193,102 @@ def _load_catalog_hash() -> str:
templates.env.globals["catalog_hash"] = _load_catalog_hash()
# --- Optional in-memory telemetry for Random Modes ---
_RANDOM_METRICS: dict[str, dict[str, int]] = {
"build": {"success": 0, "constraints_impossible": 0, "error": 0},
"full_build": {"success": 0, "fallback": 0, "constraints_impossible": 0, "error": 0},
"reroll": {"success": 0, "fallback": 0, "constraints_impossible": 0, "error": 0},
}
def _record_random_event(kind: str, *, success: bool = False, fallback: bool = False, constraints_impossible: bool = False, error: bool = False) -> None:
if not RANDOM_TELEMETRY:
return
try:
k = _RANDOM_METRICS.get(kind)
if not k:
return
if success:
k["success"] = int(k.get("success", 0)) + 1
if fallback:
k["fallback"] = int(k.get("fallback", 0)) + 1
if constraints_impossible:
k["constraints_impossible"] = int(k.get("constraints_impossible", 0)) + 1
if error:
k["error"] = int(k.get("error", 0)) + 1
except Exception:
pass
# --- Optional structured logging for Random Modes ---
def _log_random_event(kind: str, request: Request, status: str, **fields: Any) -> None:
if not RANDOM_STRUCTURED_LOGS:
return
try:
rid = getattr(request.state, "request_id", None)
payload = {
"event": "random_mode",
"kind": kind,
"status": status,
"request_id": rid,
"path": str(request.url.path),
"ip": _client_ip(request),
}
for k, v in (fields or {}).items():
# keep payload concise
if isinstance(v, (str, int, float, bool)) or v is None:
payload[k] = v
logging.getLogger("web.random").info(_json.dumps(payload, separators=(",", ":")))
except Exception:
# Never break a request due to logging
pass
# --- Optional in-memory rate limiting (best-effort, per-IP, per-group) ---
_RL_COUNTS: dict[tuple[str, str, int], int] = {}
def _client_ip(request: Request) -> str:
try:
ip = getattr(getattr(request, "client", None), "host", None) or request.headers.get("X-Forwarded-For")
if isinstance(ip, str) and ip.strip():
# If XFF has multiple, use first
return ip.split(",")[0].strip()
except Exception:
pass
return "unknown"
def rate_limit_check(request: Request, group: str) -> tuple[int, int] | None:
"""Check and increment rate limit for (ip, group).
Returns (remaining, reset_epoch) if enabled, else None.
Raises HTTPException(429) when exceeded.
"""
if not RATE_LIMIT_ENABLED:
return None
limit = 0
if group == "random":
limit = int(RATE_LIMIT_RANDOM)
elif group == "build":
limit = int(RATE_LIMIT_BUILD)
elif group == "suggest":
limit = int(RATE_LIMIT_SUGGEST)
if limit <= 0:
return None
win = max(1, int(RATE_LIMIT_WINDOW_S))
now = int(time.time())
window_id = now // win
reset_epoch = (window_id + 1) * win
key = (_client_ip(request), group, window_id)
count = int(_RL_COUNTS.get(key, 0)) + 1
_RL_COUNTS[key] = count
remaining = max(0, limit - count)
if count > limit:
# Too many
retry_after = max(0, reset_epoch - now)
raise HTTPException(status_code=429, detail="rate_limited", headers={
"Retry-After": str(retry_after),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(reset_epoch),
})
return (remaining, reset_epoch)
# --- Simple fragment cache for template partials (low-risk, TTL-based) ---
_FRAGMENT_CACHE: dict[tuple[str, str], tuple[float, str]] = {}
_FRAGMENT_TTL_SECONDS = 60.0
@ -181,6 +313,61 @@ def render_cached(template_name: str, cache_key: str | None, /, **ctx: Any) -> s
except Exception:
return templates.get_template(template_name).render(**ctx)
# --- Session helpers for Random Modes ---
def _ensure_session(request: Request) -> tuple[str, dict[str, Any], bool]:
"""Get or create a session for the incoming request.
Returns (sid, session_dict, had_existing_cookie)
"""
sid = request.cookies.get("sid")
had_cookie = bool(sid)
if not sid:
sid = new_sid()
sess = get_session(sid)
return sid, sess, had_cookie
def _update_random_session(request: Request, *, seed: int, theme: Any, constraints: Any) -> tuple[str, bool]:
"""Update session with latest random build seed/theme/constraints and maintain a bounded recent list."""
sid, sess, had_cookie = _ensure_session(request)
rb = dict(sess.get("random_build") or {})
rb["seed"] = int(seed)
if theme is not None:
rb["theme"] = theme
if constraints is not None:
rb["constraints"] = constraints
recent = list(rb.get("recent_seeds") or [])
# Append and keep last 10 unique (most-recent-first)
recent.append(int(seed))
# Dedupe while preserving order from the right (most recent)
seen = set()
dedup_rev: list[int] = []
for s in reversed(recent):
if s in seen:
continue
seen.add(s)
dedup_rev.append(s)
dedup = list(reversed(dedup_rev))
rb["recent_seeds"] = dedup[-10:]
set_session_value(sid, "random_build", rb)
return sid, had_cookie
def _toggle_seed_favorite(sid: str, seed: int) -> list[int]:
"""Toggle a seed in the favorites list and persist. Returns updated favorites."""
sess = get_session(sid)
rb = dict(sess.get("random_build") or {})
favs = list(rb.get("favorite_seeds") or [])
if seed in favs:
favs = [s for s in favs if s != seed]
else:
favs.append(seed)
# Keep stable ordering (insertion order) and cap to last 50
favs = favs[-50:]
rb["favorite_seeds"] = favs
set_session_value(sid, "random_build", rb)
return favs
templates.env.globals["render_cached"] = render_cached
# --- Diagnostics: request-id and uptime ---
@ -241,11 +428,29 @@ async def status_sys():
"RANDOM_UI": bool(RANDOM_UI),
"RANDOM_MAX_ATTEMPTS": int(RANDOM_MAX_ATTEMPTS),
"RANDOM_TIMEOUT_MS": int(RANDOM_TIMEOUT_MS),
"RANDOM_TELEMETRY": bool(RANDOM_TELEMETRY),
"RANDOM_STRUCTURED_LOGS": bool(RANDOM_STRUCTURED_LOGS),
"RANDOM_RATE_LIMIT": bool(RATE_LIMIT_ENABLED),
"RATE_LIMIT_WINDOW_S": int(RATE_LIMIT_WINDOW_S),
"RANDOM_RATE_LIMIT_RANDOM": int(RATE_LIMIT_RANDOM),
"RANDOM_RATE_LIMIT_BUILD": int(RATE_LIMIT_BUILD),
"RANDOM_RATE_LIMIT_SUGGEST": int(RATE_LIMIT_SUGGEST),
},
}
except Exception:
return {"version": "unknown", "uptime_seconds": 0, "flags": {}}
@app.get("/status/random_metrics")
async def status_random_metrics():
try:
if not RANDOM_TELEMETRY:
return JSONResponse({"ok": False, "error": "telemetry_disabled"}, status_code=403)
# Return a shallow copy to avoid mutation from clients
out = {k: dict(v) for k, v in _RANDOM_METRICS.items()}
return JSONResponse({"ok": True, "metrics": out})
except Exception:
return JSONResponse({"ok": False, "metrics": {}}, status_code=500)
def random_modes_enabled() -> bool:
"""Dynamic check so tests that set env after import still work.
@ -259,6 +464,9 @@ async def api_random_build(request: Request):
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
try:
t0 = time.time()
# Optional rate limiting (count this request per-IP)
rl = rate_limit_check(request, "build")
body = {}
try:
body = await request.json()
@ -267,6 +475,7 @@ async def api_random_build(request: Request):
except Exception:
body = {}
theme = body.get("theme")
theme = _sanitize_theme(theme)
constraints = body.get("constraints")
seed = body.get("seed")
attempts = body.get("attempts", int(RANDOM_MAX_ATTEMPTS))
@ -277,7 +486,7 @@ async def api_random_build(request: Request):
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
from deck_builder.random_entrypoint import build_random_deck, RandomConstraintsImpossibleError # type: ignore
res = build_random_deck(
theme=theme,
constraints=constraints,
@ -286,7 +495,19 @@ async def api_random_build(request: Request):
timeout_s=float(timeout_s),
)
rid = getattr(request.state, "request_id", None)
return {
_record_random_event("build", success=True)
elapsed_ms = int(round((time.time() - t0) * 1000))
_log_random_event(
"build",
request,
"success",
seed=int(res.seed),
theme=(res.theme or None),
attempts=int(attempts),
timeout_ms=int(timeout_ms),
elapsed_ms=elapsed_ms,
)
payload = {
"seed": int(res.seed),
"commander": res.commander,
"theme": res.theme,
@ -295,10 +516,25 @@ async def api_random_build(request: Request):
"timeout_ms": int(timeout_ms),
"request_id": rid,
}
resp = JSONResponse(payload)
if rl:
remaining, reset_epoch = rl
try:
resp.headers["X-RateLimit-Remaining"] = str(remaining)
resp.headers["X-RateLimit-Reset"] = str(reset_epoch)
except Exception:
pass
return resp
except HTTPException:
raise
except RandomConstraintsImpossibleError as ex:
_record_random_event("build", constraints_impossible=True)
_log_random_event("build", request, "constraints_impossible")
raise HTTPException(status_code=422, detail={"error": "constraints_impossible", "message": str(ex), "constraints": ex.constraints, "pool_size": ex.pool_size})
except Exception as ex:
logging.getLogger("web").error(f"random_build failed: {ex}")
_record_random_event("build", error=True)
_log_random_event("build", request, "error")
raise HTTPException(status_code=500, detail="random_build failed")
@ -308,6 +544,8 @@ async def api_random_full_build(request: Request):
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
try:
t0 = time.time()
rl = rate_limit_check(request, "build")
body = {}
try:
body = await request.json()
@ -316,6 +554,7 @@ async def api_random_full_build(request: Request):
except Exception:
body = {}
theme = body.get("theme")
theme = _sanitize_theme(theme)
constraints = body.get("constraints")
seed = body.get("seed")
attempts = body.get("attempts", int(RANDOM_MAX_ATTEMPTS))
@ -327,7 +566,7 @@ async def api_random_full_build(request: Request):
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
from deck_builder.random_entrypoint import build_random_full_deck, RandomConstraintsImpossibleError # type: ignore
res = build_random_full_deck(
theme=theme,
constraints=constraints,
@ -354,8 +593,23 @@ async def api_random_full_build(request: Request):
except Exception:
permalink = None
# Persist to session (so recent seeds includes initial seed)
sid, had_cookie = _update_random_session(request, seed=int(res.seed), theme=res.theme, constraints=res.constraints or {})
rid = getattr(request.state, "request_id", None)
return {
_record_random_event("full_build", success=True, fallback=bool(getattr(res, "theme_fallback", False)))
elapsed_ms = int(round((time.time() - t0) * 1000))
_log_random_event(
"full_build",
request,
"success",
seed=int(res.seed),
theme=(res.theme or None),
attempts=int(attempts),
timeout_ms=int(timeout_ms),
elapsed_ms=elapsed_ms,
fallback=bool(getattr(res, "theme_fallback", False)),
)
resp = JSONResponse({
"seed": int(res.seed),
"commander": res.commander,
"decklist": res.decklist or [],
@ -364,21 +618,48 @@ async def api_random_full_build(request: Request):
"permalink": permalink,
"attempts": int(attempts),
"timeout_ms": int(timeout_ms),
"diagnostics": res.diagnostics or {},
"fallback": bool(getattr(res, "theme_fallback", False)),
"original_theme": getattr(res, "original_theme", None),
"summary": getattr(res, "summary", None),
"csv_path": getattr(res, "csv_path", None),
"txt_path": getattr(res, "txt_path", None),
"compliance": getattr(res, "compliance", None),
"request_id": rid,
}
})
if rl:
remaining, reset_epoch = rl
try:
resp.headers["X-RateLimit-Remaining"] = str(remaining)
resp.headers["X-RateLimit-Reset"] = str(reset_epoch)
except Exception:
pass
if not had_cookie:
try:
resp.set_cookie("sid", sid, max_age=60*60*8, httponly=True, samesite="lax")
except Exception:
pass
return resp
except HTTPException:
raise
except RandomConstraintsImpossibleError as ex:
_record_random_event("full_build", constraints_impossible=True)
_log_random_event("full_build", request, "constraints_impossible")
raise HTTPException(status_code=422, detail={"error": "constraints_impossible", "message": str(ex), "constraints": ex.constraints, "pool_size": ex.pool_size})
except Exception as ex:
logging.getLogger("web").error(f"random_full_build failed: {ex}")
_record_random_event("full_build", error=True)
_log_random_event("full_build", request, "error")
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_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
try:
t0 = time.time()
rl = rate_limit_check(request, "random")
body = {}
try:
body = await request.json()
@ -387,6 +668,7 @@ async def api_random_reroll(request: Request):
except Exception:
body = {}
theme = body.get("theme")
theme = _sanitize_theme(theme)
constraints = body.get("constraints")
last_seed = body.get("seed")
# Simple deterministic reroll policy: increment prior seed when provided; else generate fresh
@ -431,8 +713,24 @@ async def api_random_reroll(request: Request):
except Exception:
permalink = None
# Persist in session and set sid cookie if we just created it
sid, had_cookie = _update_random_session(request, seed=int(res.seed), theme=res.theme, constraints=res.constraints or {})
rid = getattr(request.state, "request_id", None)
return {
_record_random_event("reroll", success=True, fallback=bool(getattr(res, "theme_fallback", False)))
elapsed_ms = int(round((time.time() - t0) * 1000))
_log_random_event(
"reroll",
request,
"success",
seed=int(res.seed),
theme=(res.theme or None),
attempts=int(attempts),
timeout_ms=int(timeout_ms),
elapsed_ms=elapsed_ms,
prev_seed=(int(last_seed) if isinstance(last_seed, int) or (isinstance(last_seed, str) and str(last_seed).isdigit()) else None),
fallback=bool(getattr(res, "theme_fallback", False)),
)
resp = JSONResponse({
"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,
@ -442,12 +740,29 @@ async def api_random_reroll(request: Request):
"permalink": permalink,
"attempts": int(attempts),
"timeout_ms": int(timeout_ms),
"diagnostics": res.diagnostics or {},
"summary": getattr(res, "summary", None),
"request_id": rid,
}
})
if rl:
remaining, reset_epoch = rl
try:
resp.headers["X-RateLimit-Remaining"] = str(remaining)
resp.headers["X-RateLimit-Reset"] = str(reset_epoch)
except Exception:
pass
if not had_cookie:
try:
resp.set_cookie("sid", sid, max_age=60*60*8, httponly=True, samesite="lax")
except Exception:
pass
return resp
except HTTPException:
raise
except Exception as ex:
logging.getLogger("web").error(f"random_reroll failed: {ex}")
_record_random_event("reroll", error=True)
_log_random_event("reroll", request, "error")
raise HTTPException(status_code=500, detail="random_reroll failed")
@ -456,16 +771,39 @@ 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 = {}
rl = rate_limit_check(request, "random")
body: Dict[str, Any] = {}
raw_text = ""
# Primary: attempt JSON
try:
body = await request.json()
if not isinstance(body, dict):
body = {}
except Exception:
body = {}
# Fallback: form/urlencoded (htmx default) or stray query-like payload
if not body:
try:
raw_bytes = await request.body()
raw_text = raw_bytes.decode("utf-8", errors="ignore")
from urllib.parse import parse_qs
parsed = parse_qs(raw_text, keep_blank_values=True)
flat: Dict[str, Any] = {}
for k, v in parsed.items():
if not v:
continue
flat[k] = v[0] if len(v) == 1 else v
body = flat or {}
except Exception:
body = {}
last_seed = body.get("seed")
mode = body.get("mode") # "surprise" (default) vs "reroll_same_commander"
locked_commander = body.get("commander") if mode == "reroll_same_commander" else None
theme = body.get("theme")
theme = _sanitize_theme(theme)
constraints = body.get("constraints")
attempts_override = body.get("attempts")
timeout_ms_override = body.get("timeout_ms")
try:
new_seed = int(last_seed) + 1 if last_seed is not None else None
except Exception:
@ -473,19 +811,167 @@ async def hx_random_reroll(request: Request):
if new_seed is None:
from random_util import generate_seed # type: ignore
new_seed = int(generate_seed())
# Import outside conditional to avoid UnboundLocalError when branch not taken
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,
)
try:
t0 = time.time()
_attempts = int(attempts_override) if attempts_override is not None else int(RANDOM_MAX_ATTEMPTS)
try:
_timeout_ms = int(timeout_ms_override) if timeout_ms_override is not None else int(RANDOM_TIMEOUT_MS)
except Exception:
_timeout_ms = int(RANDOM_TIMEOUT_MS)
_timeout_s = max(0.1, float(_timeout_ms) / 1000.0)
if locked_commander:
build_t0 = time.time()
from headless_runner import run as _run # type: ignore
# Suppress builder's internal initial export to control artifact generation (matches full random path logic)
try:
import os as _os
if _os.getenv('RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT') is None:
_os.environ['RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT'] = '1'
except Exception:
pass
builder = _run(command_name=str(locked_commander), seed=new_seed)
elapsed_ms = int(round((time.time() - build_t0) * 1000))
summary = None
try:
if hasattr(builder, 'build_deck_summary'):
summary = builder.build_deck_summary() # type: ignore[attr-defined]
except Exception:
summary = None
decklist = []
try:
if hasattr(builder, 'deck_list_final'):
decklist = getattr(builder, 'deck_list_final') # type: ignore[attr-defined]
except Exception:
decklist = []
# Controlled artifact export (single pass)
csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined]
txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined]
compliance = None
try:
import os as _os
import json as _json
# Perform exactly one export sequence now
if not csv_path and hasattr(builder, 'export_decklist_csv'):
try:
csv_path = builder.export_decklist_csv() # type: ignore[attr-defined]
except Exception:
csv_path = None
if csv_path and isinstance(csv_path, str):
base_path, _ = _os.path.splitext(csv_path)
# Ensure txt exists (create if missing)
if (not txt_path or not _os.path.isfile(str(txt_path))):
try:
base_name = _os.path.basename(base_path) + '.txt'
if hasattr(builder, 'export_decklist_text'):
txt_path = builder.export_decklist_text(filename=base_name) # type: ignore[attr-defined]
except Exception:
# Fallback: if a txt already exists from a prior build reuse it
if _os.path.isfile(base_path + '.txt'):
txt_path = base_path + '.txt'
comp_path = base_path + '_compliance.json'
if _os.path.isfile(comp_path):
try:
with open(comp_path, 'r', encoding='utf-8') as _cf:
compliance = _json.load(_cf)
except Exception:
compliance = None
else:
try:
if hasattr(builder, 'compute_and_print_compliance'):
compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined]
except Exception:
compliance = None
if summary:
sidecar = base_path + '.summary.json'
if not _os.path.isfile(sidecar):
meta = {
"commander": getattr(builder, 'commander_name', '') or getattr(builder, 'commander', ''),
"tags": list(getattr(builder, 'selected_tags', []) or []) or [t for t in [getattr(builder, 'primary_tag', None), getattr(builder, 'secondary_tag', None), getattr(builder, 'tertiary_tag', None)] if t],
"bracket_level": getattr(builder, 'bracket_level', None),
"csv": csv_path,
"txt": txt_path,
"random_seed": int(new_seed),
"random_theme": theme,
"random_constraints": constraints or {},
"locked_commander": True,
}
try:
custom_base = getattr(builder, 'custom_export_base', None)
except Exception:
custom_base = None
if isinstance(custom_base, str) and custom_base.strip():
meta["name"] = custom_base.strip()
try:
with open(sidecar, 'w', encoding='utf-8') as f:
_json.dump({"meta": meta, "summary": summary}, f, ensure_ascii=False, indent=2)
except Exception:
pass
except Exception:
compliance = None
class _Res: # minimal object with expected attrs
pass
res = _Res()
res.seed = int(new_seed)
res.commander = locked_commander
res.theme = theme
res.constraints = constraints or {}
res.diagnostics = {"locked_commander": True, "attempts": 1, "elapsed_ms": elapsed_ms}
res.summary = summary
res.decklist = decklist
res.csv_path = csv_path
res.txt_path = txt_path
res.compliance = compliance
else:
res = build_random_full_deck(
theme=theme,
constraints=constraints,
seed=new_seed,
attempts=int(_attempts),
timeout_s=float(_timeout_s),
)
except Exception as ex:
# Map constraints-impossible to a friendly fragment; other errors to a plain note
msg = ""
if ex.__class__.__name__ == "RandomConstraintsImpossibleError":
_record_random_event("reroll", constraints_impossible=True)
_log_random_event("reroll", request, "constraints_impossible")
msg = "<div class=\"error\">Constraints impossible — try loosening filters.</div>"
else:
_record_random_event("reroll", error=True)
_log_random_event("reroll", request, "error")
msg = "<div class=\"error\">Reroll failed. Please try again.</div>"
return HTMLResponse(msg, status_code=200)
# Persist to session
sid, had_cookie = _update_random_session(request, seed=int(res.seed), theme=res.theme, constraints=res.constraints or {})
# Render minimal fragment via Jinja2
try:
return templates.TemplateResponse(
elapsed_ms = int(round((time.time() - t0) * 1000))
_log_random_event(
"reroll",
request,
"success",
seed=int(res.seed),
theme=(res.theme or None),
attempts=int(RANDOM_MAX_ATTEMPTS),
timeout_ms=int(RANDOM_TIMEOUT_MS),
elapsed_ms=elapsed_ms,
)
# Build permalink token for fragment copy button
try:
import base64 as _b64
_raw = _json.dumps({
"commander": res.commander,
"random": {"seed": int(res.seed), "theme": res.theme, "constraints": res.constraints or {}},
}, separators=(",", ":"))
_token = _b64.urlsafe_b64encode(_raw.encode("utf-8")).decode("ascii").rstrip("=")
_permalink = f"/build/from?state={_token}"
except Exception:
_permalink = None
resp = templates.TemplateResponse(
"partials/random_result.html", # type: ignore
{
"request": request,
@ -494,20 +980,91 @@ async def hx_random_reroll(request: Request):
"decklist": res.decklist or [],
"theme": res.theme,
"constraints": res.constraints or {},
"diagnostics": res.diagnostics or {},
"permalink": _permalink,
"show_diagnostics": SHOW_DIAGNOSTICS,
"fallback": bool(getattr(res, "theme_fallback", False)),
"summary": getattr(res, "summary", None),
},
)
if rl:
remaining, reset_epoch = rl
try:
resp.headers["X-RateLimit-Remaining"] = str(remaining)
resp.headers["X-RateLimit-Reset"] = str(reset_epoch)
except Exception:
pass
if not had_cookie:
try:
resp.set_cookie("sid", sid, max_age=60*60*8, httponly=True, samesite="lax")
except Exception:
pass
return resp
except Exception as ex:
logging.getLogger("web").error(f"hx_random_reroll template error: {ex}")
# Fallback to JSON to avoid total failure
return JSONResponse(
resp = JSONResponse(
{
"seed": int(res.seed),
"commander": res.commander,
"decklist": res.decklist or [],
"theme": res.theme,
"constraints": res.constraints or {},
"diagnostics": res.diagnostics or {},
}
)
if not had_cookie:
try:
resp.set_cookie("sid", sid, max_age=60*60*8, httponly=True, samesite="lax")
except Exception:
pass
return resp
@app.get("/api/random/seeds")
async def api_random_recent_seeds(request: Request):
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
sid, sess, _ = _ensure_session(request)
rb = sess.get("random_build") or {}
seeds = list(rb.get("recent_seeds") or [])
last = rb.get("seed")
favorites = list(rb.get("favorite_seeds") or [])
rid = getattr(request.state, "request_id", None)
return {"seeds": seeds, "last": last, "favorites": favorites, "request_id": rid}
@app.post("/api/random/seed_favorite")
async def api_random_seed_favorite(request: Request):
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
sid, sess, _ = _ensure_session(request)
try:
body = await request.json()
if not isinstance(body, dict):
body = {}
except Exception:
body = {}
seed = body.get("seed")
try:
seed_int = int(seed)
except Exception:
raise HTTPException(status_code=400, detail="invalid seed")
favs = _toggle_seed_favorite(sid, seed_int)
rid = getattr(request.state, "request_id", None)
return {"ok": True, "favorites": favs, "request_id": rid}
@app.get("/status/random_metrics_ndjson")
async def status_random_metrics_ndjson():
if not RANDOM_TELEMETRY:
return PlainTextResponse("{}\n", media_type="application/x-ndjson")
lines = []
try:
for kind, buckets in _RANDOM_METRICS.items():
rec = {"kind": kind}
rec.update(buckets)
lines.append(_json.dumps(rec, separators=(",", ":")))
except Exception:
lines.append(_json.dumps({"error": True}))
return PlainTextResponse("\n".join(lines) + "\n", media_type="application/x-ndjson")
# Logs tail endpoint (read-only)
@app.get("/status/logs")
@ -620,18 +1177,35 @@ async def http_exception_handler(request: Request, exc: HTTPException):
# Friendly HTML page
template = "errors/404.html" if exc.status_code == 404 else "errors/4xx.html"
try:
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers={"X-Request-ID": rid})
headers = {"X-Request-ID": rid}
try:
if getattr(exc, "headers", None):
headers.update(exc.headers) # type: ignore[arg-type]
except Exception:
pass
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers=headers)
except Exception:
# Fallback plain text
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers={"X-Request-ID": rid})
headers = {"X-Request-ID": rid}
try:
if getattr(exc, "headers", None):
headers.update(exc.headers) # type: ignore[arg-type]
except Exception:
pass
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers=headers)
# JSON structure for HTMX/API
headers = {"X-Request-ID": rid}
try:
if getattr(exc, "headers", None):
headers.update(exc.headers) # type: ignore[arg-type]
except Exception:
pass
return JSONResponse(status_code=exc.status_code, content={
"error": True,
"status": exc.status_code,
"detail": exc.detail,
"request_id": rid,
"path": str(request.url.path),
}, headers={"X-Request-ID": rid})
}, headers=headers)
# Also handle Starlette's HTTPException (e.g., 404 route not found)
@ -644,16 +1218,34 @@ async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPE
if _wants_html(request):
template = "errors/404.html" if exc.status_code == 404 else "errors/4xx.html"
try:
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers={"X-Request-ID": rid})
headers = {"X-Request-ID": rid}
try:
if getattr(exc, "headers", None):
headers.update(exc.headers) # type: ignore[arg-type]
except Exception:
pass
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers=headers)
except Exception:
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers={"X-Request-ID": rid})
headers = {"X-Request-ID": rid}
try:
if getattr(exc, "headers", None):
headers.update(exc.headers) # type: ignore[arg-type]
except Exception:
pass
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers=headers)
headers = {"X-Request-ID": rid}
try:
if getattr(exc, "headers", None):
headers.update(exc.headers) # type: ignore[arg-type]
except Exception:
pass
return JSONResponse(status_code=exc.status_code, content={
"error": True,
"status": exc.status_code,
"detail": exc.detail,
"request_id": rid,
"path": str(request.url.path),
}, headers={"X-Request-ID": rid})
}, headers=headers)
@app.exception_handler(Exception)
@ -675,6 +1267,13 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
"path": str(request.url.path),
}, headers={"X-Request-ID": rid})
# --- Random Modes page (minimal shell) ---
@app.get("/random", response_class=HTMLResponse)
async def random_modes_page(request: Request) -> HTMLResponse:
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
return templates.TemplateResponse("random/index.html", {"request": request, "random_ui": bool(RANDOM_UI)})
# Lightweight file download endpoint for exports
@app.get("/files")
async def get_file(path: str):

View file

@ -115,6 +115,82 @@ def _load_fast_theme_list() -> Optional[list[dict[str, Any]]]:
return None
@router.get("/suggest")
@router.get("/api/suggest")
async def theme_suggest(
request: Request,
q: str | None = None,
limit: int | None = Query(10, ge=1, le=50),
):
"""Lightweight theme name suggestions for typeahead.
Prefers the precomputed fast path (theme_list.json). Falls back to full index if unavailable.
Returns a compact JSON: {"themes": ["<name>", ...]}.
"""
try:
# Optional rate limit using app helper if available
rl_result = None
try:
from ..app import rate_limit_check # type: ignore
rl_result = rate_limit_check(request, "suggest")
except HTTPException as http_ex: # propagate 429 with headers
raise http_ex
except Exception:
rl_result = None
lim = int(limit or 10)
names: list[str] = []
fast = _load_fast_theme_list()
if fast is not None:
try:
items = fast
if q:
ql = q.lower()
items = [e for e in items if isinstance(e.get("theme"), str) and ql in e["theme"].lower()]
for e in items[: lim * 3]: # pre-slice before unique
nm = e.get("theme")
if isinstance(nm, str):
names.append(nm)
except Exception:
names = []
if not names:
# Fallback to full index
try:
idx = load_index()
slugs = filter_slugs_fast(idx, q=q)
# summaries_for_slugs returns dicts including 'theme'
infos = summaries_for_slugs(idx, slugs[: lim * 3])
for inf in infos:
nm = inf.get("theme")
if isinstance(nm, str):
names.append(nm)
except Exception:
names = []
# Deduplicate preserving order, then clamp
seen: set[str] = set()
out: list[str] = []
for nm in names:
if nm in seen:
continue
seen.add(nm)
out.append(nm)
if len(out) >= lim:
break
resp = JSONResponse({"themes": out})
if rl_result:
remaining, reset_epoch = rl_result
try:
resp.headers["X-RateLimit-Remaining"] = str(remaining)
resp.headers["X-RateLimit-Reset"] = str(reset_epoch)
except Exception:
pass
return resp
except HTTPException as e:
# Propagate FastAPI HTTPException (e.g., 429 with headers)
raise e
except Exception as e:
return JSONResponse({"themes": [], "error": str(e)}, status_code=500)
def _load_tag_flag_time() -> Optional[float]:
try:
if TAG_FLAG_PATH.exists():

View file

@ -83,6 +83,7 @@
<a href="/owned">Owned Library</a>
<a href="/decks">Finished Decks</a>
<a href="/themes/">Themes</a>
{% if random_ui %}<a href="/random">Random</a>{% endif %}
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
</nav>
@ -514,9 +515,12 @@
el.addEventListener('mouseleave', function(){ cardPop.style.display='none'; });
});
}
attachCardHover();
bindAllCardImageRetries();
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
// Expose re-init functions globally for dynamic content
window.attachCardHover = attachCardHover;
window.bindAllCardImageRetries = bindAllCardImageRetries;
attachCardHover();
bindAllCardImageRetries();
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
})();
</script>
<script>
@ -959,10 +963,20 @@
if(!el) return null;
// If inside flip button
var btn = el.closest && el.closest('.dfc-toggle');
if(btn) return btn.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview');
if(el.matches && el.matches('img.card-thumb')) return el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview');
if(btn) return btn.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card');
// Recognized container classes (add .stack-card for finished/random deck thumbnails)
var container = el.closest && el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card');
if(container) return container;
// Image-based detection (any card image carrying data-card-name)
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))){
var up = el.closest && el.closest('.stack-card');
return up || el; // fall back to the image itself
}
// List view spans (deck summary list mode, finished deck list, etc.)
if(el.hasAttribute && el.hasAttribute('data-card-name')) return el;
return null;
}
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; });
document.addEventListener('pointerover', function(e){
var card = getCardFromEl(e.target);
if(!card) return;
@ -987,6 +1001,12 @@
var ev = window.__lastPointerEvent || { clientX: (card.getBoundingClientRect().left+12), clientY: (card.getBoundingClientRect().top+12) };
show(card, ev);
};
window.hoverShowByName = function(name){
try {
var el = document.querySelector('[data-card-name="'+CSS.escape(name)+'"]');
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
} catch(_) {}
};
// Keyboard accessibility & focus traversal (P2 UI Hover keyboard accessibility)
document.addEventListener('focusin', function(e){ var card=e.target.closest && e.target.closest('.card-sample, .commander-cell'); if(card){ show(card, {clientX:card.getBoundingClientRect().left+10, clientY:card.getBoundingClientRect().top+10}); }});
document.addEventListener('focusout', function(e){ var next=e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell'); if(!next) hide(); });

View file

@ -8,6 +8,7 @@
<a class="action-button" href="/owned">Owned Library</a>
<a class="action-button" href="/decks">Finished Decks</a>
<a class="action-button" href="/themes/">Browse Themes</a>
{% if random_ui %}<a class="action-button" href="/random">Random Build</a>{% endif %}
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
</div>
<div id="themes-quick" style="margin-top:1rem; font-size:.85rem; color:var(--text-muted);">

View file

@ -1,70 +1,15 @@
<hr style="margin:1.25rem 0; border-color: var(--border);" />
<h4>Deck Summary</h4>
{% if versions and (versions.combos or versions.synergies) %}
<div class="muted" style="font-size:12px; margin:.1rem 0 .4rem 0;">Combos/Synergies lists: v{{ versions.combos or '?' }} / v{{ versions.synergies or '?' }}</div>
{% endif %}
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
<span>Legend:</span>
<span><span class="game-changer" style="font-weight:600;">Game Changer</span> <span class="muted" style="opacity:.8;">(green highlight)</span></span>
<span><span class="owned-flag" style="margin:0 .25rem 0 .1rem;"></span>Owned • <span class="owned-flag" style="margin:0 .25rem 0 .1rem;"></span>Not owned</span>
</div>
<!-- Detected Combos & Synergies (top) -->
{% if combos or synergies %}
<section style="margin-top:.25rem;">
<h5>Combos & Synergies</h5>
{% if combos %}
<div style="margin:.25rem 0 .5rem 0;">
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected Combos ({{ combos|length }})</div>
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
{% for c in combos %}
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ c.a }}||{{ c.b }}">
<span data-card-name="{{ c.a }}">{{ c.a }}</span>
<span class="muted"> + </span>
<span data-card-name="{{ c.b }}">{{ c.b }}</span>
{% if c.cheap_early or c.setup_dependent %}
<span class="muted" style="margin-left:.4rem; font-size:12px;">
{% if c.cheap_early %}<span title="Cheap/Early" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px;">cheap/early</span>{% endif %}
{% if c.setup_dependent %}<span title="Setup Dependent" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-left:.25rem;">setup</span>{% endif %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if synergies %}
<div style="margin:.25rem 0 .5rem 0;">
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected Synergies ({{ synergies|length }})</div>
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
{% for s in synergies %}
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ s.a }}||{{ s.b }}">
<span data-card-name="{{ s.a }}">{{ s.a }}</span>
<span class="muted"> + </span>
<span data-card-name="{{ s.b }}">{{ s.b }}</span>
{% if s.tags %}
<span class="muted" style="margin-left:.4rem; font-size:12px;">
{% for t in s.tags %}<span style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-right:.25rem;">{{ t }}</span>{% endfor %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</section>
{% endif %}
<!-- Card Type Breakdown with names-only list and hover preview -->
<section style="margin-top:.5rem;">
<h5>Card Types</h5>
<div style="margin:.5rem 0 .25rem 0; display:flex; gap:.5rem; align-items:center;">
<span class="muted">View:</span>
<div class="seg" role="tablist" aria-label="Type view">
<button type="button" class="seg-btn" data-view="list" aria-selected="true">List</button>
<button type="button" class="seg-btn" data-view="thumbs">Thumbnails</button>
<button type="button" class="seg-btn" data-view="list" aria-selected="true" onclick="(function(btn){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(!list||!thumbs)return;list.classList.remove('hidden');thumbs.classList.add('hidden');btn.setAttribute('aria-selected','true');var other=btn.parentElement.querySelector('.seg-btn[data-view=thumbs]');if(other)other.setAttribute('aria-selected','false');try{localStorage.setItem('summaryTypeView','list');}catch(e){}})(this)">List</button>
<button type="button" class="seg-btn" data-view="thumbs" onclick="(function(btn){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(!list||!thumbs)return;list.classList.add('hidden');thumbs.classList.remove('hidden');btn.setAttribute('aria-selected','true');var other=btn.parentElement.querySelector('.seg-btn[data-view=list]');if(other)other.setAttribute('aria-selected','false');try{localStorage.setItem('summaryTypeView','thumbs');}catch(e){}; (function(){var tv=document.getElementById('typeview-thumbs'); if(!tv) return; tv.querySelectorAll('.stack-wrap').forEach(function(sw){var grid=sw.querySelector('.stack-grid'); if(!grid) return; var cs=getComputedStyle(sw); var cardW=parseFloat(cs.getPropertyValue('--card-w'))||160; var gap=10; var width=sw.clientWidth; if(!width||width<cardW){ sw.style.setProperty('--cols','1'); return;} var cols=Math.max(1,Math.floor((width+gap)/(cardW+gap))); sw.style.setProperty('--cols',String(cols));}); })();})(this)">Thumbnails</button>
</div>
</div>
<div style="display:none" hx-on:load="(function(){try{var mode=localStorage.getItem('summaryTypeView')||'list';if(mode==='thumbs'){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(list&&thumbs){list.classList.add('hidden');thumbs.classList.remove('hidden');var lb=document.querySelector('.seg-btn[data-view=list]');var tb=document.querySelector('.seg-btn[data-view=thumbs]');if(lb&&tb){lb.setAttribute('aria-selected','false');tb.setAttribute('aria-selected','true');}thumbs.querySelectorAll('.stack-wrap').forEach(function(sw){var grid=sw.querySelector('.stack-grid');if(!grid)return;var cs=getComputedStyle(sw);var cardW=parseFloat(cs.getPropertyValue('--card-w'))||160;var gap=10;var width=sw.clientWidth;if(!width||width<cardW){sw.style.setProperty('--cols','1');return;}var cols=Math.max(1,Math.floor((width+gap)/(cardW+gap)));sw.style.setProperty('--cols',String(cols));});}}catch(e){}})()"></div>
{% set tb = summary.type_breakdown %}
{% if tb and tb.counts %}
<style>
@ -149,58 +94,7 @@
{% endif %}
</section>
<script>
(function(){
var listBtn = document.querySelector('.seg-btn[data-view="list"]');
var thumbsBtn = document.querySelector('.seg-btn[data-view="thumbs"]');
var listView = document.getElementById('typeview-list');
var thumbsView = document.getElementById('typeview-thumbs');
function recalcThumbCols() {
if (thumbsView.classList.contains('hidden')) return;
var wraps = thumbsView.querySelectorAll('.stack-wrap');
wraps.forEach(function(sw){
var grid = sw.querySelector('.stack-grid');
if (!grid) return;
var gridStyles = window.getComputedStyle(grid);
var gap = parseFloat(gridStyles.columnGap) || 10;
var swStyles = window.getComputedStyle(sw);
var cardW = parseFloat(swStyles.getPropertyValue('--card-w')) || 160;
var width = sw.clientWidth;
if (!width || width < cardW) {
sw.style.setProperty('--cols', '1');
return;
}
var cols = Math.max(1, Math.floor((width + gap) / (cardW + gap)));
sw.style.setProperty('--cols', String(cols));
});
}
function debounce(fn, ms){ var t; return function(){ clearTimeout(t); t = setTimeout(fn, ms); }; }
var debouncedRecalc = debounce(recalcThumbCols, 100);
window.addEventListener('resize', debouncedRecalc);
document.addEventListener('htmx:afterSwap', debouncedRecalc);
function applyMode(mode){
var isList = (mode !== 'thumbs');
listView.classList.toggle('hidden', !isList);
thumbsView.classList.toggle('hidden', isList);
if (listBtn) listBtn.setAttribute('aria-selected', isList ? 'true' : 'false');
if (thumbsBtn) thumbsBtn.setAttribute('aria-selected', isList ? 'false' : 'true');
try { localStorage.setItem('summaryTypeView', mode); } catch(e) {}
if (!isList) recalcThumbCols();
}
if (listBtn && thumbsBtn) {
listBtn.addEventListener('click', function(){ applyMode('list'); });
thumbsBtn.addEventListener('click', function(){ applyMode('thumbs'); });
}
var initial = 'list';
try { initial = localStorage.getItem('summaryTypeView') || 'list'; } catch(e) {}
applyMode(initial);
if (initial === 'thumbs') recalcThumbCols();
})();
</script>
<!-- Deck Summary initializer script moved below markup for proper element availability -->
<!-- Mana Overview Row: Pips • Sources • Curve -->
<section style="margin-top:1rem;">

View file

@ -1,12 +1,70 @@
<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 class="random-result" id="random-result">
<style>
.diag-badges{display:inline-flex; gap:4px; margin-left:8px; flex-wrap:wrap;}
.diag-badge{background:var(--panel-alt,#334155); color:#fff; padding:2px 6px; border-radius:12px; font-size:10px; letter-spacing:.5px; line-height:1.2;}
.diag-badge.warn{background:#8a6d3b;}
.diag-badge.err{background:#7f1d1d;}
.diag-badge.fallback{background:#4f46e5;}
.btn-compact{font-size:11px; padding:2px 6px; line-height:1.2;}
</style>
<div class="random-meta" style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
<span class="seed">Seed: <strong>{{ seed }}</strong></span>
{% if theme %}<span class="theme">Theme: <strong>{{ theme }}</strong></span>{% endif %}
{% if permalink %}
<button class="btn btn-compact" type="button" aria-label="Copy permalink for this exact build" onclick="(async()=>{try{await navigator.clipboard.writeText(location.origin + '{{ permalink }}');(window.toast&&toast('Permalink copied'))||console.log('Permalink copied');}catch(e){alert('Copy failed');}})()">Copy Permalink</button>
{% endif %}
{% if show_diagnostics and diagnostics %}
<span class="diag-badges" aria-label="Diagnostics" role="group">
<span class="diag-badge" title="Attempts tried before acceptance">Att {{ diagnostics.attempts }}</span>
<span class="diag-badge" title="Elapsed build time in milliseconds">{{ diagnostics.elapsed_ms }}ms</span>
{% if diagnostics.timeout_hit %}<span class="diag-badge warn" title="Generation loop exceeded timeout limit before success">Timeout</span>{% endif %}
{% if diagnostics.retries_exhausted %}<span class="diag-badge warn" title="All allotted attempts were used without an early acceptable candidate">Retries</span>{% endif %}
{% if fallback or diagnostics.fallback %}<span class="diag-badge fallback" title="Original theme produced no candidates; Surprise mode fallback engaged">Fallback</span>{% endif %}
</span>
{% endif %}
</div>
<h3 class="commander">{{ commander }}</h3>
<ul class="decklist">
{% for card in decklist %}
<li>{{ card }}</li>
{% endfor %}
</ul>
<!-- Hidden current seed so HTMX reroll button can include it via hx-include -->
<input type="hidden" id="current-seed" name="seed" value="{{ seed }}" />
<input type="hidden" id="current-commander" name="commander" value="{{ commander }}" />
<div class="commander-block" style="display:flex; gap:14px; align-items:flex-start; margin-top:.75rem;">
<div class="commander-thumb" style="flex:0 0 auto;">
<img
src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal 488w"
sizes="(max-width: 600px) 120px, 160px"
alt="{{ commander }} image"
width="160" height="220"
style="width:160px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.55); border:1px solid var(--border); background:#0f1115;"
class="commander-img"
loading="lazy" decoding="async"
data-card-name="{{ commander }}" />
</div>
<div style="flex:1 1 auto;">
<div class="muted" style="font-size:12px; font-weight:600; letter-spacing:.5px; text-transform:uppercase;">Commander</div>
<h3 class="commander" style="margin:.15rem 0 0 0;" data-card-name="{{ commander }}">{{ commander }}</h3>
</div>
</div>
{% if summary %}
{# Reuse the comprehensive deck summary partial #}
{% include "partials/deck_summary.html" %}
{% else %}
<ul class="decklist">
{% for card in decklist %}
{% if card.name %}
<li>{{ card.name }}{% if card.count %} ×{{ card.count }}{% endif %}</li>
{% else %}
<li>{{ card }}</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
<script>
// Re-run bindings after OOB swap so hover & view toggle work consistently
(function(){
try { if (window.bindAllCardImageRetries) window.bindAllCardImageRetries(); } catch(_) {}
try { if (window.attachCardHover) window.attachCardHover(); } catch(_) {}
// Deck summary initializer (idempotent) will assign aria-selected
try { if (window.initDeckSummaryTypeView) window.initDeckSummaryTypeView(document.getElementById('random-result')); } catch(_) {}
})();
</script>
</div>

View file

@ -0,0 +1,274 @@
{% extends "base.html" %}
{% block content %}
{% set enable_ui = random_ui %}
<section id="random-modes" aria-labelledby="random-heading">
<h2 id="random-heading">Random Modes</h2>
{% if not enable_ui %}
<div class="notice" role="status">Random UI is disabled. Set <code>RANDOM_UI=1</code> to enable.</div>
{% else %}
<div class="controls" role="group" aria-label="Random controls" style="display:flex; gap:8px; align-items:center; flex-wrap: wrap;">
<label for="random-theme" class="field-label" style="margin-right:6px;">Theme</label>
<div style="position:relative;">
<input id="random-theme" name="theme" type="text" placeholder="optional (e.g., Tokens)" aria-label="Theme (optional)" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="theme-suggest-box" aria-haspopup="listbox" />
<div id="theme-suggest-box" role="listbox" style="display:none; position:absolute; top:100%; left:0; right:0; background:var(--panel,#1e293b); border:1px solid var(--border,#334155); z-index:20; max-height:220px; overflow-y:auto; box-shadow:0 4px 10px rgba(0,0,0,.4); font-size:13px;">
<!-- suggestions injected here -->
</div>
</div>
{% if show_diagnostics %}
<label for="rand-attempts" style="font-size:11px;">Attempts</label>
<input id="rand-attempts" name="attempts" type="number" min="1" max="25" value="{{ random_max_attempts }}" style="width:60px; font-size:11px;" title="Override max attempts" />
<label for="rand-timeout" style="font-size:11px;">Timeout(ms)</label>
<input id="rand-timeout" name="timeout_ms" type="number" min="100" max="15000" step="100" value="{{ random_timeout_ms }}" style="width:80px; font-size:11px;" title="Override generation timeout in milliseconds" />
{% endif %}
<!-- Added hx-trigger with delay to provide debounce without custom JS recursion -->
<button id="btn-surprise" class="btn" hx-post="/hx/random_reroll" hx-vals='{"mode":"surprise"}' hx-include="#random-theme{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click delay:150ms" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Surprise me">Surprise me</button>
<button id="btn-reroll" class="btn" hx-post="/hx/random_reroll" hx-vals='{"mode":"reroll_same_commander"}' hx-include="#current-seed,#current-commander,#random-theme{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click delay:150ms" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Reroll" disabled>Reroll</button>
<button id="btn-share" class="btn" type="button" aria-label="Copy permalink" onclick="(async ()=>{try{const r=await fetch('/build/permalink'); const j=await r.json(); const url=(j.permalink? location.origin + j.permalink : location.href); await navigator.clipboard.writeText(url); (window.toast && toast('Permalink copied')) || alert('Permalink copied');}catch(e){console.error(e); alert('Failed to copy permalink');}})()">Share</button>
<span id="spinner" role="status" aria-live="polite" style="display:none; margin-left:8px;">Loading…</span>
</div>
<div id="rate-limit-banner" role="status" aria-live="polite" style="display:none; margin-top:8px; padding:6px 8px; border:1px solid #cc9900; background:#fff8e1; color:#5f4200; border-radius:4px;">
Too many requests. Please wait…
</div>
<div id="random-area" style="margin-top:12px;">
<div id="random-result" class="random-result empty" aria-live="polite">Click “Surprise me” to build a deck.</div>
<div id="recent-seeds" style="margin-top:10px; font-size:12px; color:var(--text-muted);">
<button id="btn-load-seeds" class="btn" type="button" style="font-size:11px; padding:2px 6px;">Show Recent Seeds</button>
<button id="btn-metrics" class="btn" type="button" style="font-size:11px; padding:2px 6px;" title="Download NDJSON metrics" {% if not random_modes %}disabled{% endif %}>Metrics</button>
<span id="seed-list" style="margin-left:6px;"></span>
<div id="favorite-seeds" style="margin-top:6px;"></div>
</div>
</div>
<script>
(function(){
// Typeahead: simple debounce + /themes/suggest
var input = document.getElementById('random-theme');
var listBox = document.getElementById('theme-suggest-box');
var to = null;
var cache = new Map(); // simple in-memory cache of q -> [names]
var activeIndex = -1; // keyboard highlight
function hideList(){ if(listBox){ listBox.style.display='none'; input.setAttribute('aria-expanded','false'); activeIndex=-1; } }
function highlight(text, q){
try{ if(!q) return text; var i=text.toLowerCase().indexOf(q.toLowerCase()); if(i===-1) return text; return text.substring(0,i)+'<mark style="background:#4f46e5; color:#fff; padding:0 2px; border-radius:2px;">'+text.substring(i,i+q.length)+'</mark>'+text.substring(i+q.length);}catch(e){return text;}}
function renderList(items, q){
if(!listBox) return; listBox.innerHTML=''; activeIndex=-1;
if(!items || !items.length){ hideList(); return; }
items.slice(0,50).forEach(function(it, idx){
var div=document.createElement('div');
div.setAttribute('role','option');
div.setAttribute('data-value', it);
div.innerHTML=highlight(it, q);
div.style.cssText='padding:4px 8px; cursor:pointer;';
div.addEventListener('mouseenter', function(){ setActive(idx); });
div.addEventListener('mousedown', function(ev){ ev.preventDefault(); pick(it); });
listBox.appendChild(div);
});
listBox.style.display='block';
input.setAttribute('aria-expanded','true');
}
function setActive(idx){
if(!listBox) return; var children=[...listBox.children];
children.forEach(function(c,i){ c.style.background = (i===idx) ? 'rgba(99,102,241,0.35)' : 'transparent'; });
activeIndex = idx;
}
function move(delta){
if(!listBox || listBox.style.display==='none'){ return; }
var children=[...listBox.children]; if(!children.length) return;
var next = activeIndex + delta; if(next < 0) next = children.length -1; if(next >= children.length) next = 0;
setActive(next);
var el = children[next]; if(el && el.scrollIntoView){ el.scrollIntoView({block:'nearest'}); }
}
function pick(value){ input.value = value; hideList(); input.dispatchEvent(new Event('change')); }
function updateList(items, q){ renderList(items, q); }
function showRateLimitBanner(seconds){
var b = document.getElementById('rate-limit-banner');
var btn1 = document.getElementById('btn-surprise');
var btn2 = document.getElementById('btn-reroll');
if(!b){ return; }
var secs = (typeof seconds === 'number' && !isNaN(seconds) && seconds > 0) ? Math.floor(seconds) : null;
var base = 'Too many requests';
var update = function(){
if(secs !== null){ b.textContent = base + ' — try again in ' + secs + 's'; }
else { b.textContent = base + ' — please try again shortly'; }
};
update();
b.style.display = 'block';
if(btn1) btn1.disabled = true; if(btn2) btn2.disabled = true;
if(secs !== null){
var t = setInterval(function(){
secs -= 1; update();
if(secs <= 0){ clearInterval(t); b.style.display = 'none'; if(btn1) btn1.disabled = false; if(btn2) btn2.disabled = false; }
}, 1000);
}
}
function highlightMatch(item, q){
try{
var idx = item.toLowerCase().indexOf(q.toLowerCase());
if(idx === -1) return item;
return item.substring(0,idx) + '[[' + item.substring(idx, idx+q.length) + ']]' + item.substring(idx+q.length);
}catch(e){ return item; }
}
async function fetchSuggest(q){
try{
var u = '/themes/api/suggest' + (q? ('?q=' + encodeURIComponent(q)) : '');
if(cache.has(q)) { updateList(cache.get(q)); return; }
var r = await fetch(u);
if(r.status === 429){
var ra = r.headers.get('Retry-After');
var secs = ra ? parseInt(ra, 10) : null;
var msg = 'You are being rate limited';
if(secs && !isNaN(secs)) msg += ' — retry in ' + secs + 's';
if(window.toast) { toast(msg); } else { console.warn(msg); }
showRateLimitBanner(secs);
return updateList([]);
}
if(!r.ok) return updateList([]);
var j = await r.json();
var items = (j && j.themes) || [];
cache.set(q, items);
// cap cache size to 50
if(cache.size > 50){
var firstKey = cache.keys().next().value; cache.delete(firstKey);
}
updateList(items, q);
}catch(e){ /* no-op */ }
}
if(input){
input.addEventListener('input', function(){
var q = input.value || '';
if(to) clearTimeout(to);
if(!q || q.length < 2){ hideList(); return; }
to = setTimeout(function(){ fetchSuggest(q); }, 150);
});
input.addEventListener('keydown', function(ev){
if(ev.key === 'ArrowDown'){ ev.preventDefault(); move(1); }
else if(ev.key === 'ArrowUp'){ ev.preventDefault(); move(-1); }
else if(ev.key === 'Enter'){ if(activeIndex >=0 && listBox && listBox.children[activeIndex]){ ev.preventDefault(); pick(listBox.children[activeIndex].getAttribute('data-value')); } }
else if(ev.key === 'Escape'){ hideList(); }
});
document.addEventListener('click', function(ev){ if(!listBox) return; if(ev.target === input || listBox.contains(ev.target)){ return; } hideList(); });
}
// Relying on hx-trigger delay (150ms) for soft debounce. Added hx-disabled-elt to avoid rapid spamming.
document.addEventListener('htmx:afterRequest', function(){
// Safety: ensure buttons are always re-enabled after request completes
var b1=document.getElementById('btn-surprise'); var b2=document.getElementById('btn-reroll');
if(b1) b1.disabled=false; if(b2 && document.getElementById('current-seed')) b2.disabled=false;
});
// (No configRequest hook needed; using hx-vals + hx-include for simple form-style submission.)
// Enable reroll once a result exists
document.addEventListener('htmx:afterSwap', function(ev){
if (ev && ev.detail && ev.detail.target && ev.detail.target.id === 'random-result'){
var rr = document.getElementById('btn-reroll'); if (rr) rr.disabled = false;
// Refresh recent seeds asynchronously
fetch('/api/random/seeds').then(r=>r.json()).then(function(j){
try{
if(!j || !j.seeds) return; var span=document.getElementById('seed-list'); if(!span) return;
span.textContent = j.seeds.join(', ');
}catch(e){}
}).catch(function(){});
}
});
// Simple spinner hooks
document.addEventListener('htmx:beforeRequest', function(){ var s=document.getElementById('spinner'); if(s) s.style.display='inline-block'; });
document.addEventListener('htmx:afterRequest', function(){ var s=document.getElementById('spinner'); if(s) s.style.display='none'; });
// HTMX-friendly rate limit message on 429 + countdown banner
document.addEventListener('htmx:afterOnLoad', function(ev){
try{
var xhr = ev && ev.detail && ev.detail.xhr; if(!xhr) return;
if(xhr.status === 429){
var ra = xhr.getResponseHeader('Retry-After');
var secs = ra ? parseInt(ra, 10) : null;
var msg = 'Too many requests';
if(secs && !isNaN(secs)) msg += ' — try again in ' + secs + 's';
if(window.toast) { toast(msg); } else { alert(msg); }
showRateLimitBanner(secs);
}
}catch(e){/* no-op */}
});
function favoriteButton(seed, favorites){
var isFav = favorites.includes(seed);
var b=document.createElement('button');
b.type='button';
b.textContent = isFav ? '★' : '☆';
b.title = isFav ? 'Remove from favorites' : 'Add to favorites';
b.style.cssText='font-size:12px; margin-left:2px; padding:0 4px; line-height:1;';
b.addEventListener('click', function(ev){
ev.stopPropagation();
fetch('/api/random/seed_favorite', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({seed: seed})})
.then(r=>r.json()).then(function(){
// refresh seeds display
loadSeeds(true);
}).catch(()=>{});
});
return b;
}
function renderFavorites(favorites){
var container=document.getElementById('favorite-seeds'); if(!container) return;
if(!favorites || !favorites.length){ container.textContent=''; return; }
container.innerHTML='<span style="margin-right:4px;">Favorites:</span>';
favorites.forEach(function(s){
var btn=document.createElement('button'); btn.type='button'; btn.className='btn'; btn.textContent=s; btn.style.cssText='font-size:10px; margin-right:4px; padding:2px 5px;';
btn.addEventListener('click', function(){
fetch('/hx/random_reroll', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ seed: s-1, theme: document.getElementById('random-theme').value || null }) })
.then(r=>r.text()).then(html=>{ var target=document.getElementById('random-result'); if(target){ target.outerHTML=html; } });
});
container.appendChild(btn);
});
}
function renderSeedList(seeds, favorites){
var span=document.getElementById('seed-list'); if(!span) return;
if(!seeds || !seeds.length){ span.textContent='(none yet)'; return; }
span.innerHTML='';
seeds.slice().forEach(function(s){
var b=document.createElement('button');
b.type='button';
b.textContent=s;
b.className='btn seed-btn';
b.style.cssText='font-size:10px; margin-right:4px; padding:2px 5px;';
b.setAttribute('aria-label','Rebuild using seed '+s);
b.addEventListener('click', function(){
// Post to reroll endpoint but treat as explicit seed build
fetch('/hx/random_reroll', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ seed: s-1, theme: document.getElementById('random-theme').value || null }) })
.then(r=> r.text())
.then(html=>{ var target=document.getElementById('random-result'); if(target){ target.outerHTML=html; } })
.catch(()=>{});
});
span.appendChild(b);
span.appendChild(favoriteButton(s, favorites || []));
});
}
function loadSeeds(refreshFavs){
fetch('/api/random/seeds').then(r=>r.json()).then(function(j){
if(!j){ renderSeedList([]); return; }
renderSeedList(j.seeds || [], j.favorites || []);
if(refreshFavs) renderFavorites(j.favorites || []);
}).catch(function(){ var span=document.getElementById('seed-list'); if(span) span.textContent='(error)'; });
}
// Manual load seeds button
var btnSeeds = document.getElementById('btn-load-seeds');
if(btnSeeds){ btnSeeds.addEventListener('click', function(){ loadSeeds(true); }); }
var btnMetrics = document.getElementById('btn-metrics');
if(btnMetrics){
btnMetrics.addEventListener('click', function(){
fetch('/status/random_metrics_ndjson').then(r=>r.text()).then(function(t){
try{ var blob=new Blob([t], {type:'application/x-ndjson'}); var url=URL.createObjectURL(blob); var a=document.createElement('a'); a.href=url; a.download='random_metrics.ndjson'; document.body.appendChild(a); a.click(); setTimeout(function(){ URL.revokeObjectURL(url); a.remove(); }, 1000);}catch(e){ console.error(e); }
});
});
}
// Persist last used theme in localStorage
try {
var THEME_KEY='random_last_theme';
if(input){
var prev = localStorage.getItem(THEME_KEY);
if(prev && !input.value){ input.value = prev; }
input.addEventListener('change', function(){ localStorage.setItem(THEME_KEY, input.value || ''); });
}
} catch(e) { /* ignore */ }
})();
</script>
{% endif %}
</section>
{% endblock %}