feat(random): finalize multi-theme telemetry and polish
Some checks failed
Editorial Lint / lint-editorial (push) Has been cancelled

- document random theme exclusions, perf guard tooling, and roadmap completion

- tighten random reroll UX: strict theme persistence, throttle handling, export parity, diagnostics updates

- add regression coverage for telemetry counters, multi-theme flows, and locked rerolls; refresh README and notes

Tests: pytest -q (fast random + telemetry suites)
This commit is contained in:
matt 2025-09-26 18:15:52 -07:00
parent 73685f22c8
commit 49f1f8b2eb
28 changed files with 4888 additions and 251 deletions

View file

@ -14,7 +14,17 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased]
### Added
- Tests: added `test_random_reroll_throttle.py` to enforce reroll throttle behavior and `test_random_metrics_and_seed_history.py` to validate opt-in telemetry counters plus seed history exposure.
- Random Mode curated theme pool now documents manual exclusions (`config/random_theme_exclusions.yml`) and ships a reporting script `code/scripts/report_random_theme_pool.py` (`--write-exclusions` emits Markdown/JSON) alongside `docs/random_theme_exclusions.md`. Diagnostics now show manual categories and tag index telemetry.
- Performance guard: `code/scripts/check_random_theme_perf.py` compares the multi-theme profiler output to `config/random_theme_perf_baseline.json` and fails if timings regress beyond configurable thresholds (`--update-baseline` refreshes the file).
- Random Modes UI/API: separate auto-fill controls for Secondary and Tertiary themes with full session, permalink, HTMX, and JSON API support (per-slot state persists across rerolls and exports, and Tertiary auto-fill now automatically enables Secondary to keep combinations valid).
- Random Mode UI gains a lightweight “Clear themes” button that resets all theme inputs and stored preferences in one click for fast Surprise Me reruns.
- Diagnostics: `/status/random_theme_stats` exposes cached commander theme token metrics and the diagnostics dashboard renders indexed commander coverage plus top tokens for multi-theme debugging.
- Random Mode sidecar metadata now records multi-theme details (`primary_theme`, `secondary_theme`, `tertiary_theme`, `resolved_themes`, `combo_fallback`, `synergy_fallback`, `fallback_reason`, plus legacy aliases) in both the summary payload and exported `.summary.json` files.
- Tests: added `test_random_multi_theme_filtering.py` covering triple success, fallback tiers (P+S, P+T, Primary-only, synergy, full pool) and sidecar metadata emission for multi-theme builds.
- Tests: added `test_random_multi_theme_webflows.py` to exercise reroll-same-commander caching and permalink roundtrips for multi-theme runs across HTMX and API layers.
- Random Mode multi-theme groundwork: backend now supports `primary_theme`, `secondary_theme`, `tertiary_theme` with deterministic AND-combination cascade (P+S+T → P+S → P+T → P → synergy-overlap → full pool). Diagnostics fields (`resolved_themes`, `combo_fallback`, `synergy_fallback`, `fallback_reason`) added to `RandomBuildResult` (UI wiring pending).
- Tests: added `test_random_surprise_reroll_behavior.py` covering Surprise Me input preservation and locked commander reroll cache reuse.
- Locked commander reroll path now produces full artifact parity (CSV, TXT, compliance JSON, summary JSON) identical to Surprise builds.
- Random reroll tests for: commander lock invariance, artifact presence, duplicate export prevention, and form vs JSON submission.
- Roadmap document `logs/roadmaps/random_multi_theme_roadmap.md` capturing design, fallback strategy, diagnostics, and incremental delivery plan.
@ -47,10 +57,15 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Optional multi-pass performance CI variant (`preview_perf_ci_check.py --multi-pass`) to collect cold vs warm pass stats when diagnosing divergence.
### Changed
- Random theme pool builder loads manual exclusions and always emits `auto_filled_themes` as a list (empty when unused), while enhanced metadata powers diagnostics telemetry.
- Random build summaries normalize multi-theme metadata before embedding in summary payloads and sidecar exports (trimming whitespace, deduplicating/normalizing resolved theme lists).
- Random Mode strict-theme toggle is now fully stateful: the checkbox and hidden field keep session/local storage in sync, HTMX rerolls reuse the flag, and API/full-build responses plus permalinks carry `strict_theme_match` through exports and sidecars.
- Multi-theme filtering now pre-caches lowercase tag lists and builds a reusable token index so AND-combos and synergy fallback avoid repeated pandas `.apply` passes; profiling via `code/scripts/profile_multi_theme_filter.py` shows mean ~9.3ms / p95 ~21ms for cascade checks (seed 42, 300 iterations).
- Random reroll (locked commander) export flow: now reuses builder-exported artifacts when present and records `last_csv_path` / `last_txt_path` inside the headless runner to avoid duplicate suffixed files.
- Summary sidecars for random builds include `locked_commander` flag when rerolling same commander.
- Splash analytics recognize both static and adaptive penalty reasons (shared prefix handling), so existing dashboards continue to work when `SPLASH_ADAPTIVE=1`.
- Random full builds now internally force `RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT=1` (if unset) ensuring only the orchestrated export path executes (eliminates historical duplicate `*_1.csv` / `*_1.txt`). Set `RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT=0` to intentionally restore the legacy double-export (not recommended outside debugging).
- Multi-theme Random UI polish: fallback notices now surface high-contrast icons, focus outlines, and aria-friendly copy; diagnostics badges gain icons/labels; help tooltip converted to an accessible popover with keyboard support; Secondary/Tertiary inputs persist across sessions.
- Picker list & API use optimized fast filtering path (`filter_slugs_fast`) replacing per-request linear scans.
- Preview sampling: curated examples pinned first, diversity quotas (~40% payoff / 40% enabler+support / 20% wildcard), synthetic placeholders only if underfilled.
- Sampling refinements: rarity diminishing weight, splash leniency (single off-color allowance with penalty for 45 color commanders), role saturation penalty, refined commander overlap scaling curve.
@ -63,6 +78,7 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Performance gating formalized: CI fails if warm p95 regression > configured threshold (default 5%). Baseline refresh policy: only update committed warm baseline when (a) intentional performance improvement >10% p95, or (b) unavoidable drift exceeds threshold and is justified in CHANGELOG entry.
### Fixed
- Random UI Surprise Me rerolls now keep user-supplied theme inputs instead of adopting fallback combinations, and reroll-same-commander builds reuse cached resolved themes without re-running the filter cascade.
- Removed redundant template environment instantiation causing inconsistent navigation state.
- Ensured preview cache key includes catalog ETag to prevent stale sample reuse after catalog reload.
- Explicit cache bust after tagging/catalog rebuild prevents stale preview exposure.

BIN
README.md

Binary file not shown.

View file

@ -3,28 +3,20 @@
## Unreleased (Draft)
### Added
- Random Mode multi-theme groundwork: backend accepts `primary_theme`, `secondary_theme`, `tertiary_theme` and computes a resolved combination with ordered fallback (triple → P+S → P+T → P → synergy token overlap → full pool). Exposes diagnostics (`resolved_themes`, `combo_fallback`, `synergy_fallback`, `fallback_reason`) for upcoming UI integration.
- Locked commander reroll now outputs the full export artifact set (CSV, TXT, compliance, summary) with duplicate prevention.
- Taxonomy snapshot utility (`python -m code.scripts.snapshot_taxonomy`): captures an auditable JSON of BRACKET_DEFINITIONS under `logs/taxonomy_snapshots/` with a content hash. Safe to run any time; subsequent identical snapshots are skipped.
- Random Full Build export parity: random full builds now emit the full artifact set (`.csv`, `.txt`, `_compliance.json`, `.summary.json`) matching standard builds; API includes `csv_path`, `txt_path`, and `compliance` path fields.
- Opt-out env var `RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT` (defaults to suppressed) allows re-enabling legacy double-export for debugging when set to `0`.
- Optional adaptive splash penalty (experiment): enable with `SPLASH_ADAPTIVE=1`; scale per commander color count with `SPLASH_ADAPTIVE_SCALE` (default `1:1.0,2:1.0,3:1.0,4:0.6,5:0.35`). Reasons are emitted as `splash_off_color_penalty_adaptive:<colors>:<value>`.
- Tests: added `test_random_reroll_throttle.py` to guard reroll throttle behavior and `test_random_metrics_and_seed_history.py` to verify opt-in telemetry counters and seed history API output.
- Analytics: splash penalty counters recognize both static and adaptive reasons; compare deltas with the flag toggled.
- Theme picker performance: precomputed summary projections + lowercase haystacks and memoized filtered slug cache (keyed by (etag, q, archetype, bucket, colors)) for sub50ms typical list queries on warm path.
- Skeleton loading UI for theme picker list, preview modal, and initial shell.
- Theme preview endpoint (`/themes/api/theme/{id}/preview` + HTML fragment) returning representative sample with roles (payoff/enabler/support/wildcard/example/curated_synergy/synthetic).
- Commander bias heuristics in preview sampling (color identity filtering + overlap/theme bonuses) for context-aware suggestions.
- Inmemory TTL (600s) preview cache with metrics (requests, cache hits, average build ms) exposed at diagnostics endpoint.
- Web UI: Double-faced card (DFC) hover support with single-image overlay flip control (top-left button, keyboard (Enter/Space/F), aria-live), persisted face (localStorage), and immediate refresh post-flip.
- Diagnostics flag `WEB_THEME_PICKER_DIAGNOSTICS=1` gating fallback description flag, editorial quality badges, uncapped synergy lists, raw YAML fetch, and metrics endpoint (`/themes/metrics`).
- Catalog & preview metrics endpoint combining filter + preview counters & cache stats.
- Performance headers on list & API responses: `X-ThemeCatalog-Filter-Duration-ms` and `ETag` for conditional requests.
- Random Mode curated pool now loads manual exclusions (`config/random_theme_exclusions.yml`), includes reporting helpers (`code/scripts/report_random_theme_pool.py --write-exclusions`), and ships documentation (`docs/random_theme_exclusions.md`). Diagnostics cards show manual categories and tag index telemetry.
- Added `code/scripts/check_random_theme_perf.py` guard that compares the multi-theme profiler (`code/scripts/profile_multi_theme_filter.py`) against `config/random_theme_perf_baseline.json` with optional `--update-baseline`.
- Random Mode UI adds a “Clear themes” control that resets Primary/Secondary/Tertiary inputs plus local persistence in a single click.
- Diagnostics: Added `/status/random_theme_stats` and a diagnostics dashboard card surfacing commander/theme token coverage and top tokens for multi-theme debugging.
- Cache bust hooks tied to catalog refresh & tagging completion clear filter/preview caches (metrics now include last bust timestamps).
- Governance metrics: `example_enforcement_active`, `example_enforce_threshold_pct` (threshold default 90%) signal when curated coverage enforcement is active.
- Server authoritative mana & color identity fields (`mana_cost`, `color_identity_list`, `pip_colors`) included in preview/export; legacy client parsers removed.
### Changed
- Random reroll export logic deduplicated by persisting `last_csv_path` / `last_txt_path` from headless runs; avoids creation of `*_1` suffixed artifacts on reroll.
### Added
- Tests: added `test_random_multi_theme_webflows.py` validating reroll-same-commander caching and permalink roundtrips for multi-theme runs across HTMX and API layers.
- Multi-theme filtering now reuses a cached lowercase tag column and builds a reusable token index so combination checks and synergy fallback avoid repeated pandas `.apply` passes; new script `code/scripts/profile_multi_theme_filter.py` reports mean ~9.3ms / p95 ~21ms cascade timings on the current catalog (seed 42, 300 iterations).
- Splash analytics updated to count both static and adaptive penalty reasons via a shared prefix, keeping historical dashboards intact.
- Random full builds internally auto-set `RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT=1` (unless explicitly provided) to eliminate duplicate suffixed decklists.
- Preview assembly now pins curated `example_cards` then `synergy_example_cards` before heuristic sampling with diversity quotas (~40% payoff, 40% enabler/support, 20% wildcard) and synthetic placeholders only when underfilled.
@ -45,6 +37,7 @@
### Added
- Theme whitelist governance (`config/themes/theme_whitelist.yml`) with normalization, enforced synergies, and synergy cap (5).
- Expanded curated synergy matrix plus PMI-based inferred synergies (data-driven) blended with curated anchors.
- Random UI polish: fallback notices gain accessible icons, focus outlines, and aria copy; diagnostics badges now include icons/labels; the theme help tooltip is an accessible popover with keyboard controls; secondary/tertiary theme inputs persist via localStorage so repeat builds start with previous choices.
- Test: `test_theme_whitelist_and_synergy_cap.py` validates enforced synergy presence and cap compliance.
- PyYAML dependency for governance parsing.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,118 @@
"""Opt-in guard that compares multi-theme filter performance to a stored baseline.
Run inside the project virtual environment:
python -m code.scripts.check_random_theme_perf --baseline config/random_theme_perf_baseline.json
The script executes the same profiling loop as `profile_multi_theme_filter` and fails
if the observed mean or p95 timings regress more than the allowed threshold.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, Tuple
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_BASELINE = PROJECT_ROOT / "config" / "random_theme_perf_baseline.json"
if str(PROJECT_ROOT) not in sys.path:
sys.path.append(str(PROJECT_ROOT))
from code.scripts.profile_multi_theme_filter import run_profile # type: ignore # noqa: E402
def _load_baseline(path: Path) -> Dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"Baseline file not found: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
return data
def _extract(metric: Dict[str, Any], key: str) -> float:
try:
value = float(metric.get(key, 0.0))
except Exception:
value = 0.0
return value
def _check_section(name: str, actual: Dict[str, Any], baseline: Dict[str, Any], threshold: float) -> Tuple[bool, str]:
a_mean = _extract(actual, "mean_ms")
b_mean = _extract(baseline, "mean_ms")
a_p95 = _extract(actual, "p95_ms")
b_p95 = _extract(baseline, "p95_ms")
allowed_mean = b_mean * (1.0 + threshold)
allowed_p95 = b_p95 * (1.0 + threshold)
mean_ok = a_mean <= allowed_mean or b_mean == 0.0
p95_ok = a_p95 <= allowed_p95 or b_p95 == 0.0
status = mean_ok and p95_ok
def _format_row(label: str, actual_val: float, baseline_val: float, allowed_val: float, ok: bool) -> str:
trend = ((actual_val - baseline_val) / baseline_val * 100.0) if baseline_val else 0.0
trend_str = f"{trend:+.1f}%" if baseline_val else "n/a"
limit_str = f"{allowed_val:.3f}ms" if baseline_val else "n/a"
return f" {label:<6} actual={actual_val:.3f}ms baseline={baseline_val:.3f}ms ({trend_str}), limit {limit_str} -> {'OK' if ok else 'FAIL'}"
rows = [f"Section: {name}"]
rows.append(_format_row("mean", a_mean, b_mean, allowed_mean, mean_ok))
rows.append(_format_row("p95", a_p95, b_p95, allowed_p95, p95_ok))
return status, "\n".join(rows)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Check multi-theme filtering performance against a baseline")
parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE, help="Baseline JSON file (default: config/random_theme_perf_baseline.json)")
parser.add_argument("--iterations", type=int, default=400, help="Number of iterations to sample (default: 400)")
parser.add_argument("--seed", type=int, default=None, help="Optional RNG seed for reproducibility")
parser.add_argument("--threshold", type=float, default=0.15, help="Allowed regression threshold as a fraction (default: 0.15 = 15%)")
parser.add_argument("--update-baseline", action="store_true", help="Overwrite the baseline file with the newly collected metrics")
args = parser.parse_args(argv)
baseline_path = args.baseline if args.baseline else DEFAULT_BASELINE
if args.update_baseline and not baseline_path.parent.exists():
baseline_path.parent.mkdir(parents=True, exist_ok=True)
if not args.update_baseline:
baseline = _load_baseline(baseline_path)
else:
baseline = {}
results = run_profile(args.iterations, args.seed)
cascade_status, cascade_report = _check_section("cascade", results.get("cascade", {}), baseline.get("cascade", {}), args.threshold)
synergy_status, synergy_report = _check_section("synergy", results.get("synergy", {}), baseline.get("synergy", {}), args.threshold)
print("Iterations:", results.get("iterations"))
print("Seed:", results.get("seed"))
print(cascade_report)
print(synergy_report)
overall_ok = cascade_status and synergy_status
if args.update_baseline:
payload = {
"iterations": results.get("iterations"),
"seed": results.get("seed"),
"cascade": results.get("cascade"),
"synergy": results.get("synergy"),
}
baseline_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(f"Baseline updated → {baseline_path}")
return 0
if not overall_ok:
print(f"FAIL: performance regressions exceeded {args.threshold * 100:.1f}% threshold", file=sys.stderr)
return 1
print("PASS: performance within allowed threshold")
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())

View file

@ -0,0 +1,136 @@
"""Profile helper for multi-theme commander filtering.
Run within the project virtual environment:
python code/scripts/profile_multi_theme_filter.py --iterations 500
Outputs aggregate timing for combination and synergy fallback scenarios.
"""
from __future__ import annotations
import argparse
import json
import statistics
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Tuple
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.append(str(PROJECT_ROOT))
from deck_builder.random_entrypoint import _ensure_theme_tag_cache, _filter_multi, _load_commanders_df # noqa: E402
def _sample_combinations(tags: List[str], iterations: int) -> List[Tuple[str | None, str | None, str | None]]:
import random
combos: List[Tuple[str | None, str | None, str | None]] = []
if not tags:
return combos
for _ in range(iterations):
primary = random.choice(tags)
secondary = random.choice(tags) if random.random() < 0.45 else None
tertiary = random.choice(tags) if random.random() < 0.25 else None
combos.append((primary, secondary, tertiary))
return combos
def _collect_tag_pool(df: pd.DataFrame) -> List[str]:
tag_pool: set[str] = set()
for tags in df.get("_ltags", []): # type: ignore[assignment]
if not tags:
continue
for token in tags:
tag_pool.add(token)
return sorted(tag_pool)
def _summarize(values: List[float]) -> Dict[str, float]:
mean_ms = statistics.mean(values) * 1000
if len(values) >= 20:
p95_ms = statistics.quantiles(values, n=20)[18] * 1000
else:
p95_ms = max(values) * 1000 if values else 0.0
return {
"mean_ms": round(mean_ms, 6),
"p95_ms": round(p95_ms, 6),
"samples": len(values),
}
def run_profile(iterations: int, seed: int | None = None) -> Dict[str, Any]:
if iterations <= 0:
raise ValueError("Iterations must be a positive integer")
df = _load_commanders_df()
df = _ensure_theme_tag_cache(df)
tag_pool = _collect_tag_pool(df)
if not tag_pool:
raise RuntimeError("No theme tags available in dataset; ensure commander catalog is populated")
combos = _sample_combinations(tag_pool, iterations)
if not combos:
raise RuntimeError("Failed to generate theme combinations for profiling")
timings: List[float] = []
synergy_timings: List[float] = []
for primary, secondary, tertiary in combos:
start = time.perf_counter()
_filter_multi(df, primary, secondary, tertiary)
timings.append(time.perf_counter() - start)
improbable_primary = f"{primary or 'aggro'}_unlikely_value"
start_synergy = time.perf_counter()
_filter_multi(df, improbable_primary, secondary, tertiary)
synergy_timings.append(time.perf_counter() - start_synergy)
return {
"iterations": iterations,
"seed": seed,
"cascade": _summarize(timings),
"synergy": _summarize(synergy_timings),
}
def main() -> None:
parser = argparse.ArgumentParser(description="Profile multi-theme filtering performance")
parser.add_argument("--iterations", type=int, default=400, help="Number of random theme combinations to evaluate")
parser.add_argument("--seed", type=int, default=None, help="Optional RNG seed for repeatability")
parser.add_argument("--json", type=Path, help="Optional path to write the raw metrics as JSON")
args = parser.parse_args()
if args.seed is not None:
import random
random.seed(args.seed)
results = run_profile(args.iterations, args.seed)
def _print(label: str, stats: Dict[str, float]) -> None:
mean_ms = stats.get("mean_ms", 0.0)
p95_ms = stats.get("p95_ms", 0.0)
samples = stats.get("samples", 0)
print(f"{label}: mean={mean_ms:.4f}ms p95={p95_ms:.4f}ms (n={samples})")
_print("AND-combo cascade", results.get("cascade", {}))
_print("Synergy fallback", results.get("synergy", {}))
if args.json:
payload = {
"iterations": results.get("iterations"),
"seed": results.get("seed"),
"cascade": results.get("cascade"),
"synergy": results.get("synergy"),
}
args.json.parent.mkdir(parents=True, exist_ok=True)
args.json.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,193 @@
"""Summarize the curated random theme pool and exclusion rules.
Usage examples:
python -m code.scripts.report_random_theme_pool --format markdown
python -m code.scripts.report_random_theme_pool --output logs/random_theme_pool.json
The script refreshes the commander catalog, rebuilds the curated random
pool using the same heuristics as Random Mode auto-fill, and prints a
summary (JSON by default).
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, List
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.append(str(PROJECT_ROOT))
from deck_builder.random_entrypoint import ( # type: ignore # noqa: E402
_build_random_theme_pool,
_ensure_theme_tag_cache,
_load_commanders_df,
_OVERREPRESENTED_SHARE_THRESHOLD,
)
def build_report(refresh: bool = False) -> Dict[str, Any]:
df = _load_commanders_df()
if refresh:
# Force re-cache of tag structures
df = _ensure_theme_tag_cache(df)
else:
try:
df = _ensure_theme_tag_cache(df)
except Exception:
pass
allowed, metadata = _build_random_theme_pool(df, include_details=True)
detail = metadata.pop("excluded_detail", {})
report = {
"allowed_tokens": sorted(allowed),
"allowed_count": len(allowed),
"metadata": metadata,
"excluded_detail": detail,
}
return report
def format_markdown(report: Dict[str, Any], *, limit: int = 20) -> str:
lines: List[str] = []
meta = report.get("metadata", {})
rules = meta.get("rules", {})
lines.append("# Curated Random Theme Pool")
lines.append("")
lines.append(f"- Allowed tokens: **{report.get('allowed_count', 0)}**")
total_commander_count = meta.get("total_commander_count")
if total_commander_count is not None:
lines.append(f"- Commander entries analyzed: **{total_commander_count}**")
coverage = meta.get("coverage_ratio")
if coverage is not None:
pct = round(float(coverage) * 100.0, 2)
lines.append(f"- Coverage: **{pct}%** of catalog tokens")
if rules:
thresh = rules.get("overrepresented_share_threshold", _OVERREPRESENTED_SHARE_THRESHOLD)
thresh_pct = round(float(thresh) * 100.0, 2)
lines.append("- Exclusion rules:")
lines.append(" - Minimum commander coverage: 5 unique commanders")
lines.append(f" - Kindred filter keywords: {', '.join(rules.get('kindred_keywords', []))}")
lines.append(f" - Global theme keywords: {', '.join(rules.get('excluded_keywords', []))}")
pattern_str = ", ".join(rules.get("excluded_patterns", []))
if pattern_str:
lines.append(f" - Global theme patterns: {pattern_str}")
lines.append(f" - Over-represented threshold: ≥ {thresh_pct}% of commanders")
manual_src = rules.get("manual_exclusions_source")
manual_groups = rules.get("manual_exclusions") or []
if manual_src or manual_groups:
lines.append(f" - Manual exclusion config: {manual_src or 'config/random_theme_exclusions.yml'}")
if manual_groups:
lines.append(f" - Manual categories: {len(manual_groups)} tracked groups")
counts = meta.get("excluded_counts", {}) or {}
if counts:
lines.append("")
lines.append("## Excluded tokens by reason")
lines.append("Reason | Count")
lines.append("------ | -----")
for reason, count in sorted(counts.items(), key=lambda item: item[0]):
lines.append(f"{reason} | {count}")
samples = meta.get("excluded_samples", {}) or {}
if samples:
lines.append("")
lines.append("## Sample tokens per exclusion reason")
for reason, tokens in sorted(samples.items(), key=lambda item: item[0]):
subset = tokens[:limit]
more = "" if len(tokens) <= limit else f" … (+{len(tokens) - limit})"
lines.append(f"- **{reason}**: {', '.join(subset)}{more}")
detail = report.get("excluded_detail", {}) or {}
if detail:
lines.append("")
lines.append("## Detailed exclusions (first few)")
for token, reasons in list(sorted(detail.items()))[:limit]:
lines.append(f"- {token}: {', '.join(reasons)}")
if len(detail) > limit:
lines.append(f"… (+{len(detail) - limit} more tokens)")
manual_detail = meta.get("manual_exclusion_detail", {}) or {}
if manual_detail:
lines.append("")
lines.append("## Manual exclusions applied")
for token, info in sorted(manual_detail.items(), key=lambda item: item[0]):
display = info.get("display", token)
category = info.get("category")
summary = info.get("summary")
notes = info.get("notes")
descriptors: List[str] = []
if category:
descriptors.append(f"category={category}")
if summary:
descriptors.append(summary)
if notes:
descriptors.append(notes)
suffix = f"{'; '.join(descriptors)}" if descriptors else ""
lines.append(f"- {display}{suffix}")
if rules.get("manual_exclusions"):
lines.append("")
lines.append("## Manual exclusion categories")
for group in rules["manual_exclusions"]:
if not isinstance(group, dict):
continue
category = group.get("category", "manual")
summary = group.get("summary")
tokens = group.get("tokens", []) or []
notes = group.get("notes")
lines.append(f"- **{category}** — {summary or 'no summary provided'}")
if notes:
lines.append(f" - Notes: {notes}")
if tokens:
token_list = tokens[:limit]
more = "" if len(tokens) <= limit else f" … (+{len(tokens) - limit})"
lines.append(f" - Tokens: {', '.join(token_list)}{more}")
return "\n".join(lines)
def write_output(path: Path, payload: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\n")
def write_manual_exclusions(path: Path, report: Dict[str, Any]) -> None:
meta = report.get("metadata", {}) or {}
rules = meta.get("rules", {}) or {}
detail = meta.get("manual_exclusion_detail", {}) or {}
payload = {
"source": rules.get("manual_exclusions_source"),
"categories": rules.get("manual_exclusions", []),
"tokens": detail,
}
write_output(path, payload)
def main(argv: List[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Report the curated random theme pool heuristics")
parser.add_argument("--format", choices={"json", "markdown"}, default="json", help="Output format (default: json)")
parser.add_argument("--output", type=Path, help="Optional path to write the structured report (JSON regardless of --format)")
parser.add_argument("--limit", type=int, default=20, help="Max sample tokens per reason when printing markdown (default: 20)")
parser.add_argument("--refresh", action="store_true", help="Bypass caches when rebuilding commander stats")
parser.add_argument("--write-exclusions", type=Path, help="Optional path for writing manual exclusion tokens + metadata (JSON)")
args = parser.parse_args(argv)
report = build_report(refresh=args.refresh)
if args.output:
write_output(args.output, report)
if args.write_exclusions:
write_manual_exclusions(args.write_exclusions, report)
if args.format == "markdown":
print(format_markdown(report, limit=max(1, args.limit)))
else:
print(json.dumps(report, indent=2, sort_keys=True))
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())

View file

@ -11,6 +11,7 @@ def test_random_build_api_commander_and_seed(monkeypatch):
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
app_module = importlib.import_module('code.web.app')
app_module = importlib.reload(app_module)
client = TestClient(app_module.app)
payload = {"seed": 12345, "theme": "Goblin Kindred"}
@ -20,3 +21,122 @@ def test_random_build_api_commander_and_seed(monkeypatch):
assert data["seed"] == 12345
assert isinstance(data.get("commander"), str)
assert data.get("commander")
assert "auto_fill_enabled" in data
assert "auto_fill_secondary_enabled" in data
assert "auto_fill_tertiary_enabled" in data
assert "auto_fill_applied" in data
assert "auto_filled_themes" in data
assert "display_themes" in data
def test_random_build_api_auto_fill_toggle(monkeypatch):
monkeypatch.setenv("RANDOM_MODES", "1")
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
payload = {"seed": 54321, "primary_theme": "Aggro", "auto_fill_enabled": True}
r = client.post('/api/random_build', json=payload)
assert r.status_code == 200, r.text
data = r.json()
assert data["seed"] == 54321
assert data.get("auto_fill_enabled") is True
assert data.get("auto_fill_secondary_enabled") is True
assert data.get("auto_fill_tertiary_enabled") is True
assert data.get("auto_fill_applied") in (True, False)
assert isinstance(data.get("auto_filled_themes"), list)
assert isinstance(data.get("display_themes"), list)
def test_random_build_api_partial_auto_fill(monkeypatch):
monkeypatch.setenv("RANDOM_MODES", "1")
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
payload = {
"seed": 98765,
"primary_theme": "Aggro",
"auto_fill_secondary_enabled": True,
"auto_fill_tertiary_enabled": False,
}
r = client.post('/api/random_build', json=payload)
assert r.status_code == 200, r.text
data = r.json()
assert data["seed"] == 98765
assert data.get("auto_fill_enabled") is True
assert data.get("auto_fill_secondary_enabled") is True
assert data.get("auto_fill_tertiary_enabled") is False
assert data.get("auto_fill_applied") in (True, False)
assert isinstance(data.get("auto_filled_themes"), list)
def test_random_build_api_tertiary_requires_secondary(monkeypatch):
monkeypatch.setenv("RANDOM_MODES", "1")
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
payload = {
"seed": 192837,
"primary_theme": "Aggro",
"auto_fill_secondary_enabled": False,
"auto_fill_tertiary_enabled": True,
}
r = client.post('/api/random_build', json=payload)
assert r.status_code == 200, r.text
data = r.json()
assert data["seed"] == 192837
assert data.get("auto_fill_enabled") is True
assert data.get("auto_fill_secondary_enabled") is True
assert data.get("auto_fill_tertiary_enabled") is True
assert data.get("auto_fill_applied") in (True, False)
assert isinstance(data.get("auto_filled_themes"), list)
def test_random_build_api_reports_auto_filled_themes(monkeypatch):
monkeypatch.setenv("RANDOM_MODES", "1")
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
import code.web.app as app_module
import code.deck_builder.random_entrypoint as random_entrypoint
import deck_builder.random_entrypoint as random_entrypoint_pkg
def fake_auto_fill(
df,
commander,
rng,
*,
primary_theme,
secondary_theme,
tertiary_theme,
allowed_pool,
fill_secondary,
fill_tertiary,
):
return "Tokens", "Sacrifice", ["Tokens", "Sacrifice"]
monkeypatch.setattr(random_entrypoint, "_auto_fill_missing_themes", fake_auto_fill)
monkeypatch.setattr(random_entrypoint_pkg, "_auto_fill_missing_themes", fake_auto_fill)
client = TestClient(app_module.app)
payload = {
"seed": 654321,
"primary_theme": "Aggro",
"auto_fill_enabled": True,
"auto_fill_secondary_enabled": True,
"auto_fill_tertiary_enabled": True,
}
r = client.post('/api/random_build', json=payload)
assert r.status_code == 200, r.text
data = r.json()
assert data["seed"] == 654321
assert data.get("auto_fill_enabled") is True
assert data.get("auto_fill_applied") is True
assert data.get("auto_fill_secondary_enabled") is True
assert data.get("auto_fill_tertiary_enabled") is True
assert data.get("auto_filled_themes") == ["Tokens", "Sacrifice"]

View file

@ -1,32 +1,66 @@
from __future__ import annotations
import os
from fastapi.testclient import TestClient
def test_metrics_and_seed_history(monkeypatch):
monkeypatch.setenv('RANDOM_MODES', '1')
monkeypatch.setenv('RANDOM_UI', '1')
monkeypatch.setenv('RANDOM_TELEMETRY', '1')
monkeypatch.setenv('CSV_FILES_DIR', os.path.join('csv_files', 'testdata'))
from code.web.app import app
client = TestClient(app)
monkeypatch.setenv("RANDOM_MODES", "1")
monkeypatch.setenv("RANDOM_UI", "1")
monkeypatch.setenv("RANDOM_TELEMETRY", "1")
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
# Build + reroll to generate metrics and seed history
r1 = client.post('/api/random_full_build', json={'seed': 9090})
assert r1.status_code == 200, r1.text
r2 = client.post('/api/random_reroll', json={'seed': 9090})
assert r2.status_code == 200, r2.text
import code.web.app as app_module
# Metrics
m = client.get('/status/random_metrics')
assert m.status_code == 200, m.text
mj = m.json()
assert mj.get('ok') is True
metrics = mj.get('metrics') or {}
assert 'full_build' in metrics and 'reroll' in metrics
# Reset in-memory telemetry so assertions are deterministic
app_module.RANDOM_TELEMETRY = True
app_module.RATE_LIMIT_ENABLED = False
for bucket in app_module._RANDOM_METRICS.values():
for key in bucket:
bucket[key] = 0
for key in list(app_module._RANDOM_USAGE_METRICS.keys()):
app_module._RANDOM_USAGE_METRICS[key] = 0
for key in list(app_module._RANDOM_FALLBACK_METRICS.keys()):
app_module._RANDOM_FALLBACK_METRICS[key] = 0
app_module._RANDOM_FALLBACK_REASONS.clear()
app_module._RL_COUNTS.clear()
# Seed history
sh = client.get('/api/random/seeds')
assert sh.status_code == 200
sj = sh.json()
seeds = sj.get('seeds') or []
assert any(s == 9090 for s in seeds) and sj.get('last') in seeds
prev_ms = app_module.RANDOM_REROLL_THROTTLE_MS
prev_seconds = app_module._REROLL_THROTTLE_SECONDS
app_module.RANDOM_REROLL_THROTTLE_MS = 0
app_module._REROLL_THROTTLE_SECONDS = 0.0
try:
with TestClient(app_module.app) as client:
# Build + reroll to generate metrics and seed history
r1 = client.post("/api/random_full_build", json={"seed": 9090, "primary_theme": "Aggro"})
assert r1.status_code == 200, r1.text
r2 = client.post("/api/random_reroll", json={"seed": 9090})
assert r2.status_code == 200, r2.text
# Metrics
m = client.get("/status/random_metrics")
assert m.status_code == 200, m.text
mj = m.json()
assert mj.get("ok") is True
metrics = mj.get("metrics") or {}
assert "full_build" in metrics and "reroll" in metrics
usage = mj.get("usage") or {}
modes = usage.get("modes") or {}
fallbacks = usage.get("fallbacks") or {}
assert set(modes.keys()) >= {"theme", "reroll", "surprise", "reroll_same_commander"}
assert modes.get("theme", 0) >= 2
assert "none" in fallbacks
assert isinstance(usage.get("fallback_reasons"), dict)
# Seed history
sh = client.get("/api/random/seeds")
assert sh.status_code == 200
sj = sh.json()
seeds = sj.get("seeds") or []
assert any(s == 9090 for s in seeds) and sj.get("last") in seeds
finally:
app_module.RANDOM_REROLL_THROTTLE_MS = prev_ms
app_module._REROLL_THROTTLE_SECONDS = prev_seconds

View file

@ -0,0 +1,236 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Iterable, Sequence
import pandas as pd
from deck_builder import random_entrypoint
def _patch_commanders(monkeypatch, rows: Sequence[dict[str, object]]) -> None:
df = pd.DataFrame(rows)
monkeypatch.setattr(random_entrypoint, "_load_commanders_df", lambda: df)
def _make_row(name: str, tags: Iterable[str]) -> dict[str, object]:
return {"name": name, "themeTags": list(tags)}
def test_random_multi_theme_exact_triple_success(monkeypatch) -> None:
_patch_commanders(
monkeypatch,
[_make_row("Triple Threat", ["aggro", "tokens", "equipment"])],
)
res = random_entrypoint.build_random_deck(
primary_theme="aggro",
secondary_theme="tokens",
tertiary_theme="equipment",
seed=1313,
)
assert res.commander == "Triple Threat"
assert res.resolved_themes == ["aggro", "tokens", "equipment"]
assert res.combo_fallback is False
assert res.synergy_fallback is False
assert res.fallback_reason is None
def test_random_multi_theme_fallback_to_ps(monkeypatch) -> None:
_patch_commanders(
monkeypatch,
[
_make_row("PrimarySecondary", ["Aggro", "Tokens"]),
_make_row("Other Commander", ["Tokens", "Equipment"]),
],
)
res = random_entrypoint.build_random_deck(
primary_theme="Aggro",
secondary_theme="Tokens",
tertiary_theme="Equipment",
seed=2024,
)
assert res.commander == "PrimarySecondary"
assert res.resolved_themes == ["Aggro", "Tokens"]
assert res.combo_fallback is True
assert res.synergy_fallback is False
assert "Primary+Secondary" in (res.fallback_reason or "")
def test_random_multi_theme_fallback_to_pt(monkeypatch) -> None:
_patch_commanders(
monkeypatch,
[
_make_row("PrimaryTertiary", ["Aggro", "Equipment"]),
_make_row("Tokens Only", ["Tokens"]),
],
)
res = random_entrypoint.build_random_deck(
primary_theme="Aggro",
secondary_theme="Tokens",
tertiary_theme="Equipment",
seed=777,
)
assert res.commander == "PrimaryTertiary"
assert res.resolved_themes == ["Aggro", "Equipment"]
assert res.combo_fallback is True
assert res.synergy_fallback is False
assert "Primary+Tertiary" in (res.fallback_reason or "")
def test_random_multi_theme_fallback_primary_only(monkeypatch) -> None:
_patch_commanders(
monkeypatch,
[
_make_row("PrimarySolo", ["Aggro"]),
_make_row("Tokens Solo", ["Tokens"]),
],
)
res = random_entrypoint.build_random_deck(
primary_theme="Aggro",
secondary_theme="Tokens",
tertiary_theme="Equipment",
seed=9090,
)
assert res.commander == "PrimarySolo"
assert res.resolved_themes == ["Aggro"]
assert res.combo_fallback is True
assert res.synergy_fallback is False
assert "Primary only" in (res.fallback_reason or "")
def test_random_multi_theme_synergy_fallback(monkeypatch) -> None:
_patch_commanders(
monkeypatch,
[
_make_row("Synergy Commander", ["aggro surge"]),
_make_row("Unrelated", ["tokens"]),
],
)
res = random_entrypoint.build_random_deck(
primary_theme="aggro swarm",
secondary_theme="treasure",
tertiary_theme="artifacts",
seed=5150,
)
assert res.commander == "Synergy Commander"
assert res.resolved_themes == ["aggro", "swarm"]
assert res.combo_fallback is True
assert res.synergy_fallback is True
assert "synergy overlap" in (res.fallback_reason or "")
def test_random_multi_theme_full_pool_fallback(monkeypatch) -> None:
_patch_commanders(
monkeypatch,
[_make_row("Any Commander", ["control"])],
)
res = random_entrypoint.build_random_deck(
primary_theme="nonexistent",
secondary_theme="made up",
tertiary_theme="imaginary",
seed=6060,
)
assert res.commander == "Any Commander"
assert res.resolved_themes == []
assert res.combo_fallback is True
assert res.synergy_fallback is True
assert "full commander pool" in (res.fallback_reason or "")
def test_random_multi_theme_sidecar_fields_present(monkeypatch, tmp_path) -> None:
export_dir = tmp_path / "exports"
export_dir.mkdir()
commander_name = "Tri Commander"
_patch_commanders(
monkeypatch,
[_make_row(commander_name, ["Aggro", "Tokens", "Equipment"])],
)
import headless_runner
def _fake_run(
command_name: str,
seed: int | None = None,
primary_choice: int | None = None,
secondary_choice: int | None = None,
tertiary_choice: int | None = None,
):
base_path = export_dir / command_name.replace(" ", "_")
csv_path = base_path.with_suffix(".csv")
txt_path = base_path.with_suffix(".txt")
csv_path.write_text("Name\nCard\n", encoding="utf-8")
txt_path.write_text("Decklist", encoding="utf-8")
class DummyBuilder:
def __init__(self) -> None:
self.commander_name = command_name
self.commander = command_name
self.selected_tags = ["Aggro", "Tokens", "Equipment"]
self.primary_tag = "Aggro"
self.secondary_tag = "Tokens"
self.tertiary_tag = "Equipment"
self.bracket_level = 3
self.last_csv_path = str(csv_path)
self.last_txt_path = str(txt_path)
self.custom_export_base = command_name
def build_deck_summary(self) -> dict[str, object]:
return {"meta": {"existing": True}, "counts": {"total": 100}}
def compute_and_print_compliance(self, base_stem: str | None = None):
return {"ok": True}
return DummyBuilder()
monkeypatch.setattr(headless_runner, "run", _fake_run)
result = random_entrypoint.build_random_full_deck(
primary_theme="Aggro",
secondary_theme="Tokens",
tertiary_theme="Equipment",
seed=4242,
)
assert result.summary is not None
meta = result.summary.get("meta")
assert meta is not None
assert meta["primary_theme"] == "Aggro"
assert meta["secondary_theme"] == "Tokens"
assert meta["tertiary_theme"] == "Equipment"
assert meta["resolved_themes"] == ["aggro", "tokens", "equipment"]
assert meta["combo_fallback"] is False
assert meta["synergy_fallback"] is False
assert meta["fallback_reason"] is None
assert result.csv_path is not None
sidecar_path = Path(result.csv_path).with_suffix(".summary.json")
assert sidecar_path.is_file()
payload = json.loads(sidecar_path.read_text(encoding="utf-8"))
sidecar_meta = payload["meta"]
assert sidecar_meta["primary_theme"] == "Aggro"
assert sidecar_meta["secondary_theme"] == "Tokens"
assert sidecar_meta["tertiary_theme"] == "Equipment"
assert sidecar_meta["resolved_themes"] == ["aggro", "tokens", "equipment"]
assert sidecar_meta["random_primary_theme"] == "Aggro"
assert sidecar_meta["random_resolved_themes"] == ["aggro", "tokens", "equipment"]
# cleanup
sidecar_path.unlink(missing_ok=True)
Path(result.csv_path).unlink(missing_ok=True)
txt_candidate = Path(result.csv_path).with_suffix(".txt")
txt_candidate.unlink(missing_ok=True)

View file

@ -0,0 +1,46 @@
from __future__ import annotations
import os
from deck_builder.random_entrypoint import build_random_deck
def _use_testdata(monkeypatch) -> None:
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
def test_multi_theme_same_seed_same_result(monkeypatch) -> None:
_use_testdata(monkeypatch)
kwargs = {
"primary_theme": "Goblin Kindred",
"secondary_theme": "Token Swarm",
"tertiary_theme": "Treasure Support",
"seed": 4040,
}
res_a = build_random_deck(**kwargs)
res_b = build_random_deck(**kwargs)
assert res_a.seed == res_b.seed == 4040
assert res_a.commander == res_b.commander
assert res_a.resolved_themes == res_b.resolved_themes
def test_legacy_theme_and_primary_equivalence(monkeypatch) -> None:
_use_testdata(monkeypatch)
legacy = build_random_deck(theme="Goblin Kindred", seed=5151)
multi = build_random_deck(primary_theme="Goblin Kindred", seed=5151)
assert legacy.commander == multi.commander
assert legacy.seed == multi.seed == 5151
def test_string_seed_coerces_to_int(monkeypatch) -> None:
_use_testdata(monkeypatch)
result = build_random_deck(primary_theme="Goblin Kindred", seed="6262")
assert result.seed == 6262
# Sanity check that commander selection remains deterministic once coerced
repeat = build_random_deck(primary_theme="Goblin Kindred", seed="6262")
assert repeat.commander == result.commander

View file

@ -0,0 +1,204 @@
from __future__ import annotations
import base64
import json
import os
from typing import Any, Dict, Iterator, List
from urllib.parse import urlencode
import importlib
import pytest
from fastapi.testclient import TestClient
from deck_builder.random_entrypoint import RandomFullBuildResult
def _decode_state_token(token: str) -> Dict[str, Any]:
pad = "=" * (-len(token) % 4)
raw = base64.urlsafe_b64decode((token + pad).encode("ascii")).decode("utf-8")
return json.loads(raw)
@pytest.fixture()
def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
monkeypatch.setenv("RANDOM_MODES", "1")
monkeypatch.setenv("RANDOM_UI", "1")
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
web_app_module = importlib.import_module("code.web.app")
web_app_module = importlib.reload(web_app_module)
from code.web.services import tasks
tasks._SESSIONS.clear()
with TestClient(web_app_module.app) as test_client:
yield test_client
tasks._SESSIONS.clear()
def _make_full_result(seed: int) -> RandomFullBuildResult:
return RandomFullBuildResult(
seed=seed,
commander=f"Commander-{seed}",
theme="Aggro",
constraints={},
primary_theme="Aggro",
secondary_theme="Tokens",
tertiary_theme="Equipment",
resolved_themes=["aggro", "tokens", "equipment"],
combo_fallback=False,
synergy_fallback=False,
fallback_reason=None,
decklist=[{"name": "Sample Card", "count": 1}],
diagnostics={"elapsed_ms": 5},
summary={"meta": {"existing": True}},
csv_path=None,
txt_path=None,
compliance=None,
)
def test_random_multi_theme_reroll_same_commander_preserves_resolved(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
import deck_builder.random_entrypoint as random_entrypoint
import headless_runner
from code.web.services import tasks
build_calls: List[Dict[str, Any]] = []
def fake_build_random_full_deck(*, theme, constraints, seed, attempts, timeout_s, primary_theme, secondary_theme, tertiary_theme):
build_calls.append(
{
"theme": theme,
"primary": primary_theme,
"secondary": secondary_theme,
"tertiary": tertiary_theme,
"seed": seed,
}
)
return _make_full_result(int(seed))
monkeypatch.setattr(random_entrypoint, "build_random_full_deck", fake_build_random_full_deck)
class DummyBuilder:
def __init__(self, commander: str, seed: int) -> None:
self.commander_name = commander
self.commander = commander
self.deck_list_final: List[Dict[str, Any]] = []
self.last_csv_path = None
self.last_txt_path = None
self.custom_export_base = commander
def build_deck_summary(self) -> Dict[str, Any]:
return {"meta": {"rebuild": True}}
def export_decklist_csv(self) -> str:
return "deck_files/placeholder.csv"
def export_decklist_text(self, filename: str | None = None) -> str:
return "deck_files/placeholder.txt"
def compute_and_print_compliance(self, base_stem: str | None = None) -> Dict[str, Any]:
return {"ok": True}
reroll_runs: List[Dict[str, Any]] = []
def fake_run(command_name: str, seed: int | None = None):
reroll_runs.append({"commander": command_name, "seed": seed})
return DummyBuilder(command_name, seed or 0)
monkeypatch.setattr(headless_runner, "run", fake_run)
tasks._SESSIONS.clear()
resp1 = client.post(
"/hx/random_reroll",
json={
"mode": "surprise",
"primary_theme": "Aggro",
"secondary_theme": "Tokens",
"tertiary_theme": "Equipment",
"seed": 1010,
},
)
assert resp1.status_code == 200, resp1.text
assert build_calls and build_calls[0]["primary"] == "Aggro"
assert "value=\"aggro||tokens||equipment\"" in resp1.text
sid = client.cookies.get("sid")
assert sid
session = tasks.get_session(sid)
resolved_list = session.get("random_build", {}).get("resolved_theme_info", {}).get("resolved_list")
assert resolved_list == ["aggro", "tokens", "equipment"]
commander = f"Commander-{build_calls[0]['seed']}"
form_payload = [
("mode", "reroll_same_commander"),
("commander", commander),
("seed", str(build_calls[0]["seed"])),
("resolved_themes", "aggro||tokens||equipment"),
]
encoded = urlencode(form_payload, doseq=True)
resp2 = client.post(
"/hx/random_reroll",
content=encoded,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert resp2.status_code == 200, resp2.text
assert len(build_calls) == 1
assert reroll_runs and reroll_runs[0]["commander"] == commander
assert "value=\"aggro||tokens||equipment\"" in resp2.text
session_after = tasks.get_session(sid)
resolved_after = session_after.get("random_build", {}).get("resolved_theme_info", {}).get("resolved_list")
assert resolved_after == ["aggro", "tokens", "equipment"]
def test_random_multi_theme_permalink_roundtrip(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
import deck_builder.random_entrypoint as random_entrypoint
from code.web.services import tasks
seeds_seen: List[int] = []
def fake_build_random_full_deck(*, theme, constraints, seed, attempts, timeout_s, primary_theme, secondary_theme, tertiary_theme):
seeds_seen.append(int(seed))
return _make_full_result(int(seed))
monkeypatch.setattr(random_entrypoint, "build_random_full_deck", fake_build_random_full_deck)
tasks._SESSIONS.clear()
resp = client.post(
"/api/random_full_build",
json={
"seed": 4242,
"primary_theme": "Aggro",
"secondary_theme": "Tokens",
"tertiary_theme": "Equipment",
},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["primary_theme"] == "Aggro"
assert body["secondary_theme"] == "Tokens"
assert body["tertiary_theme"] == "Equipment"
assert body["resolved_themes"] == ["aggro", "tokens", "equipment"]
permalink = body["permalink"]
assert permalink and permalink.startswith("/build/from?state=")
visit = client.get(permalink)
assert visit.status_code == 200
state_resp = client.get("/build/permalink")
assert state_resp.status_code == 200, state_resp.text
state_payload = state_resp.json()
token = state_payload["permalink"].split("state=", 1)[1]
decoded = _decode_state_token(token)
random_section = decoded.get("random") or {}
assert random_section.get("primary_theme") == "Aggro"
assert random_section.get("secondary_theme") == "Tokens"
assert random_section.get("tertiary_theme") == "Equipment"
assert random_section.get("resolved_themes") == ["aggro", "tokens", "equipment"]
requested = random_section.get("requested_themes") or {}
assert requested.get("primary") == "Aggro"
assert requested.get("secondary") == "Tokens"
assert requested.get("tertiary") == "Equipment"
assert seeds_seen == [4242]

View file

@ -32,9 +32,76 @@ def test_api_random_reroll_increments_seed(client: TestClient):
assert data2.get("permalink")
def test_api_random_reroll_auto_fill_metadata(client: TestClient):
r1 = client.post("/api/random_full_build", json={"seed": 555, "primary_theme": "Aggro"})
assert r1.status_code == 200, r1.text
r2 = client.post(
"/api/random_reroll",
json={"seed": 555, "primary_theme": "Aggro", "auto_fill_enabled": True},
)
assert r2.status_code == 200, r2.text
data = r2.json()
assert data.get("auto_fill_enabled") is True
assert data.get("auto_fill_secondary_enabled") is True
assert data.get("auto_fill_tertiary_enabled") is True
assert data.get("auto_fill_applied") in (True, False)
assert isinstance(data.get("auto_filled_themes"), list)
assert data.get("requested_themes", {}).get("auto_fill_enabled") is True
assert data.get("requested_themes", {}).get("auto_fill_secondary_enabled") is True
assert data.get("requested_themes", {}).get("auto_fill_tertiary_enabled") is True
assert "display_themes" in data
def test_api_random_reroll_secondary_only_auto_fill(client: TestClient):
r1 = client.post(
"/api/random_reroll",
json={
"seed": 777,
"primary_theme": "Aggro",
"auto_fill_secondary_enabled": True,
"auto_fill_tertiary_enabled": False,
},
)
assert r1.status_code == 200, r1.text
data = r1.json()
assert data.get("auto_fill_enabled") is True
assert data.get("auto_fill_secondary_enabled") is True
assert data.get("auto_fill_tertiary_enabled") is False
assert data.get("auto_fill_applied") in (True, False)
assert isinstance(data.get("auto_filled_themes"), list)
requested = data.get("requested_themes", {})
assert requested.get("auto_fill_enabled") is True
assert requested.get("auto_fill_secondary_enabled") is True
assert requested.get("auto_fill_tertiary_enabled") is False
def test_api_random_reroll_tertiary_requires_secondary(client: TestClient):
r1 = client.post(
"/api/random_reroll",
json={
"seed": 778,
"primary_theme": "Aggro",
"auto_fill_secondary_enabled": False,
"auto_fill_tertiary_enabled": True,
},
)
assert r1.status_code == 200, r1.text
data = r1.json()
assert data.get("auto_fill_enabled") is True
assert data.get("auto_fill_secondary_enabled") is True
assert data.get("auto_fill_tertiary_enabled") is True
assert data.get("auto_fill_applied") in (True, False)
assert isinstance(data.get("auto_filled_themes"), list)
requested = data.get("requested_themes", {})
assert requested.get("auto_fill_enabled") is True
assert requested.get("auto_fill_secondary_enabled") is True
assert requested.get("auto_fill_tertiary_enabled") is True
def test_hx_random_reroll_returns_html(client: TestClient):
headers = {"HX-Request": "true", "Content-Type": "application/json"}
r = client.post("/hx/random_reroll", data=json.dumps({"seed": 42}), headers=headers)
r = client.post("/hx/random_reroll", content=json.dumps({"seed": 42}), headers=headers)
assert r.status_code == 200, r.text
# Accept either HTML fragment or JSON fallback
content_type = r.headers.get("content-type", "")

View file

@ -35,7 +35,7 @@ def test_locked_reroll_generates_summary_and_compliance():
start = time.time()
# Locked reroll via HTMX path (form style)
form_body = f"seed={seed}&commander={commander}&mode=reroll_same_commander"
r2 = c.post('/hx/random_reroll', data=form_body, headers={'Content-Type':'application/x-www-form-urlencoded'})
r2 = c.post('/hx/random_reroll', content=form_body, headers={'Content-Type':'application/x-www-form-urlencoded'})
assert r2.status_code == 200, r2.text
# Look for new sidecar/compliance created after start

View file

@ -23,14 +23,14 @@ def test_reroll_keeps_commander():
# First reroll with commander lock
headers = {'Content-Type': 'application/json'}
body = json.dumps({'seed': seed, 'commander': commander, 'mode': 'reroll_same_commander'})
r2 = client.post('/hx/random_reroll', data=body, headers=headers)
r2 = client.post('/hx/random_reroll', content=body, headers=headers)
assert r2.status_code == 200
html1 = r2.text
assert commander in html1
# Second reroll should keep same commander (seed increments so prior +1 used on server)
body2 = json.dumps({'seed': seed + 1, 'commander': commander, 'mode': 'reroll_same_commander'})
r3 = client.post('/hx/random_reroll', data=body2, headers=headers)
r3 = client.post('/hx/random_reroll', content=body2, headers=headers)
assert r3.status_code == 200
html2 = r3.text
assert commander in html2

View file

@ -20,12 +20,12 @@ def test_reroll_keeps_commander_form_encoded():
seed = data1['seed']
form_body = f"seed={seed}&commander={quote_plus(commander)}&mode=reroll_same_commander"
r2 = client.post('/hx/random_reroll', data=form_body, headers={'Content-Type': 'application/x-www-form-urlencoded'})
r2 = client.post('/hx/random_reroll', content=form_body, headers={'Content-Type': 'application/x-www-form-urlencoded'})
assert r2.status_code == 200
assert commander in r2.text
# second reroll with incremented seed
form_body2 = f"seed={seed+1}&commander={quote_plus(commander)}&mode=reroll_same_commander"
r3 = client.post('/hx/random_reroll', data=form_body2, headers={'Content-Type': 'application/x-www-form-urlencoded'})
r3 = client.post('/hx/random_reroll', content=form_body2, headers={'Content-Type': 'application/x-www-form-urlencoded'})
assert r3.status_code == 200
assert commander in r3.text

View file

@ -19,7 +19,7 @@ def test_locked_reroll_single_export():
commander = r.json()['commander']
before_csvs = set(glob.glob('deck_files/*.csv'))
form_body = f"seed={seed}&commander={commander}&mode=reroll_same_commander"
r2 = c.post('/hx/random_reroll', data=form_body, headers={'Content-Type':'application/x-www-form-urlencoded'})
r2 = c.post('/hx/random_reroll', content=form_body, headers={'Content-Type':'application/x-www-form-urlencoded'})
assert r2.status_code == 200
after_csvs = set(glob.glob('deck_files/*.csv'))
new_csvs = after_csvs - before_csvs

View file

@ -0,0 +1,65 @@
from __future__ import annotations
import os
import time
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def throttle_client(monkeypatch):
monkeypatch.setenv("RANDOM_MODES", "1")
monkeypatch.setenv("RANDOM_UI", "1")
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
import code.web.app as app_module
# Ensure feature flags and globals reflect the test configuration
app_module.RANDOM_MODES = True
app_module.RANDOM_UI = True
app_module.RATE_LIMIT_ENABLED = False
# Keep existing values so we can restore after the test
prev_ms = app_module.RANDOM_REROLL_THROTTLE_MS
prev_seconds = app_module._REROLL_THROTTLE_SECONDS
app_module.RANDOM_REROLL_THROTTLE_MS = 50
app_module._REROLL_THROTTLE_SECONDS = 0.05
app_module._RL_COUNTS.clear()
with TestClient(app_module.app) as client:
yield client, app_module
# Restore globals for other tests
app_module.RANDOM_REROLL_THROTTLE_MS = prev_ms
app_module._REROLL_THROTTLE_SECONDS = prev_seconds
app_module._RL_COUNTS.clear()
def test_random_reroll_session_throttle(throttle_client):
client, app_module = throttle_client
# First reroll succeeds and seeds the session timestamp
first = client.post("/api/random_reroll", json={"seed": 5000})
assert first.status_code == 200, first.text
assert "sid" in client.cookies
# Immediate follow-up should hit the throttle guard
second = client.post("/api/random_reroll", json={"seed": 5001})
assert second.status_code == 429
retry_after = second.headers.get("Retry-After")
assert retry_after is not None
assert int(retry_after) >= 1
# After waiting slightly longer than the throttle window, requests succeed again
time.sleep(0.06)
third = client.post("/api/random_reroll", json={"seed": 5002})
assert third.status_code == 200, third.text
assert int(third.json().get("seed")) >= 5002
# Telemetry shouldn't record fallback for the throttle rejection
metrics_snapshot = app_module._RANDOM_METRICS.get("reroll")
assert metrics_snapshot is not None
assert metrics_snapshot.get("error", 0) == 0

View file

@ -0,0 +1,178 @@
from __future__ import annotations
import importlib
import itertools
import os
from typing import Any
from fastapi.testclient import TestClient
def _make_stub_result(seed: int | None, theme: Any, primary: Any, secondary: Any = None, tertiary: Any = None):
class _Result:
pass
res = _Result()
res.seed = int(seed) if seed is not None else 0
res.commander = f"Commander-{res.seed}"
res.decklist = []
res.theme = theme
res.primary_theme = primary
res.secondary_theme = secondary
res.tertiary_theme = tertiary
res.resolved_themes = [t for t in [primary, secondary, tertiary] if t]
res.combo_fallback = True if primary and primary != theme else False
res.synergy_fallback = False
res.fallback_reason = "fallback" if res.combo_fallback else None
res.constraints = {}
res.diagnostics = {}
res.summary = None
res.theme_fallback = bool(res.combo_fallback or res.synergy_fallback)
res.csv_path = None
res.txt_path = None
res.compliance = None
res.original_theme = theme
return res
def test_surprise_reuses_requested_theme(monkeypatch):
monkeypatch.setenv("RANDOM_MODES", "1")
monkeypatch.setenv("RANDOM_UI", "1")
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
random_util = importlib.import_module("random_util")
seed_iter = itertools.count(1000)
monkeypatch.setattr(random_util, "generate_seed", lambda: next(seed_iter))
random_entrypoint = importlib.import_module("deck_builder.random_entrypoint")
build_calls: list[dict[str, Any]] = []
def fake_build_random_full_deck(*, theme, constraints, seed, attempts, timeout_s, primary_theme, secondary_theme, tertiary_theme):
build_calls.append({
"theme": theme,
"primary": primary_theme,
"secondary": secondary_theme,
"tertiary": tertiary_theme,
"seed": seed,
})
return _make_stub_result(seed, theme, "ResolvedTokens")
monkeypatch.setattr(random_entrypoint, "build_random_full_deck", fake_build_random_full_deck)
web_app_module = importlib.import_module("code.web.app")
web_app_module = importlib.reload(web_app_module)
client = TestClient(web_app_module.app)
# Initial surprise request with explicit theme
resp1 = client.post("/hx/random_reroll", json={"mode": "surprise", "primary_theme": "Tokens"})
assert resp1.status_code == 200
assert build_calls[0]["primary"] == "Tokens"
assert build_calls[0]["theme"] == "Tokens"
# Subsequent surprise request without providing themes should reuse requested input, not resolved fallback
resp2 = client.post("/hx/random_reroll", json={"mode": "surprise"})
assert resp2.status_code == 200
assert len(build_calls) == 2
assert build_calls[1]["primary"] == "Tokens"
assert build_calls[1]["theme"] == "Tokens"
def test_reroll_same_commander_uses_resolved_cache(monkeypatch):
monkeypatch.setenv("RANDOM_MODES", "1")
monkeypatch.setenv("RANDOM_UI", "1")
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
random_util = importlib.import_module("random_util")
seed_iter = itertools.count(2000)
monkeypatch.setattr(random_util, "generate_seed", lambda: next(seed_iter))
random_entrypoint = importlib.import_module("deck_builder.random_entrypoint")
build_calls: list[dict[str, Any]] = []
def fake_build_random_full_deck(*, theme, constraints, seed, attempts, timeout_s, primary_theme, secondary_theme, tertiary_theme):
build_calls.append({
"theme": theme,
"primary": primary_theme,
"seed": seed,
})
return _make_stub_result(seed, theme, "ResolvedArtifacts")
monkeypatch.setattr(random_entrypoint, "build_random_full_deck", fake_build_random_full_deck)
headless_runner = importlib.import_module("headless_runner")
locked_runs: list[dict[str, Any]] = []
class DummyBuilder:
def __init__(self, commander: str):
self.commander_name = commander
self.commander = commander
self.deck_list_final: list[Any] = []
self.last_csv_path = None
self.last_txt_path = None
self.custom_export_base = None
def build_deck_summary(self):
return None
def export_decklist_csv(self):
return None
def export_decklist_text(self, filename: str | None = None): # pragma: no cover - optional path
return None
def compute_and_print_compliance(self, base_stem: str | None = None): # pragma: no cover - optional path
return None
def fake_run(command_name: str, seed: int | None = None):
locked_runs.append({"commander": command_name, "seed": seed})
return DummyBuilder(command_name)
monkeypatch.setattr(headless_runner, "run", fake_run)
web_app_module = importlib.import_module("code.web.app")
web_app_module = importlib.reload(web_app_module)
from code.web.services import tasks
tasks._SESSIONS.clear()
client = TestClient(web_app_module.app)
# Initial surprise build to populate session cache
resp1 = client.post("/hx/random_reroll", json={"mode": "surprise", "primary_theme": "Artifacts"})
assert resp1.status_code == 200
assert build_calls[0]["primary"] == "Artifacts"
commander_name = f"Commander-{build_calls[0]['seed']}"
first_seed = build_calls[0]["seed"]
form_payload = [
("mode", "reroll_same_commander"),
("commander", commander_name),
("seed", str(first_seed)),
("primary_theme", "ResolvedArtifacts"),
("primary_theme", "UserOverride"),
("resolved_themes", "ResolvedArtifacts"),
]
from urllib.parse import urlencode
encoded = urlencode(form_payload, doseq=True)
resp2 = client.post(
"/hx/random_reroll",
content=encoded,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert resp2.status_code == 200
assert resp2.request.headers.get("Content-Type") == "application/x-www-form-urlencoded"
assert len(locked_runs) == 1 # headless runner invoked once
assert len(build_calls) == 1 # no additional filter build
# Hidden input should reflect resolved theme, not user override
assert 'id="current-primary-theme"' in resp2.text
assert 'value="ResolvedArtifacts"' in resp2.text
assert "UserOverride" not in resp2.text
sid = client.cookies.get("sid")
assert sid
session = tasks.get_session(sid)
requested = session.get("random_build", {}).get("requested_themes") or {}
assert requested.get("primary") == "Artifacts"

View file

@ -0,0 +1,37 @@
import sys
from pathlib import Path
from fastapi.testclient import TestClient
from code.web import app as web_app # type: ignore
from code.web.app import app # type: ignore
# Ensure project root on sys.path for absolute imports
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _make_client() -> TestClient:
return TestClient(app)
def test_theme_stats_requires_diagnostics_flag(monkeypatch):
monkeypatch.setattr(web_app, "SHOW_DIAGNOSTICS", False)
client = _make_client()
resp = client.get("/status/random_theme_stats")
assert resp.status_code == 404
def test_theme_stats_payload_includes_core_fields(monkeypatch):
monkeypatch.setattr(web_app, "SHOW_DIAGNOSTICS", True)
client = _make_client()
resp = client.get("/status/random_theme_stats")
assert resp.status_code == 200
payload = resp.json()
assert payload.get("ok") is True
stats = payload.get("stats") or {}
assert "commanders" in stats
assert "unique_tokens" in stats
assert "total_assignments" in stats
assert isinstance(stats.get("top_tokens"), list)

View file

@ -0,0 +1,39 @@
import pandas as pd
from deck_builder.random_entrypoint import _ensure_theme_tag_cache, _filter_multi
def _build_df() -> pd.DataFrame:
data = {
"name": ["Alpha", "Beta", "Gamma"],
"themeTags": [
["Aggro", "Tokens"],
["LifeGain", "Control"],
["Artifacts", "Combo"],
],
}
df = pd.DataFrame(data)
return _ensure_theme_tag_cache(df)
def test_and_filter_uses_cached_index():
df = _build_df()
filtered, diag = _filter_multi(df, "Aggro", "Tokens", None)
assert list(filtered["name"].values) == ["Alpha"]
assert diag["resolved_themes"] == ["Aggro", "Tokens"]
assert not diag["combo_fallback"]
assert "aggro" in df.attrs["_ltag_index"]
assert "tokens" in df.attrs["_ltag_index"]
def test_synergy_fallback_partial_match_uses_index_union():
df = _build_df()
filtered, diag = _filter_multi(df, "Life Gain", None, None)
assert list(filtered["name"].values) == ["Beta"]
assert diag["combo_fallback"]
assert diag["synergy_fallback"]
assert diag["resolved_themes"] == ["life", "gain"]
assert diag["fallback_reason"] is not None

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from fastapi import APIRouter, Request, Form, Query
from fastapi.responses import HTMLResponse, JSONResponse
from typing import Any
from ..app import ALLOW_MUST_HAVES # Import feature flag
from ..services.build_utils import (
step5_ctx_from_result,
@ -2859,7 +2860,35 @@ async def build_permalink(request: Request):
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}
inc: dict[str, Any] = {}
for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
if rb.get(key) is not None:
inc[key] = rb.get(key)
resolved_list = rb.get("resolved_themes")
if isinstance(resolved_list, list):
inc["resolved_themes"] = list(resolved_list)
resolved_info = rb.get("resolved_theme_info")
if isinstance(resolved_info, dict):
inc["resolved_theme_info"] = dict(resolved_info)
if rb.get("combo_fallback") is not None:
inc["combo_fallback"] = bool(rb.get("combo_fallback"))
if rb.get("synergy_fallback") is not None:
inc["synergy_fallback"] = bool(rb.get("synergy_fallback"))
if rb.get("fallback_reason") is not None:
inc["fallback_reason"] = rb.get("fallback_reason")
requested = rb.get("requested_themes")
if isinstance(requested, dict):
inc["requested_themes"] = dict(requested)
if rb.get("auto_fill_enabled") is not None:
inc["auto_fill_enabled"] = bool(rb.get("auto_fill_enabled"))
if rb.get("auto_fill_applied") is not None:
inc["auto_fill_applied"] = bool(rb.get("auto_fill_applied"))
auto_filled = rb.get("auto_filled_themes")
if isinstance(auto_filled, list):
inc["auto_filled_themes"] = list(auto_filled)
display = rb.get("display_themes")
if isinstance(display, list):
inc["display_themes"] = list(display)
if inc:
payload["random"] = inc
except Exception:
@ -2914,9 +2943,43 @@ async def build_from(request: Request, state: str | None = None) -> HTMLResponse
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
}
rb_payload: dict[str, Any] = {}
for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
if r.get(key) is not None:
rb_payload[key] = r.get(key)
if isinstance(r.get("resolved_themes"), list):
rb_payload["resolved_themes"] = list(r.get("resolved_themes") or [])
if isinstance(r.get("resolved_theme_info"), dict):
rb_payload["resolved_theme_info"] = dict(r.get("resolved_theme_info"))
if r.get("combo_fallback") is not None:
rb_payload["combo_fallback"] = bool(r.get("combo_fallback"))
if r.get("synergy_fallback") is not None:
rb_payload["synergy_fallback"] = bool(r.get("synergy_fallback"))
if r.get("fallback_reason") is not None:
rb_payload["fallback_reason"] = r.get("fallback_reason")
if isinstance(r.get("requested_themes"), dict):
requested_payload = dict(r.get("requested_themes"))
if "auto_fill_enabled" in requested_payload:
requested_payload["auto_fill_enabled"] = bool(requested_payload.get("auto_fill_enabled"))
rb_payload["requested_themes"] = requested_payload
if r.get("auto_fill_enabled") is not None:
rb_payload["auto_fill_enabled"] = bool(r.get("auto_fill_enabled"))
if r.get("auto_fill_applied") is not None:
rb_payload["auto_fill_applied"] = bool(r.get("auto_fill_applied"))
auto_filled = r.get("auto_filled_themes")
if isinstance(auto_filled, list):
rb_payload["auto_filled_themes"] = list(auto_filled)
display = r.get("display_themes")
if isinstance(display, list):
rb_payload["display_themes"] = list(display)
if "seed" in rb_payload:
try:
seed_int = int(rb_payload["seed"])
rb_payload["seed"] = seed_int
rb_payload.setdefault("recent_seeds", [seed_int])
except Exception:
rb_payload.setdefault("recent_seeds", [])
sess["random_build"] = rb_payload
except Exception:
pass

View file

@ -7,6 +7,7 @@
<h3 style="margin-top:0">System summary</h3>
<div id="sysSummary" class="muted">Loading…</div>
<div id="themeSummary" style="margin-top:.5rem"></div>
<div id="themeTokenStats" class="muted" style="margin-top:.5rem">Loading theme stats…</div>
<div style="margin-top:.35rem">
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
</div>
@ -76,6 +77,121 @@
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
}
load();
var tokenEl = document.getElementById('themeTokenStats');
function renderTokens(payload){
if (!tokenEl) return;
try {
if (!payload || payload.ok !== true) {
tokenEl.textContent = 'Theme stats unavailable';
return;
}
var stats = payload.stats || {};
var top = Array.isArray(stats.top_tokens) ? stats.top_tokens.slice(0, 5) : [];
var html = '';
var commanders = (stats && stats.commanders != null) ? stats.commanders : '0';
var withTags = (stats && stats.with_tags != null) ? stats.with_tags : '0';
var uniqueTokens = (stats && stats.unique_tokens != null) ? stats.unique_tokens : '0';
var assignments = (stats && stats.total_assignments != null) ? stats.total_assignments : '0';
var avgTokens = (stats && stats.avg_tokens_per_commander != null) ? stats.avg_tokens_per_commander : '0';
var medianTokens = (stats && stats.median_tokens_per_commander != null) ? stats.median_tokens_per_commander : '0';
html += '<div><strong>Commanders indexed:</strong> ' + String(commanders) + ' (' + String(withTags) + ' with tags)</div>';
html += '<div><strong>Theme tokens:</strong> ' + String(uniqueTokens) + ' unique; ' + String(assignments) + ' assignments</div>';
html += '<div><strong>Tokens per commander:</strong> avg ' + String(avgTokens) + ', median ' + String(medianTokens) + '</div>';
if (top.length) {
var parts = [];
top.forEach(function(item){
parts.push(String(item.token) + ' (' + String(item.count) + ')');
});
html += '<div><strong>Top tokens:</strong> ' + parts.join(', ') + '</div>';
}
var pool = stats.random_pool || {};
if (pool && typeof pool.size !== 'undefined'){
var coveragePct = null;
if (pool.coverage_ratio != null){
var cov = Number(pool.coverage_ratio);
if (!Number.isNaN(cov)){ coveragePct = (cov * 100).toFixed(1); }
}
html += '<div style="margin-top:0.35rem;"><strong>Curated random pool:</strong> ' + String(pool.size) + ' tokens';
if (coveragePct !== null){ html += ' (' + coveragePct + '% of catalog tokens)'; }
html += '</div>';
var rules = pool.rules || {};
var threshold = rules.overrepresented_share_threshold;
if (threshold != null){
var thrPct = Number(threshold);
if (!Number.isNaN(thrPct)){ html += '<div style="font-size:11px;">Over-represented threshold: ≥ ' + (thrPct * 100).toFixed(1) + '% of commanders</div>'; }
}
var excludedCounts = pool.excluded_counts || {};
var reasonKeys = Object.keys(excludedCounts);
if (reasonKeys.length){
var badges = reasonKeys.map(function(reason){
return reason + ' (' + excludedCounts[reason] + ')';
});
html += '<div style="font-size:11px;">Exclusions: ' + badges.join(', ') + '</div>';
}
var samples = pool.excluded_samples || {};
var sampleKeys = Object.keys(samples);
if (sampleKeys.length){
var sampleLines = [];
sampleKeys.slice(0, 3).forEach(function(reason){
var tokens = samples[reason] || [];
var sampleTokens = (tokens || []).slice(0, 3);
var remainder = Math.max((tokens || []).length - sampleTokens.length, 0);
var tokenLabel = sampleTokens.join(', ');
if (remainder > 0){ tokenLabel += ' +' + remainder; }
sampleLines.push(reason + ': ' + tokenLabel);
});
html += '<div style="font-size:11px; opacity:0.75;">Samples → ' + sampleLines.join(' | ') + '</div>';
}
var manualDetail = pool.manual_exclusion_detail || {};
var manualKeys = Object.keys(manualDetail);
if (manualKeys.length){
var manualSamples = manualKeys.slice(0, 3).map(function(token){
var info = manualDetail[token] || {};
var label = info.display || token;
var cat = info.category ? (' [' + info.category + ']') : '';
return label + cat;
});
var manualRemainder = Math.max(manualKeys.length - manualSamples.length, 0);
var manualLine = manualSamples.join(', ');
if (manualRemainder > 0){ manualLine += ' +' + manualRemainder; }
html += '<div style="font-size:11px;">Manual exclusions: ' + manualLine + '</div>';
}
var manualGroups = Array.isArray(rules.manual_exclusions) ? rules.manual_exclusions : [];
if (manualGroups.length){
var categoryList = manualGroups.map(function(group){ return group.category || 'manual'; });
html += '<div style="font-size:11px; opacity:0.75;">Manual categories: ' + categoryList.join(', ') + '</div>';
}
}
var telemetry = stats.index_telemetry || {};
if (telemetry && typeof telemetry.token_count !== 'undefined'){
var hitRate = telemetry.hit_rate != null ? Number(telemetry.hit_rate) : null;
var hitPct = (hitRate !== null && !Number.isNaN(hitRate)) ? (hitRate * 100).toFixed(1) : null;
var teleLine = '<div style="font-size:11px; margin-top:0.25rem;">Tag index: ' + String(telemetry.token_count || 0) + ' tokens · lookups ' + String(telemetry.lookups || 0);
if (hitPct !== null){ teleLine += ' · hit rate ' + hitPct + '%'; }
if (telemetry.substring_checks){ teleLine += ' · substring checks ' + String(telemetry.substring_checks || 0); }
teleLine += '</div>';
html += teleLine;
}
tokenEl.innerHTML = html;
} catch(_){
tokenEl.textContent = 'Theme stats unavailable';
}
}
function loadTokenStats(){
if (!tokenEl) return;
tokenEl.textContent = 'Loading theme stats…';
fetch('/status/random_theme_stats', { cache: 'no-store' })
.then(function(resp){
if (resp.status === 404) {
tokenEl.textContent = 'Diagnostics disabled (stats unavailable)';
return null;
}
return resp.json();
})
.then(function(data){ if (data) renderTokens(data); })
.catch(function(){ tokenEl.textContent = 'Theme stats unavailable'; });
}
loadTokenStats();
// Theme status and reset
try{
var tEl = document.getElementById('themeSummary');

View file

@ -1,11 +1,19 @@
<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;}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
.diag-badges{display:inline-flex; gap:4px; margin-left:8px; flex-wrap:wrap; align-items:center;}
.diag-badge{background:var(--panel-alt,#334155); color:#fff; padding:3px 7px; border-radius:12px; font-size:10px; letter-spacing:.5px; line-height:1.3; display:inline-flex; align-items:center; gap:4px;}
.diag-badge.warn{background:#814c14;}
.diag-badge.err{background:#7f1d1d;}
.diag-badge.fallback{background:#4f46e5;}
.diag-badge.fallback{background:#4338ca;}
.diag-badge .badge-icon{font-size:12px; display:inline-block;}
.btn-compact{font-size:11px; padding:2px 6px; line-height:1.2;}
.fallback-notice{margin-top:8px; padding:10px 12px; border-radius:6px; font-size:13px; line-height:1.45; border-left:4px solid currentColor; display:flex; gap:8px; align-items:flex-start;}
.fallback-notice.info{background:rgba(79,70,229,0.18); color:#2c2891; border:1px solid rgba(79,70,229,0.45);}
.fallback-notice.warn{background:#fff0d6; color:#6c4505; border:1px solid #d89b32;}
.fallback-notice.danger{background:#fef2f2; color:#7f1d1d; border:1px solid rgba(127,29,29,0.5);}
.fallback-notice:focus-visible{outline:2px solid currentColor; outline-offset:2px;}
.fallback-notice .notice-icon{font-size:16px; line-height:1; margin-top:2px;}
</style>
<div class="random-meta" style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
<span class="seed">Seed: <strong>{{ seed }}</strong></span>
@ -14,18 +22,90 @@
<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 class="diag-badges" aria-label="Diagnostics" role="status" aria-live="polite" aria-atomic="true">
<span class="diag-badge" title="Attempts tried before acceptance" aria-label="Attempts tried before acceptance">
<span class="badge-icon" aria-hidden="true"></span>
<span aria-hidden="true">Att {{ diagnostics.attempts }}</span>
</span>
<span class="diag-badge" title="Elapsed build time in milliseconds" aria-label="Elapsed build time in milliseconds">
<span class="badge-icon" aria-hidden="true"></span>
<span aria-hidden="true">{{ diagnostics.elapsed_ms }}ms</span>
</span>
{% if diagnostics.timeout_hit %}
<span class="diag-badge warn" title="Generation loop exceeded timeout limit before success" aria-label="Generation exceeded timeout limit before success">
<span class="badge-icon" aria-hidden="true"></span>
<span aria-hidden="true">Timeout</span>
</span>
{% endif %}
{% if diagnostics.retries_exhausted %}
<span class="diag-badge warn" title="All allotted attempts were used without an early acceptable candidate" aria-label="All attempts were used without an early acceptable candidate">
<span class="badge-icon" aria-hidden="true"></span>
<span aria-hidden="true">Retries</span>
</span>
{% endif %}
{% if fallback or diagnostics.fallback %}
<span class="diag-badge fallback" title="Original theme produced no candidates; Surprise mode fallback engaged" aria-label="Fallback engaged after theme produced no candidates">
<span class="badge-icon" aria-hidden="true"></span>
<span aria-hidden="true">Fallback</span>
</span>
{% endif %}
</span>
{% endif %}
</div>
{% set display_list = display_themes or resolved_themes or [] %}
{% set resolved_list = display_list %}
{% set has_primary = primary_theme or secondary_theme or tertiary_theme %}
{% if resolved_list or has_primary %}
<div class="resolved-themes" style="margin-top:6px; font-size:13px; color:var(--text-muted,#94a3b8);" role="status" aria-live="polite">
{% if resolved_list %}
Resolved themes: <strong>{{ resolved_list|join(' + ') }}</strong>
{% else %}
Resolved themes: <strong>Full pool fallback</strong>
{% endif %}
</div>
{% endif %}
{% if auto_fill_applied and auto_filled_themes %}
<div class="auto-fill-note" style="margin-top:4px; font-size:12px; color:var(--text-muted,#94a3b8);" role="status" aria-live="polite">
Auto-filled: <strong>{{ auto_filled_themes|join(', ') }}</strong>
</div>
{% endif %}
{% if fallback_reason %}
{% if synergy_fallback and (not resolved_list) %}
{% set notice_class = 'danger' %}
{% elif synergy_fallback %}
{% set notice_class = 'warn' %}
{% else %}
{% set notice_class = 'info' %}
{% endif %}
{% if notice_class == 'danger' %}
{% set notice_icon = '⛔' %}
{% elif notice_class == 'warn' %}
{% set notice_icon = '⚠️' %}
{% else %}
{% set notice_icon = '' %}
{% endif %}
<div class="fallback-notice {{ notice_class }}" role="status" aria-live="assertive" aria-atomic="true" tabindex="0">
<span class="notice-icon" aria-hidden="true">{{ notice_icon }}</span>
<span>
<strong>Heads up:</strong>
<span id="fallback-reason-text">{{ fallback_reason }}.</span>
<span class="sr-only">You can tweak secondary or tertiary themes for different mixes, or reroll to explore more options.</span>
<span aria-hidden="true"> Try tweaking Secondary or Tertiary themes for different mixes, or reroll to explore more options.</span>
</span>
</div>
{% endif %}
<!-- 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 }}" />
<input type="hidden" id="current-primary-theme" name="primary_theme" value="{{ primary_theme or '' }}" />
<input type="hidden" id="current-secondary-theme" name="secondary_theme" value="{{ secondary_theme or '' }}" />
<input type="hidden" id="current-tertiary-theme" name="tertiary_theme" value="{{ tertiary_theme or '' }}" />
<input type="hidden" id="current-resolved-themes" name="resolved_themes" value="{{ resolved_list|join('||') }}" />
<input type="hidden" id="current-auto-fill-enabled" name="auto_fill_enabled" value="{{ 'true' if auto_fill_enabled else 'false' }}" />
<input type="hidden" id="current-auto-fill-secondary-enabled" name="auto_fill_secondary_enabled" value="{{ 'true' if auto_fill_secondary_enabled else 'false' }}" />
<input type="hidden" id="current-auto-fill-tertiary-enabled" name="auto_fill_tertiary_enabled" value="{{ 'true' if auto_fill_tertiary_enabled else 'false' }}" />
<input type="hidden" id="current-auto-filled-themes" name="auto_filled_themes" value="{{ auto_filled_themes|join('||') if auto_filled_themes else '' }}" />
<input type="hidden" id="current-strict-theme-match" name="strict_theme_match" value="{{ 'true' if strict_theme_match else 'false' }}" />
<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

View file

@ -6,25 +6,118 @@
{% 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 -->
<style>
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
.theme-tooltip{position:relative; display:inline-flex; align-items:center;}
.theme-tooltip button.help-icon{border:none; background:transparent; color:var(--text-muted,#94a3b8); font-size:14px; cursor:help; border-radius:50%; width:20px; height:20px; line-height:20px; text-align:center; transition:color .2s ease;}
.theme-tooltip button.help-icon:focus-visible{outline:2px solid var(--accent,#6366f1); outline-offset:2px;}
.theme-tooltip .tooltip-panel{position:absolute; top:100%; left:50%; transform:translate(-50%,8px); background:var(--panel,#111827); color:var(--text,#f8fafc); border:1px solid var(--border,#334155); border-radius:8px; padding:10px 12px; font-size:12px; width:220px; box-shadow:0 10px 25px rgba(15,23,42,0.35); opacity:0; pointer-events:none; transition:opacity .2s ease; z-index:30;}
.theme-tooltip[data-open="true"] .tooltip-panel{opacity:1; pointer-events:auto;}
.theme-tooltip .tooltip-panel p{margin:0; line-height:1.4;}
.clear-themes-btn{background:transparent; border:none; color:var(--accent,#6366f1); font-size:12px; padding:2px 8px; border-radius:6px; cursor:pointer; text-decoration:underline; transition:color .15s ease, background .15s ease;}
.clear-themes-btn:hover{color:var(--accent-strong,#818cf8);}
.clear-themes-btn:focus-visible{outline:2px solid var(--accent,#6366f1); outline-offset:2px;}
.strict-toggle{display:flex; align-items:center; gap:8px; font-size:12px; color:var(--text-muted,#94a3b8);}
</style>
<div class="controls" role="group" aria-label="Random controls" style="display:flex; flex-direction:column; gap:12px;">
<fieldset class="theme-section" role="group" aria-labelledby="theme-group-label" style="border:1px solid var(--border,#1f2937); border-radius:10px; padding:12px 16px; display:flex; flex-direction:column; gap:10px; min-width:320px; flex:0 0 auto;">
<legend id="theme-group-label" style="font-size:13px; font-weight:600; letter-spacing:.4px; display:flex; align-items:center; gap:6px; justify-content:space-between; flex-wrap:wrap;">
<span class="theme-legend-label" style="display:inline-flex; align-items:center; gap:6px;">
Themes
<span class="theme-tooltip" data-open="false">
<button type="button" class="help-icon" aria-expanded="false" aria-controls="theme-tooltip-panel" aria-describedby="theme-tooltip-text">?</button>
<span id="theme-tooltip-text" class="sr-only">Explain theme fallback order</span>
<div id="theme-tooltip-panel" class="tooltip-panel" role="dialog" aria-modal="false">
<p>We attempt your Primary + Secondary + Tertiary first. If that has no hits, we relax to Primary + Secondary, then Primary + Tertiary, Primary only, synergy overlap, and finally the full pool.</p>
</div>
</span>
</span>
<button type="button" id="random-clear-themes" class="clear-themes-btn" aria-describedby="theme-group-label">Clear themes</button>
</legend>
<div style="display:flex; flex-direction:column; gap:10px;" aria-describedby="theme-help">
<div class="theme-row" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<label for="random-primary-theme" class="field-label" style="min-width:110px; font-weight:600;">Primary</label>
<div style="position:relative; flex:1 1 260px; min-width:220px;">
<input id="random-primary-theme" name="primary_theme" type="text" placeholder="e.g. Aggro, Goblin Kindred" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="primary-theme-suggest-box" aria-controls="primary-theme-suggest-box" aria-describedby="theme-help" aria-haspopup="listbox" />
<div id="primary-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:22; max-height:220px; overflow-y:auto; box-shadow:0 4px 10px rgba(0,0,0,.4); font-size:13px;">
<!-- suggestions injected here -->
</div>
</div>
</div>
<div class="theme-row" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<label for="random-secondary-theme" class="field-label" style="min-width:110px; font-weight:600;">Secondary <span style="font-size:11px; color:var(--text-muted,#94a3b8); font-weight:400;">(optional)</span></label>
<div style="position:relative; flex:1 1 260px; min-width:220px;">
<input id="random-secondary-theme" name="secondary_theme" type="text" placeholder="Optional secondary" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="secondary-theme-suggest-box" aria-controls="secondary-theme-suggest-box" aria-describedby="theme-help" aria-haspopup="listbox" />
<div id="secondary-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:21; max-height:220px; overflow-y:auto; box-shadow:0 4px 10px rgba(0,0,0,.4); font-size:13px;">
<!-- suggestions injected here -->
</div>
</div>
</div>
<div class="theme-row" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<label for="random-tertiary-theme" class="field-label" style="min-width:110px; font-weight:600;">Tertiary <span style="font-size:11px; color:var(--text-muted,#94a3b8); font-weight:400;">(optional)</span></label>
<div style="position:relative; flex:1 1 260px; min-width:220px;">
<input id="random-tertiary-theme" name="tertiary_theme" type="text" placeholder="Optional tertiary" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="tertiary-theme-suggest-box" aria-controls="tertiary-theme-suggest-box" aria-describedby="theme-help" aria-haspopup="listbox" />
<div id="tertiary-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>
</div>
</div>
<div class="auto-fill-toggle" style="display:flex; flex-direction:column; gap:6px; margin-top:4px;">
<input type="hidden" id="random-auto-fill-state" name="auto_fill_enabled" value="false" />
<input type="hidden" id="random-auto-fill-secondary-state" name="auto_fill_secondary_enabled" value="false" />
<input type="hidden" id="random-auto-fill-tertiary-state" name="auto_fill_tertiary_enabled" value="false" />
<div style="display:flex; align-items:flex-start; gap:12px;">
<div style="min-width:110px; font-weight:600; margin-top:2px; display:flex; align-items:center; gap:6px;">
<span>Auto-fill themes</span>
<span class="theme-tooltip auto-fill-tooltip" data-open="false">
<button type="button" class="help-icon" aria-expanded="false" aria-controls="auto-fill-tooltip-panel" aria-describedby="auto-fill-tooltip-text">?</button>
<span id="auto-fill-tooltip-text" class="sr-only">Auto-fill only fills empty slots; manual entries always take priority.</span>
<div id="auto-fill-tooltip-panel" class="tooltip-panel" role="dialog" aria-modal="false">
<p>We only auto-fill the theme slots you leave empty. Manual entries always take priority.</p>
</div>
</span>
</div>
<div style="display:flex; flex-direction:column; gap:4px;">
<div style="display:flex; align-items:center; gap:16px; flex-wrap:wrap;">
<label for="random-auto-fill-secondary" style="display:inline-flex; align-items:center; gap:6px; font-size:12px;">
<input id="random-auto-fill-secondary" type="checkbox" aria-describedby="auto-fill-tooltip-text" />
<span>Fill Secondary</span>
</label>
<label for="random-auto-fill-tertiary" style="display:inline-flex; align-items:center; gap:6px; font-size:12px;">
<input id="random-auto-fill-tertiary" type="checkbox" aria-describedby="auto-fill-tooltip-text" />
<span>Fill Tertiary</span>
</label>
</div>
</div>
</div>
</div>
<div class="strict-toggle" style="margin-top:4px;">
<input type="hidden" id="random-strict-theme-hidden" name="strict_theme_match" value="{{ 'true' if strict_theme_match|default(false) else 'false' }}" />
<label for="random-strict-theme" style="display:inline-flex; align-items:center; gap:6px; cursor:pointer;">
<input id="random-strict-theme" type="checkbox" aria-describedby="random-strict-theme-help" {% if strict_theme_match|default(false) %}checked{% endif %} />
<span>Require exact theme match (no fallbacks)</span>
</label>
<span id="random-strict-theme-help" class="sr-only">When enabled, themes must match exactly. No fallback themes will be used.</span>
</div>
<input type="hidden" id="random-legacy-theme" name="theme" value="" />
</fieldset>
<div class="action-row" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
{% if show_diagnostics %}
<div class="diagnostics" style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; font-size:11px;">
<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" />
</div>
{% endif %}
<div class="button-row" style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<!-- 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-primary-theme,#random-secondary-theme,#random-tertiary-theme,#random-legacy-theme,#random-auto-fill-state,#random-auto-fill-secondary-state,#random-auto-fill-tertiary-state,#random-strict-theme-hidden{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click" 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,#current-primary-theme,#current-secondary-theme,#current-tertiary-theme,#current-resolved-themes,#current-auto-fill-enabled,#current-auto-fill-secondary-enabled,#current-auto-fill-tertiary-enabled,#current-strict-theme-match,#random-primary-theme,#random-secondary-theme,#random-tertiary-theme,#random-legacy-theme,#random-auto-fill-state,#random-auto-fill-secondary-state,#random-auto-fill-tertiary-state,#random-strict-theme-hidden{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Reroll" disabled>Reroll</button>
<span id="spinner" role="status" aria-live="polite" style="display:none;">Loading…</span>
</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…
@ -40,78 +133,334 @@
</div>
<script>
(function(){
// Typeahead: simple debounce + /themes/suggest
var input = document.getElementById('random-theme');
var listBox = document.getElementById('theme-suggest-box');
var to = null;
// Typeahead: debounce + /themes/suggest shared across inputs
var legacyInput = document.getElementById('random-legacy-theme');
var AUTO_FILL_KEY = 'random_auto_fill_enabled';
var AUTO_FILL_SECONDARY_KEY = 'random_auto_fill_secondary_enabled';
var AUTO_FILL_TERTIARY_KEY = 'random_auto_fill_tertiary_enabled';
var THEME_KEY = 'random_last_primary_theme';
var LEGACY_KEY = 'random_last_theme';
var SECONDARY_KEY = 'random_last_secondary_theme';
var TERTIARY_KEY = 'random_last_tertiary_theme';
var themeInputs = {
primary: {
input: document.getElementById('random-primary-theme'),
list: document.getElementById('primary-theme-suggest-box'),
syncLegacy: true,
},
secondary: {
input: document.getElementById('random-secondary-theme'),
list: document.getElementById('secondary-theme-suggest-box'),
},
tertiary: {
input: document.getElementById('random-tertiary-theme'),
list: document.getElementById('tertiary-theme-suggest-box'),
}
};
var primaryInput = themeInputs.primary.input;
var secondaryInput = themeInputs.secondary.input;
var tertiaryInput = themeInputs.tertiary.input;
var clearThemesButton = document.getElementById('random-clear-themes');
var autoFillHidden = document.getElementById('random-auto-fill-state');
var autoFillSecondaryHidden = document.getElementById('random-auto-fill-secondary-state');
var autoFillTertiaryHidden = document.getElementById('random-auto-fill-tertiary-state');
var autoFillSecondaryInput = document.getElementById('random-auto-fill-secondary');
var autoFillTertiaryInput = document.getElementById('random-auto-fill-tertiary');
var strictHidden = document.getElementById('random-strict-theme-hidden');
var strictCheckbox = document.getElementById('random-strict-theme');
var STRICT_KEY = 'random_strict_theme_match';
var debounceTimers = new Map();
var cache = new Map(); // simple in-memory cache of q -> [names]
var REROLL_CACHE_STORAGE_KEY = 'random_theme_suggest_cache_v1';
var throttleRaw = "{{ random_reroll_throttle_ms | default(350) }}";
var REROLL_THROTTLE_MS = parseInt(throttleRaw, 10);
if (isNaN(REROLL_THROTTLE_MS) || REROLL_THROTTLE_MS < 0) {
REROLL_THROTTLE_MS = 0;
}
var throttleTimerId = null;
var throttleUnlockAt = 0;
var lastRandomRequestAt = 0;
var rateLimitIntervalId = null;
var activeIndex = -1; // keyboard highlight
function hideList(){ if(listBox){ listBox.style.display='none'; input.setAttribute('aria-expanded','false'); activeIndex=-1; } }
var activeKey = null;
try {
var persistedCache = sessionStorage.getItem(REROLL_CACHE_STORAGE_KEY);
if (persistedCache) {
var parsedCache = JSON.parse(persistedCache);
if (Array.isArray(parsedCache)) {
parsedCache.forEach(function(entry) {
if (!entry || typeof entry.q !== 'string') {
return;
}
var key = entry.q.toLowerCase();
var results = Array.isArray(entry.results) ? entry.results : [];
cache.set(key, results);
});
}
}
} catch (e) { /* ignore storage failures */ }
function enforceTertiaryRequirement(){
if(autoFillTertiaryInput && autoFillSecondaryInput){
if(autoFillTertiaryInput.checked && !autoFillSecondaryInput.checked){
autoFillSecondaryInput.checked = true;
}
}
if(autoFillSecondaryInput && autoFillTertiaryInput){
var secChecked = !!autoFillSecondaryInput.checked;
autoFillTertiaryInput.disabled = !secChecked;
if(!secChecked && autoFillTertiaryInput.checked){
autoFillTertiaryInput.checked = false;
}
} else if(autoFillTertiaryInput){
autoFillTertiaryInput.disabled = false;
}
}
function currentAutoFillFlags(){
enforceTertiaryRequirement();
var secondaryChecked = !!(autoFillSecondaryInput && autoFillSecondaryInput.checked);
var tertiaryChecked = !!(secondaryChecked && autoFillTertiaryInput && autoFillTertiaryInput.checked);
return {
secondary: secondaryChecked,
tertiary: tertiaryChecked,
};
}
function syncAutoFillHidden(){
var flags = currentAutoFillFlags();
if(autoFillSecondaryHidden){ autoFillSecondaryHidden.value = flags.secondary ? 'true' : 'false'; }
if(autoFillTertiaryHidden){ autoFillTertiaryHidden.value = flags.tertiary ? 'true' : 'false'; }
if(autoFillHidden){ autoFillHidden.value = (flags.secondary || flags.tertiary) ? 'true' : 'false'; }
}
function persistAutoFillPreferences(){
try{
var flags = currentAutoFillFlags();
localStorage.setItem(AUTO_FILL_SECONDARY_KEY, flags.secondary ? '1' : '0');
localStorage.setItem(AUTO_FILL_TERTIARY_KEY, flags.tertiary ? '1' : '0');
localStorage.setItem(AUTO_FILL_KEY, (flags.secondary || flags.tertiary) ? '1' : '0');
}catch(e){ /* ignore */ }
}
function syncStrictHidden(){
if(strictHidden){
var checked = !!(strictCheckbox && strictCheckbox.checked);
strictHidden.value = checked ? 'true' : 'false';
}
}
function persistStrictPreference(){
if(!strictCheckbox) return;
try{
localStorage.setItem(STRICT_KEY, strictCheckbox.checked ? '1' : '0');
}catch(e){ /* ignore */ }
}
function hideAllLists(){
Object.keys(themeInputs).forEach(function(key){
var cfg = themeInputs[key];
if(cfg.list){ cfg.list.style.display='none'; }
if(cfg.input){
cfg.input.setAttribute('aria-expanded','false');
cfg.input.removeAttribute('aria-activedescendant');
}
});
activeIndex = -1;
activeKey = null;
}
function syncLegacy(){
if(!legacyInput) return;
legacyInput.value = primaryInput && primaryInput.value ? primaryInput.value : '';
}
function clearThemeInputs(){
if(primaryInput){ primaryInput.value = ''; }
if(secondaryInput){ secondaryInput.value = ''; }
if(tertiaryInput){ tertiaryInput.value = ''; }
hideAllLists();
syncLegacy();
try{
localStorage.setItem(THEME_KEY, '');
localStorage.setItem(LEGACY_KEY, '');
localStorage.setItem(SECONDARY_KEY, '');
localStorage.setItem(TERTIARY_KEY, '');
}catch(e){ /* ignore */ }
if(primaryInput){ primaryInput.dispatchEvent(new Event('change')); }
if(secondaryInput){ secondaryInput.dispatchEvent(new Event('change')); }
if(tertiaryInput){ tertiaryInput.dispatchEvent(new Event('change')); }
if(primaryInput){ primaryInput.focus(); }
}
function collectThemePayload(extra){
syncAutoFillHidden();
syncStrictHidden();
var payload = Object.assign({}, extra || {});
var primaryVal = primaryInput && primaryInput.value ? primaryInput.value.trim() : null;
var secondaryVal = secondaryInput && secondaryInput.value ? secondaryInput.value.trim() : null;
var tertiaryVal = tertiaryInput && tertiaryInput.value ? tertiaryInput.value.trim() : null;
payload.theme = primaryVal;
payload.primary_theme = primaryVal;
payload.secondary_theme = secondaryVal || null;
payload.tertiary_theme = tertiaryVal || null;
var flags = currentAutoFillFlags();
if(!autoFillSecondaryInput && autoFillSecondaryHidden){
var secVal = (autoFillSecondaryHidden.value || '').toLowerCase();
flags.secondary = (secVal === 'true' || secVal === '1' || secVal === 'on');
}
if(!autoFillTertiaryInput && autoFillTertiaryHidden){
var terVal = (autoFillTertiaryHidden.value || '').toLowerCase();
flags.tertiary = (terVal === 'true' || terVal === '1' || terVal === 'on');
}
if(flags.tertiary && !flags.secondary){
flags.secondary = true;
}
if(!flags.secondary){
flags.tertiary = false;
}
payload.auto_fill_secondary_enabled = flags.secondary;
payload.auto_fill_tertiary_enabled = flags.tertiary;
payload.auto_fill_enabled = flags.secondary || flags.tertiary;
var strictFlag = false;
if(strictCheckbox){
strictFlag = !!strictCheckbox.checked;
} else if(strictHidden){
var strictVal = (strictHidden.value || '').toLowerCase();
strictFlag = (strictVal === 'true' || strictVal === '1' || strictVal === 'on');
}
payload.strict_theme_match = strictFlag;
return payload;
}
async function submitRandomPayload(payload){
if(!payload) payload = {};
if(!canTriggerRandomRequest()){
return;
}
markRandomRequestStarted();
var spinner = document.getElementById('spinner');
if(spinner){ spinner.style.display = 'inline-block'; }
try{
var res = await fetch('/hx/random_reroll', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload),
credentials: 'same-origin'
});
if(res.status === 429){
var ra = res.headers ? res.headers.get('Retry-After') : null;
var secs = ra ? parseInt(ra, 10) : null;
if(window.toast){ try{ toast('Too many requests'); }catch(e){} }
if(secs && !isNaN(secs)){ applyRetryAfterSeconds(secs); }
else { showRateLimitBanner(null, 'Too many requests'); }
return;
}
if(!res.ok){
if(window.toast){ try{ toast('Failed to load random build'); }catch(e){} }
return;
}
var html = await res.text();
var target = document.getElementById('random-result');
if(target){ target.outerHTML = html; }
hideRateLimitBanner();
}catch(err){
if(window.toast){ try{ toast('Error contacting server'); }catch(e){} }
}finally{
if(spinner){ spinner.style.display = 'none'; }
enableRandomButtonsIfReady();
}
}
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; }
function renderList(key, items, q){
var cfg = themeInputs[key];
if(!cfg || !cfg.list){ return; }
var list = cfg.list;
list.innerHTML='';
activeIndex = -1;
if(!items || !items.length){
list.style.display='none';
if(cfg.input){
cfg.input.setAttribute('aria-expanded','false');
cfg.input.removeAttribute('aria-activedescendant');
}
activeKey = null;
return;
}
items.slice(0,50).forEach(function(it, idx){
var div=document.createElement('div');
div.setAttribute('role','option');
div.setAttribute('data-value', it);
var optionId = key + '-theme-option-' + idx;
div.id = optionId;
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);
div.addEventListener('mousedown', function(ev){ ev.preventDefault(); pick(key, it); });
list.appendChild(div);
});
listBox.style.display='block';
input.setAttribute('aria-expanded','true');
list.style.display='block';
if(cfg.input){
cfg.input.setAttribute('aria-expanded','true');
cfg.input.removeAttribute('aria-activedescendant');
}
activeKey = key;
}
function currentList(){
if(!activeKey) return null;
var cfg = themeInputs[activeKey];
return cfg ? cfg.list : null;
}
function setActive(idx){
if(!listBox) return; var children=[...listBox.children];
var list = currentList();
if(!list) return;
var children=[...list.children];
children.forEach(function(c,i){ c.style.background = (i===idx) ? 'rgba(99,102,241,0.35)' : 'transparent'; });
var cfg = activeKey ? themeInputs[activeKey] : null;
if(cfg && cfg.input){
if(idx >= 0 && children[idx]){
cfg.input.setAttribute('aria-activedescendant', children[idx].id || '');
} else {
cfg.input.removeAttribute('aria-activedescendant');
}
}
activeIndex = idx;
}
function move(delta){
if(!listBox || listBox.style.display==='none'){ return; }
var children=[...listBox.children]; if(!children.length) return;
var list = currentList();
if(!list || list.style.display==='none'){ return; }
var children=[...list.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 pick(key, value){
var cfg = themeInputs[key];
if(!cfg || !cfg.input) return;
cfg.input.value = value;
if(key === 'primary'){ syncLegacy(); }
hideAllLists();
cfg.input.dispatchEvent(new Event('change'));
cfg.input.removeAttribute('aria-activedescendant');
cfg.input.focus();
}
function persistSuggestCache(){
try{
if(typeof sessionStorage === 'undefined'){ return; }
var entries = [];
cache.forEach(function(results, q){
entries.push({ q: q, results: Array.isArray(results) ? results.slice(0, 40) : [] });
});
if(entries.length > 75){
entries = entries.slice(entries.length - 75);
}
sessionStorage.setItem(REROLL_CACHE_STORAGE_KEY, JSON.stringify(entries));
}catch(e){ /* ignore */ }
}
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){
async function fetchSuggest(q, key){
try{
var cachedKey = (q || '').toLowerCase();
var u = '/themes/api/suggest' + (q? ('?q=' + encodeURIComponent(q)) : '');
if(cache.has(q)) { updateList(cache.get(q)); return; }
if(cache.has(cachedKey)) { renderList(key, cache.get(cachedKey), q); return; }
var r = await fetch(u);
if(r.status === 429){
var ra = r.headers.get('Retry-After');
@ -120,45 +469,244 @@
if(secs && !isNaN(secs)) msg += ' — retry in ' + secs + 's';
if(window.toast) { toast(msg); } else { console.warn(msg); }
showRateLimitBanner(secs);
return updateList([]);
hideAllLists();
return;
}
if(!r.ok) return updateList([]);
if(!r.ok){ renderList(key, [], q); return; }
var j = await r.json();
var items = (j && j.themes) || [];
cache.set(q, items);
// cap cache size to 50
if(cache.size > 50){
cache.set(cachedKey, items);
// cap cache size to 60
if(cache.size > 60){
var firstKey = cache.keys().next().value; cache.delete(firstKey);
}
updateList(items, q);
persistSuggestCache();
renderList(key, 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);
function attachInput(key){
var cfg = themeInputs[key];
if(!cfg || !cfg.input) return;
cfg.input.addEventListener('input', function(){
if(key === 'primary'){ syncLegacy(); }
var q = cfg.input.value || '';
var existingTimer = debounceTimers.get(key);
if(existingTimer) clearTimeout(existingTimer);
if(!q || q.length < 2){ hideAllLists(); return; }
var timerId = setTimeout(function(){ fetchSuggest(q, key); }, 150);
debounceTimers.set(key, timerId);
});
input.addEventListener('keydown', function(ev){
cfg.input.addEventListener('focus', function(){
var q = cfg.input.value || '';
if(q && q.length >= 2){ fetchSuggest(q, key); }
});
cfg.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(); }
else if(ev.key === 'Enter'){
var list = currentList();
if(list && activeKey && activeIndex >= 0 && list.children[activeIndex]){
ev.preventDefault();
pick(activeKey, list.children[activeIndex].getAttribute('data-value'));
} else {
hideAllLists();
}
}
else if(ev.key === 'Escape'){ hideAllLists(); }
});
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.
Object.keys(themeInputs).forEach(attachInput);
document.addEventListener('click', function(ev){
var target = ev.target;
var insideInput = Object.keys(themeInputs).some(function(key){ var cfg = themeInputs[key]; return cfg.input && cfg.input === target; });
var insideList = Object.keys(themeInputs).some(function(key){ var cfg = themeInputs[key]; return cfg.list && cfg.list.contains(target); });
if(!insideInput && !insideList){ hideAllLists(); }
});
if(clearThemesButton){
clearThemesButton.addEventListener('click', function(){
clearThemeInputs();
if(window.toast){ try{ toast('Themes cleared'); }catch(e){ /* ignore */ } }
});
}
if(primaryInput){ primaryInput.addEventListener('change', syncLegacy); syncLegacy(); }
function disableRandomButtons(){
var btn1 = document.getElementById('btn-surprise');
var btn2 = document.getElementById('btn-reroll');
if(btn1) btn1.disabled = true;
if(btn2) btn2.disabled = true;
}
function enableRandomButtonsIfReady(){
var btn1 = document.getElementById('btn-surprise');
var btn2 = document.getElementById('btn-reroll');
var now = Date.now();
if(REROLL_THROTTLE_MS > 0 && now < throttleUnlockAt){
scheduleThrottleUnlock();
return;
}
if(throttleTimerId){
clearTimeout(throttleTimerId);
throttleTimerId = null;
}
if(btn1) btn1.disabled = false;
if(btn2){
btn2.disabled = !document.getElementById('current-seed');
}
hideRateLimitBanner();
}
function scheduleThrottleUnlock(){
if(REROLL_THROTTLE_MS <= 0){ return; }
if(throttleTimerId){ clearTimeout(throttleTimerId); }
var delay = Math.max(0, throttleUnlockAt - Date.now());
if(delay === 0){
enableRandomButtonsIfReady();
return;
}
throttleTimerId = setTimeout(function(){ enableRandomButtonsIfReady(); }, delay);
}
function showRateLimitBanner(seconds, message){
var b = document.getElementById('rate-limit-banner');
if(!b){ return; }
var secs = (typeof seconds === 'number' && !isNaN(seconds) && seconds > 0) ? Math.floor(seconds) : null;
var base = message || 'Please wait before trying again';
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';
disableRandomButtons();
if(rateLimitIntervalId){
clearInterval(rateLimitIntervalId);
rateLimitIntervalId = null;
}
if(secs !== null){
rateLimitIntervalId = setInterval(function(){
secs -= 1; update();
if(secs <= 0){
if(rateLimitIntervalId){
clearInterval(rateLimitIntervalId);
rateLimitIntervalId = null;
}
hideRateLimitBanner();
enableRandomButtonsIfReady();
}
}, 1000);
}
}
function hideRateLimitBanner(){
var b = document.getElementById('rate-limit-banner');
if(b){ b.style.display = 'none'; }
if(rateLimitIntervalId){
clearInterval(rateLimitIntervalId);
rateLimitIntervalId = null;
}
}
function remainingThrottleMs(){
if(REROLL_THROTTLE_MS <= 0) return 0;
return Math.max(0, throttleUnlockAt - Date.now());
}
function canTriggerRandomRequest(){
if(REROLL_THROTTLE_MS <= 0) return true;
var remaining = remainingThrottleMs();
if(remaining <= 0){ return true; }
showRateLimitBanner(Math.ceil(remaining / 1000), 'Hold up — reroll throttle active');
scheduleThrottleUnlock();
return false;
}
function markRandomRequestStarted(){
lastRandomRequestAt = Date.now();
if(REROLL_THROTTLE_MS <= 0){ return; }
throttleUnlockAt = lastRandomRequestAt + REROLL_THROTTLE_MS;
disableRandomButtons();
scheduleThrottleUnlock();
}
function applyRetryAfterSeconds(seconds){
if(!seconds || isNaN(seconds) || seconds <= 0){ return; }
throttleUnlockAt = Date.now() + (seconds * 1000);
disableRandomButtons();
scheduleThrottleUnlock();
showRateLimitBanner(seconds, 'Too many requests');
}
document.addEventListener('htmx:configRequest', function(ev){
var detail = ev && ev.detail;
if(!detail) return;
var path = detail.path || '';
if(typeof path !== 'string') return;
if(path.indexOf('/random_reroll') === -1){ return; }
if(!canTriggerRandomRequest()){
ev.preventDefault();
return;
}
markRandomRequestStarted();
});
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;
enableRandomButtonsIfReady();
});
// (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;
var rr = document.getElementById('btn-reroll');
if(rr){
if(remainingThrottleMs() > 0){
rr.disabled = true;
scheduleThrottleUnlock();
} else {
rr.disabled = false;
}
}
var hiddenAuto = document.getElementById('current-auto-fill-enabled');
var aggregated = null;
if(hiddenAuto){
var aggVal = (hiddenAuto.value || '').toLowerCase();
if(aggVal){ aggregated = (aggVal === 'true' || aggVal === '1' || aggVal === 'on'); }
}
var hiddenSecondary = document.getElementById('current-auto-fill-secondary-enabled');
var hiddenTertiary = document.getElementById('current-auto-fill-tertiary-enabled');
if(autoFillSecondaryInput){
var secNext = null;
if(hiddenSecondary){
var secVal = (hiddenSecondary.value || '').toLowerCase();
secNext = (secVal === 'true' || secVal === '1' || secVal === 'on');
} else if(aggregated !== null){
secNext = aggregated;
}
if(secNext !== null){ autoFillSecondaryInput.checked = !!secNext; }
}
if(autoFillTertiaryInput){
var terNext = null;
if(hiddenTertiary){
var terVal = (hiddenTertiary.value || '').toLowerCase();
terNext = (terVal === 'true' || terVal === '1' || terVal === 'on');
} else if(aggregated !== null){
terNext = aggregated;
}
if(terNext !== null){ autoFillTertiaryInput.checked = !!terNext; }
}
enforceTertiaryRequirement();
var currentStrict = document.getElementById('current-strict-theme-match');
if(strictCheckbox && currentStrict){
var strictVal = (currentStrict.value || '').toLowerCase();
strictCheckbox.checked = (strictVal === 'true' || strictVal === '1' || strictVal === 'on');
} else if(strictHidden && currentStrict){
var hiddenVal = (currentStrict.value || '').toLowerCase();
strictHidden.value = (hiddenVal === 'true' || hiddenVal === '1' || hiddenVal === 'on') ? 'true' : 'false';
}
syncStrictHidden();
persistStrictPreference();
syncAutoFillHidden();
persistAutoFillPreferences();
hideRateLimitBanner();
// Refresh recent seeds asynchronously
fetch('/api/random/seeds').then(r=>r.json()).then(function(j){
try{
@ -181,7 +729,8 @@
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);
if(secs && !isNaN(secs)) applyRetryAfterSeconds(secs);
else showRateLimitBanner(null, 'Too many requests');
}
}catch(e){/* no-op */}
});
@ -210,8 +759,8 @@
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; } });
var payload = collectThemePayload({ seed: s-1 });
submitRandomPayload(payload);
});
container.appendChild(btn);
});
@ -229,10 +778,8 @@
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(()=>{});
var payload = collectThemePayload({ seed: s-1 });
submitRandomPayload(payload);
});
span.appendChild(b);
span.appendChild(favoriteButton(s, favorites || []));
@ -260,13 +807,114 @@
// 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 || ''); });
var THEME_KEY='random_last_primary_theme';
var LEGACY_KEY='random_last_theme';
var SECONDARY_KEY='random_last_secondary_theme';
var TERTIARY_KEY='random_last_tertiary_theme';
if(primaryInput){
var prev = localStorage.getItem(THEME_KEY) || localStorage.getItem(LEGACY_KEY);
if(prev && !primaryInput.value){ primaryInput.value = prev; }
primaryInput.addEventListener('change', function(){
localStorage.setItem(THEME_KEY, primaryInput.value || '');
localStorage.setItem(LEGACY_KEY, primaryInput.value || '');
syncLegacy();
});
syncLegacy();
}
} catch(e) { /* ignore */ }
if(secondaryInput){
var prevSecondary = localStorage.getItem(SECONDARY_KEY);
if(prevSecondary && !secondaryInput.value){ secondaryInput.value = prevSecondary; }
secondaryInput.addEventListener('change', function(){
localStorage.setItem(SECONDARY_KEY, secondaryInput.value || '');
});
}
if(tertiaryInput){
var prevTertiary = localStorage.getItem(TERTIARY_KEY);
if(prevTertiary && !tertiaryInput.value){ tertiaryInput.value = prevTertiary; }
tertiaryInput.addEventListener('change', function(){
localStorage.setItem(TERTIARY_KEY, tertiaryInput.value || '');
});
}
var legacyAutoFill = null;
try { legacyAutoFill = localStorage.getItem(AUTO_FILL_KEY); } catch(e){ legacyAutoFill = null; }
function restoreAutoFillCheckbox(input, storedValue){
if(!input) return;
if(storedValue !== null){
input.checked = (storedValue === '1' || storedValue === 'true' || storedValue === 'on');
}
input.addEventListener('change', function(){
enforceTertiaryRequirement();
syncAutoFillHidden();
persistAutoFillPreferences();
});
}
var storedSecondary = null;
try { storedSecondary = localStorage.getItem(AUTO_FILL_SECONDARY_KEY); } catch(e){ storedSecondary = null; }
if(storedSecondary === null){ storedSecondary = legacyAutoFill; }
restoreAutoFillCheckbox(autoFillSecondaryInput, storedSecondary);
var storedTertiary = null;
try { storedTertiary = localStorage.getItem(AUTO_FILL_TERTIARY_KEY); } catch(e){ storedTertiary = null; }
if(storedTertiary === null){ storedTertiary = legacyAutoFill; }
restoreAutoFillCheckbox(autoFillTertiaryInput, storedTertiary);
enforceTertiaryRequirement();
syncAutoFillHidden();
persistAutoFillPreferences();
if(strictCheckbox){
var storedStrict = null;
try { storedStrict = localStorage.getItem(STRICT_KEY); } catch(e){ storedStrict = null; }
if(storedStrict !== null){
strictCheckbox.checked = (storedStrict === '1' || storedStrict === 'true' || storedStrict === 'on');
}
syncStrictHidden();
strictCheckbox.addEventListener('change', function(){
syncStrictHidden();
persistStrictPreference();
});
persistStrictPreference();
} else {
syncStrictHidden();
}
} catch(e) { /* ignore */ }
syncStrictHidden();
(function(){
var tooltipWrappers = document.querySelectorAll('.theme-tooltip');
if(!tooltipWrappers.length) return;
tooltipWrappers.forEach(function(tooltipWrapper){
var button = tooltipWrapper.querySelector('button.help-icon');
var panel = tooltipWrapper.querySelector('.tooltip-panel');
if(!button || !panel) return;
function setOpen(state){
tooltipWrapper.dataset.open = state ? 'true' : 'false';
button.setAttribute('aria-expanded', state ? 'true' : 'false');
}
function handleDocumentClick(ev){
if(!tooltipWrapper.contains(ev.target)){ setOpen(false); }
}
button.addEventListener('click', function(){
var isOpen = tooltipWrapper.dataset.open === 'true';
setOpen(!isOpen);
if(!isOpen){ panel.focus({preventScroll:true}); }
});
button.addEventListener('keypress', function(ev){
if(ev.key === 'Enter' || ev.key === ' '){ ev.preventDefault(); button.click(); }
});
tooltipWrapper.addEventListener('mouseenter', function(){ setOpen(true); });
tooltipWrapper.addEventListener('mouseleave', function(){ setOpen(false); });
button.addEventListener('focus', function(){ setOpen(true); });
button.addEventListener('blur', function(){
setTimeout(function(){
if(!tooltipWrapper.contains(document.activeElement)){ setOpen(false); }
}, 0);
});
panel.setAttribute('tabindex','-1');
panel.addEventListener('keydown', function(ev){ if(ev.key === 'Escape'){ setOpen(false); button.focus(); } });
document.addEventListener('click', handleDocumentClick);
});
})();
})();
</script>
{% endif %}

View file

@ -0,0 +1,35 @@
# Curated exclusions for Random Mode auto-fill suggestions
#
# Each entry lists a set of tokens we explicitly remove from the curated
# random theme pool along with the reason. These tokens remain searchable
# via manual text entry; they are excluded only from surprise/random
# suggestions and auto-fill assistance.
manual_exclusions:
- category: ubiquitous_baseline
summary: Baseline game actions that nearly every Commander deck shares.
tokens:
- card advantage
- card draw
- removal
- interaction
notes: Manual typing will still find these, but surfacing them in surprise mode is not actionable.
- category: degenerate_catchall
summary: Broad "good stuff"/"value" descriptors that do not communicate a cohesive plan.
tokens:
- value
- good stuff
- goodstuff
- good-stuff
- midrange value
notes: Users should choose the underlying themes they actually want instead of generic catch-alls.
- category: non_theme_qualifiers
summary: Tags that describe constraints outside of theme-building.
tokens:
- budget
- competitive
- cedh
- high power
notes: These categories belong in power settings rather than surprise theme suggestions.

View file

@ -0,0 +1,59 @@
# Random Mode Theme Exclusions
The curated random theme pool keeps auto-fill suggestions focused on themes that lead to actionable Commander builds. This document summarizes the heuristics and manual exclusions that shape the pool and explains how to discover every theme when you want to override the curated list.
## Heuristics applied automatically
We remove a theme token from the curated pool when any of the following conditions apply:
1. **Insufficient examples** fewer than five unique commanders in the catalog advertise the token.
2. **Kindred and species-specific labels** anything matching keywords such as `kindred`, `tribal`, `clan`, or endings like `" tribe"` is treated as commander-specific and filtered out.
3. **Global catch-alls** broad phrases (for example `goodstuff`, `legendary matter`, `historic matter`) offer little guidance for theme selection, so they are excluded.
4. **Over-represented themes** if 30% or more of the commander catalog advertises a token, it is removed from the surprise pool to keep suggestions varied.
These rules are codified in `code/deck_builder/random_entrypoint.py` and surfaced via the diagnostics panel and the reporting script.
## Manual exclusions
Some descriptors are technically valid tokens but still degrade the surprise experience. They live in `config/random_theme_exclusions.yml` so we can document why they are hidden and keep the list reviewable.
| Category | Why it is excluded | Tokens |
| --- | --- | --- |
| `ubiquitous_baseline` | Baseline game actions every deck performs; surfacing them would be redundant. | `card advantage`, `card draw`, `removal`, `interaction` |
| `degenerate_catchall` | Generic "good stuff" style descriptors that do not communicate a coherent plan. | `value`, `good stuff`, `goodstuff`, `good-stuff`, `midrange value` |
| `non_theme_qualifiers` | Power-level or budget qualifiers; these belong in settings, not theme suggestions. | `budget`, `competitive`, `cedh`, `high power` |
Themes removed here still resolve just fine when you type them manually into any theme field or when you import them from permalinks, sessions, or the CLI.
### Keeping the list discoverable
The reporting script can export the manual list alongside the curated pool:
```powershell
# Markdown summary with exclusions
python code/scripts/report_random_theme_pool.py --format markdown
# Structured exclusions for tooling
python code/scripts/report_random_theme_pool.py --write-exclusions logs/random_theme_exclusions.json
```
Both commands refresh the commander catalog on demand and mirror the exact heuristics used by the web UI and API.
## Surfacing the information in the app
When diagnostics are enabled (`SHOW_DIAGNOSTICS=1`), the `/diagnostics` panel shows:
- Total curated pool size and coverage.
- Counts per exclusion reason (including manual categories).
- Sample tokens and the manual categories that removed them.
- Tag index telemetry (build count, cache hit rate) for performance monitoring.
This makes it easy to audit the pool after catalog or heuristic changes.
## Updating the manual list
1. Edit `config/random_theme_exclusions.yml` and add or adjust entries (keep tokens lowercase; normalization happens automatically).
2. Run `python code/scripts/report_random_theme_pool.py --format markdown --refresh` to verify the pool summary.
3. Commit the YAML update together with the regenerated documentation when you are satisfied.
The curated pool will pick up the change automatically thanks to the file timestamp watcher in `random_entrypoint.py`.