mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
feat(random): finalize multi-theme telemetry and polish
Some checks failed
Editorial Lint / lint-editorial (push) Has been cancelled
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:
parent
73685f22c8
commit
49f1f8b2eb
28 changed files with 4888 additions and 251 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -14,7 +14,17 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### 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).
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Optional multi-pass performance CI variant (`preview_perf_ci_check.py --multi-pass`) to collect cold vs warm pass stats when diagnosing divergence.
|
||||||
|
|
||||||
### Changed
|
### 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.3 ms / p95 ~21 ms 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.
|
- 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.
|
- 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`.
|
- 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).
|
- 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.
|
- 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.
|
- 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 4–5 color commanders), role saturation penalty, refined commander overlap scaling curve.
|
- Sampling refinements: rarity diminishing weight, splash leniency (single off-color allowance with penalty for 4–5 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.
|
- 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
|
### 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.
|
- Removed redundant template environment instantiation causing inconsistent navigation state.
|
||||||
- Ensured preview cache key includes catalog ETag to prevent stale sample reuse after catalog reload.
|
- 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.
|
- Explicit cache bust after tagging/catalog rebuild prevents stale preview exposure.
|
||||||
|
|
|
||||||
BIN
README.md
BIN
README.md
Binary file not shown.
|
|
@ -3,28 +3,20 @@
|
||||||
## Unreleased (Draft)
|
## Unreleased (Draft)
|
||||||
|
|
||||||
### Added
|
### 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.
|
- 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.
|
||||||
- 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>`.
|
|
||||||
- Analytics: splash penalty counters recognize both static and adaptive reasons; compare deltas with the flag toggled.
|
- 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 sub‑50ms typical list queries on warm path.
|
- 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.
|
||||||
- Skeleton loading UI for theme picker list, preview modal, and initial shell.
|
- 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`.
|
||||||
- Theme preview endpoint (`/themes/api/theme/{id}/preview` + HTML fragment) returning representative sample with roles (payoff/enabler/support/wildcard/example/curated_synergy/synthetic).
|
- Random Mode UI adds a “Clear themes” control that resets Primary/Secondary/Tertiary inputs plus local persistence in a single click.
|
||||||
- Commander bias heuristics in preview sampling (color identity filtering + overlap/theme bonuses) for context-aware suggestions.
|
- Diagnostics: Added `/status/random_theme_stats` and a diagnostics dashboard card surfacing commander/theme token coverage and top tokens for multi-theme debugging.
|
||||||
- In‑memory 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.
|
|
||||||
- Cache bust hooks tied to catalog refresh & tagging completion clear filter/preview caches (metrics now include last bust timestamps).
|
- 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.
|
- 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.
|
- Server authoritative mana & color identity fields (`mana_cost`, `color_identity_list`, `pip_colors`) included in preview/export; legacy client parsers removed.
|
||||||
|
|
||||||
### Changed
|
### 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.3 ms / p95 ~21 ms 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.
|
- 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.
|
- 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.
|
- 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
|
### Added
|
||||||
- Theme whitelist governance (`config/themes/theme_whitelist.yml`) with normalization, enforced synergies, and synergy cap (5).
|
- 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.
|
- 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.
|
- Test: `test_theme_whitelist_and_synergy_cap.py` validates enforced synergy presence and cap compliance.
|
||||||
- PyYAML dependency for governance parsing.
|
- PyYAML dependency for governance parsing.
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
118
code/scripts/check_random_theme_perf.py
Normal file
118
code/scripts/check_random_theme_perf.py
Normal 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())
|
||||||
136
code/scripts/profile_multi_theme_filter.py
Normal file
136
code/scripts/profile_multi_theme_filter.py
Normal 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()
|
||||||
193
code/scripts/report_random_theme_pool.py
Normal file
193
code/scripts/report_random_theme_pool.py
Normal 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())
|
||||||
|
|
@ -11,6 +11,7 @@ def test_random_build_api_commander_and_seed(monkeypatch):
|
||||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||||
|
|
||||||
app_module = importlib.import_module('code.web.app')
|
app_module = importlib.import_module('code.web.app')
|
||||||
|
app_module = importlib.reload(app_module)
|
||||||
client = TestClient(app_module.app)
|
client = TestClient(app_module.app)
|
||||||
|
|
||||||
payload = {"seed": 12345, "theme": "Goblin Kindred"}
|
payload = {"seed": 12345, "theme": "Goblin Kindred"}
|
||||||
|
|
@ -20,3 +21,122 @@ def test_random_build_api_commander_and_seed(monkeypatch):
|
||||||
assert data["seed"] == 12345
|
assert data["seed"] == 12345
|
||||||
assert isinstance(data.get("commander"), str)
|
assert isinstance(data.get("commander"), str)
|
||||||
assert data.get("commander")
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,66 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
def test_metrics_and_seed_history(monkeypatch):
|
def test_metrics_and_seed_history(monkeypatch):
|
||||||
monkeypatch.setenv('RANDOM_MODES', '1')
|
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||||
monkeypatch.setenv('RANDOM_UI', '1')
|
monkeypatch.setenv("RANDOM_UI", "1")
|
||||||
monkeypatch.setenv('RANDOM_TELEMETRY', '1')
|
monkeypatch.setenv("RANDOM_TELEMETRY", "1")
|
||||||
monkeypatch.setenv('CSV_FILES_DIR', os.path.join('csv_files', 'testdata'))
|
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||||
from code.web.app import app
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
# Build + reroll to generate metrics and seed history
|
import code.web.app as app_module
|
||||||
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
|
|
||||||
|
|
||||||
# Metrics
|
# Reset in-memory telemetry so assertions are deterministic
|
||||||
m = client.get('/status/random_metrics')
|
app_module.RANDOM_TELEMETRY = True
|
||||||
assert m.status_code == 200, m.text
|
app_module.RATE_LIMIT_ENABLED = False
|
||||||
mj = m.json()
|
for bucket in app_module._RANDOM_METRICS.values():
|
||||||
assert mj.get('ok') is True
|
for key in bucket:
|
||||||
metrics = mj.get('metrics') or {}
|
bucket[key] = 0
|
||||||
assert 'full_build' in metrics and 'reroll' in metrics
|
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
|
prev_ms = app_module.RANDOM_REROLL_THROTTLE_MS
|
||||||
sh = client.get('/api/random/seeds')
|
prev_seconds = app_module._REROLL_THROTTLE_SECONDS
|
||||||
assert sh.status_code == 200
|
app_module.RANDOM_REROLL_THROTTLE_MS = 0
|
||||||
sj = sh.json()
|
app_module._REROLL_THROTTLE_SECONDS = 0.0
|
||||||
seeds = sj.get('seeds') or []
|
|
||||||
assert any(s == 9090 for s in seeds) and sj.get('last') in seeds
|
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
|
||||||
|
|
|
||||||
236
code/tests/test_random_multi_theme_filtering.py
Normal file
236
code/tests/test_random_multi_theme_filtering.py
Normal 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)
|
||||||
46
code/tests/test_random_multi_theme_seed_stability.py
Normal file
46
code/tests/test_random_multi_theme_seed_stability.py
Normal 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
|
||||||
204
code/tests/test_random_multi_theme_webflows.py
Normal file
204
code/tests/test_random_multi_theme_webflows.py
Normal 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]
|
||||||
|
|
@ -32,9 +32,76 @@ def test_api_random_reroll_increments_seed(client: TestClient):
|
||||||
assert data2.get("permalink")
|
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):
|
def test_hx_random_reroll_returns_html(client: TestClient):
|
||||||
headers = {"HX-Request": "true", "Content-Type": "application/json"}
|
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
|
assert r.status_code == 200, r.text
|
||||||
# Accept either HTML fragment or JSON fallback
|
# Accept either HTML fragment or JSON fallback
|
||||||
content_type = r.headers.get("content-type", "")
|
content_type = r.headers.get("content-type", "")
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ def test_locked_reroll_generates_summary_and_compliance():
|
||||||
start = time.time()
|
start = time.time()
|
||||||
# Locked reroll via HTMX path (form style)
|
# Locked reroll via HTMX path (form style)
|
||||||
form_body = f"seed={seed}&commander={commander}&mode=reroll_same_commander"
|
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
|
assert r2.status_code == 200, r2.text
|
||||||
|
|
||||||
# Look for new sidecar/compliance created after start
|
# Look for new sidecar/compliance created after start
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,14 @@ def test_reroll_keeps_commander():
|
||||||
# First reroll with commander lock
|
# First reroll with commander lock
|
||||||
headers = {'Content-Type': 'application/json'}
|
headers = {'Content-Type': 'application/json'}
|
||||||
body = json.dumps({'seed': seed, 'commander': commander, 'mode': 'reroll_same_commander'})
|
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
|
assert r2.status_code == 200
|
||||||
html1 = r2.text
|
html1 = r2.text
|
||||||
assert commander in html1
|
assert commander in html1
|
||||||
|
|
||||||
# Second reroll should keep same commander (seed increments so prior +1 used on server)
|
# 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'})
|
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
|
assert r3.status_code == 200
|
||||||
html2 = r3.text
|
html2 = r3.text
|
||||||
assert commander in html2
|
assert commander in html2
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,12 @@ def test_reroll_keeps_commander_form_encoded():
|
||||||
seed = data1['seed']
|
seed = data1['seed']
|
||||||
|
|
||||||
form_body = f"seed={seed}&commander={quote_plus(commander)}&mode=reroll_same_commander"
|
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 r2.status_code == 200
|
||||||
assert commander in r2.text
|
assert commander in r2.text
|
||||||
|
|
||||||
# second reroll with incremented seed
|
# second reroll with incremented seed
|
||||||
form_body2 = f"seed={seed+1}&commander={quote_plus(commander)}&mode=reroll_same_commander"
|
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 r3.status_code == 200
|
||||||
assert commander in r3.text
|
assert commander in r3.text
|
||||||
|
|
@ -19,7 +19,7 @@ def test_locked_reroll_single_export():
|
||||||
commander = r.json()['commander']
|
commander = r.json()['commander']
|
||||||
before_csvs = set(glob.glob('deck_files/*.csv'))
|
before_csvs = set(glob.glob('deck_files/*.csv'))
|
||||||
form_body = f"seed={seed}&commander={commander}&mode=reroll_same_commander"
|
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
|
assert r2.status_code == 200
|
||||||
after_csvs = set(glob.glob('deck_files/*.csv'))
|
after_csvs = set(glob.glob('deck_files/*.csv'))
|
||||||
new_csvs = after_csvs - before_csvs
|
new_csvs = after_csvs - before_csvs
|
||||||
|
|
|
||||||
65
code/tests/test_random_reroll_throttle.py
Normal file
65
code/tests/test_random_reroll_throttle.py
Normal 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
|
||||||
178
code/tests/test_random_surprise_reroll_behavior.py
Normal file
178
code/tests/test_random_surprise_reroll_behavior.py
Normal 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"
|
||||||
37
code/tests/test_random_theme_stats_diagnostics.py
Normal file
37
code/tests/test_random_theme_stats_diagnostics.py
Normal 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)
|
||||||
39
code/tests/test_random_theme_tag_cache.py
Normal file
39
code/tests/test_random_theme_tag_cache.py
Normal 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
|
||||||
1065
code/web/app.py
1065
code/web/app.py
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Form, Query
|
from fastapi import APIRouter, Request, Form, Query
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from typing import Any
|
||||||
from ..app import ALLOW_MUST_HAVES # Import feature flag
|
from ..app import ALLOW_MUST_HAVES # Import feature flag
|
||||||
from ..services.build_utils import (
|
from ..services.build_utils import (
|
||||||
step5_ctx_from_result,
|
step5_ctx_from_result,
|
||||||
|
|
@ -2859,7 +2860,35 @@ async def build_permalink(request: Request):
|
||||||
rb = sess.get("random_build") or {}
|
rb = sess.get("random_build") or {}
|
||||||
if rb:
|
if rb:
|
||||||
# Only include known keys to avoid leaking unrelated session data
|
# 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:
|
if inc:
|
||||||
payload["random"] = inc
|
payload["random"] = inc
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -2914,9 +2943,43 @@ async def build_from(request: Request, state: str | None = None) -> HTMLResponse
|
||||||
try:
|
try:
|
||||||
r = data.get("random") or {}
|
r = data.get("random") or {}
|
||||||
if r:
|
if r:
|
||||||
sess["random_build"] = {
|
rb_payload: dict[str, Any] = {}
|
||||||
k: r.get(k) for k in ("seed", "theme", "constraints") if k in r
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<h3 style="margin-top:0">System summary</h3>
|
<h3 style="margin-top:0">System summary</h3>
|
||||||
<div id="sysSummary" class="muted">Loading…</div>
|
<div id="sysSummary" class="muted">Loading…</div>
|
||||||
<div id="themeSummary" style="margin-top:.5rem"></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">
|
<div style="margin-top:.35rem">
|
||||||
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
|
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
|
||||||
</div>
|
</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'; }
|
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();
|
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
|
// Theme status and reset
|
||||||
try{
|
try{
|
||||||
var tEl = document.getElementById('themeSummary');
|
var tEl = document.getElementById('themeSummary');
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
<div class="random-result" id="random-result">
|
<div class="random-result" id="random-result">
|
||||||
<style>
|
<style>
|
||||||
.diag-badges{display:inline-flex; gap:4px; margin-left:8px; flex-wrap:wrap;}
|
.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-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-badges{display:inline-flex; gap:4px; margin-left:8px; flex-wrap:wrap; align-items:center;}
|
||||||
.diag-badge.warn{background:#8a6d3b;}
|
.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.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;}
|
.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>
|
</style>
|
||||||
<div class="random-meta" style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
<div class="random-meta" style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
||||||
<span class="seed">Seed: <strong>{{ seed }}</strong></span>
|
<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>
|
<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 %}
|
{% endif %}
|
||||||
{% if show_diagnostics and diagnostics %}
|
{% if show_diagnostics and diagnostics %}
|
||||||
<span class="diag-badges" aria-label="Diagnostics" role="group">
|
<span class="diag-badges" aria-label="Diagnostics" role="status" aria-live="polite" aria-atomic="true">
|
||||||
<span class="diag-badge" title="Attempts tried before acceptance">Att {{ diagnostics.attempts }}</span>
|
<span class="diag-badge" title="Attempts tried before acceptance" aria-label="Attempts tried before acceptance">
|
||||||
<span class="diag-badge" title="Elapsed build time in milliseconds">{{ diagnostics.elapsed_ms }}ms</span>
|
<span class="badge-icon" aria-hidden="true">⟳</span>
|
||||||
{% if diagnostics.timeout_hit %}<span class="diag-badge warn" title="Generation loop exceeded timeout limit before success">Timeout</span>{% endif %}
|
<span aria-hidden="true">Att {{ diagnostics.attempts }}</span>
|
||||||
{% if diagnostics.retries_exhausted %}<span class="diag-badge warn" title="All allotted attempts were used without an early acceptable candidate">Retries</span>{% endif %}
|
</span>
|
||||||
{% 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-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>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 -->
|
<!-- 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-seed" name="seed" value="{{ seed }}" />
|
||||||
<input type="hidden" id="current-commander" name="commander" value="{{ commander }}" />
|
<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-block" style="display:flex; gap:14px; align-items:flex-start; margin-top:.75rem;">
|
||||||
<div class="commander-thumb" style="flex:0 0 auto;">
|
<div class="commander-thumb" style="flex:0 0 auto;">
|
||||||
<img
|
<img
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,118 @@
|
||||||
{% if not enable_ui %}
|
{% if not enable_ui %}
|
||||||
<div class="notice" role="status">Random UI is disabled. Set <code>RANDOM_UI=1</code> to enable.</div>
|
<div class="notice" role="status">Random UI is disabled. Set <code>RANDOM_UI=1</code> to enable.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="controls" role="group" aria-label="Random controls" style="display:flex; gap:8px; align-items:center; flex-wrap: wrap;">
|
<style>
|
||||||
<label for="random-theme" class="field-label" style="margin-right:6px;">Theme</label>
|
.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;}
|
||||||
<div style="position:relative;">
|
.theme-tooltip{position:relative; display:inline-flex; align-items:center;}
|
||||||
<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" />
|
.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;}
|
||||||
<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;">
|
.theme-tooltip button.help-icon:focus-visible{outline:2px solid var(--accent,#6366f1); outline-offset:2px;}
|
||||||
<!-- suggestions injected here -->
|
.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>
|
||||||
</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>
|
||||||
<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;">
|
<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…
|
Too many requests. Please wait…
|
||||||
|
|
@ -40,78 +133,334 @@
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
// Typeahead: simple debounce + /themes/suggest
|
// Typeahead: debounce + /themes/suggest shared across inputs
|
||||||
var input = document.getElementById('random-theme');
|
var legacyInput = document.getElementById('random-legacy-theme');
|
||||||
var listBox = document.getElementById('theme-suggest-box');
|
var AUTO_FILL_KEY = 'random_auto_fill_enabled';
|
||||||
var to = null;
|
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 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
|
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){
|
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;}}
|
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){
|
function renderList(key, items, q){
|
||||||
if(!listBox) return; listBox.innerHTML=''; activeIndex=-1;
|
var cfg = themeInputs[key];
|
||||||
if(!items || !items.length){ hideList(); return; }
|
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){
|
items.slice(0,50).forEach(function(it, idx){
|
||||||
var div=document.createElement('div');
|
var div=document.createElement('div');
|
||||||
div.setAttribute('role','option');
|
div.setAttribute('role','option');
|
||||||
div.setAttribute('data-value', it);
|
div.setAttribute('data-value', it);
|
||||||
|
var optionId = key + '-theme-option-' + idx;
|
||||||
|
div.id = optionId;
|
||||||
div.innerHTML=highlight(it, q);
|
div.innerHTML=highlight(it, q);
|
||||||
div.style.cssText='padding:4px 8px; cursor:pointer;';
|
div.style.cssText='padding:4px 8px; cursor:pointer;';
|
||||||
div.addEventListener('mouseenter', function(){ setActive(idx); });
|
div.addEventListener('mouseenter', function(){ setActive(idx); });
|
||||||
div.addEventListener('mousedown', function(ev){ ev.preventDefault(); pick(it); });
|
div.addEventListener('mousedown', function(ev){ ev.preventDefault(); pick(key, it); });
|
||||||
listBox.appendChild(div);
|
list.appendChild(div);
|
||||||
});
|
});
|
||||||
listBox.style.display='block';
|
list.style.display='block';
|
||||||
input.setAttribute('aria-expanded','true');
|
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){
|
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'; });
|
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;
|
activeIndex = idx;
|
||||||
}
|
}
|
||||||
function move(delta){
|
function move(delta){
|
||||||
if(!listBox || listBox.style.display==='none'){ return; }
|
var list = currentList();
|
||||||
var children=[...listBox.children]; if(!children.length) return;
|
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;
|
var next = activeIndex + delta; if(next < 0) next = children.length -1; if(next >= children.length) next = 0;
|
||||||
setActive(next);
|
setActive(next);
|
||||||
var el = children[next]; if(el && el.scrollIntoView){ el.scrollIntoView({block:'nearest'}); }
|
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 pick(key, value){
|
||||||
function updateList(items, q){ renderList(items, q); }
|
var cfg = themeInputs[key];
|
||||||
function showRateLimitBanner(seconds){
|
if(!cfg || !cfg.input) return;
|
||||||
var b = document.getElementById('rate-limit-banner');
|
cfg.input.value = value;
|
||||||
var btn1 = document.getElementById('btn-surprise');
|
if(key === 'primary'){ syncLegacy(); }
|
||||||
var btn2 = document.getElementById('btn-reroll');
|
hideAllLists();
|
||||||
if(!b){ return; }
|
cfg.input.dispatchEvent(new Event('change'));
|
||||||
var secs = (typeof seconds === 'number' && !isNaN(seconds) && seconds > 0) ? Math.floor(seconds) : null;
|
cfg.input.removeAttribute('aria-activedescendant');
|
||||||
var base = 'Too many requests';
|
cfg.input.focus();
|
||||||
var update = function(){
|
}
|
||||||
if(secs !== null){ b.textContent = base + ' — try again in ' + secs + 's'; }
|
function persistSuggestCache(){
|
||||||
else { b.textContent = base + ' — please try again shortly'; }
|
try{
|
||||||
};
|
if(typeof sessionStorage === 'undefined'){ return; }
|
||||||
update();
|
var entries = [];
|
||||||
b.style.display = 'block';
|
cache.forEach(function(results, q){
|
||||||
if(btn1) btn1.disabled = true; if(btn2) btn2.disabled = true;
|
entries.push({ q: q, results: Array.isArray(results) ? results.slice(0, 40) : [] });
|
||||||
if(secs !== null){
|
});
|
||||||
var t = setInterval(function(){
|
if(entries.length > 75){
|
||||||
secs -= 1; update();
|
entries = entries.slice(entries.length - 75);
|
||||||
if(secs <= 0){ clearInterval(t); b.style.display = 'none'; if(btn1) btn1.disabled = false; if(btn2) btn2.disabled = false; }
|
}
|
||||||
}, 1000);
|
sessionStorage.setItem(REROLL_CACHE_STORAGE_KEY, JSON.stringify(entries));
|
||||||
}
|
}catch(e){ /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightMatch(item, q){
|
async function fetchSuggest(q, key){
|
||||||
try{
|
|
||||||
var idx = item.toLowerCase().indexOf(q.toLowerCase());
|
|
||||||
if(idx === -1) return item;
|
|
||||||
return item.substring(0,idx) + '[[' + item.substring(idx, idx+q.length) + ']]' + item.substring(idx+q.length);
|
|
||||||
}catch(e){ return item; }
|
|
||||||
}
|
|
||||||
async function fetchSuggest(q){
|
|
||||||
try{
|
try{
|
||||||
|
var cachedKey = (q || '').toLowerCase();
|
||||||
var u = '/themes/api/suggest' + (q? ('?q=' + encodeURIComponent(q)) : '');
|
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);
|
var r = await fetch(u);
|
||||||
if(r.status === 429){
|
if(r.status === 429){
|
||||||
var ra = r.headers.get('Retry-After');
|
var ra = r.headers.get('Retry-After');
|
||||||
|
|
@ -120,45 +469,244 @@
|
||||||
if(secs && !isNaN(secs)) msg += ' — retry in ' + secs + 's';
|
if(secs && !isNaN(secs)) msg += ' — retry in ' + secs + 's';
|
||||||
if(window.toast) { toast(msg); } else { console.warn(msg); }
|
if(window.toast) { toast(msg); } else { console.warn(msg); }
|
||||||
showRateLimitBanner(secs);
|
showRateLimitBanner(secs);
|
||||||
return updateList([]);
|
hideAllLists();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if(!r.ok) return updateList([]);
|
if(!r.ok){ renderList(key, [], q); return; }
|
||||||
var j = await r.json();
|
var j = await r.json();
|
||||||
var items = (j && j.themes) || [];
|
var items = (j && j.themes) || [];
|
||||||
cache.set(q, items);
|
cache.set(cachedKey, items);
|
||||||
// cap cache size to 50
|
// cap cache size to 60
|
||||||
if(cache.size > 50){
|
if(cache.size > 60){
|
||||||
var firstKey = cache.keys().next().value; cache.delete(firstKey);
|
var firstKey = cache.keys().next().value; cache.delete(firstKey);
|
||||||
}
|
}
|
||||||
updateList(items, q);
|
persistSuggestCache();
|
||||||
|
renderList(key, items, q);
|
||||||
}catch(e){ /* no-op */ }
|
}catch(e){ /* no-op */ }
|
||||||
}
|
}
|
||||||
if(input){
|
function attachInput(key){
|
||||||
input.addEventListener('input', function(){
|
var cfg = themeInputs[key];
|
||||||
var q = input.value || '';
|
if(!cfg || !cfg.input) return;
|
||||||
if(to) clearTimeout(to);
|
cfg.input.addEventListener('input', function(){
|
||||||
if(!q || q.length < 2){ hideList(); return; }
|
if(key === 'primary'){ syncLegacy(); }
|
||||||
to = setTimeout(function(){ fetchSuggest(q); }, 150);
|
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); }
|
if(ev.key === 'ArrowDown'){ ev.preventDefault(); move(1); }
|
||||||
else if(ev.key === 'ArrowUp'){ 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 === 'Enter'){
|
||||||
else if(ev.key === 'Escape'){ hideList(); }
|
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(){
|
document.addEventListener('htmx:afterRequest', function(){
|
||||||
// Safety: ensure buttons are always re-enabled after request completes
|
enableRandomButtonsIfReady();
|
||||||
var b1=document.getElementById('btn-surprise'); var b2=document.getElementById('btn-reroll');
|
|
||||||
if(b1) b1.disabled=false; if(b2 && document.getElementById('current-seed')) b2.disabled=false;
|
|
||||||
});
|
});
|
||||||
// (No configRequest hook needed; using hx-vals + hx-include for simple form-style submission.)
|
// (No configRequest hook needed; using hx-vals + hx-include for simple form-style submission.)
|
||||||
// Enable reroll once a result exists
|
// Enable reroll once a result exists
|
||||||
document.addEventListener('htmx:afterSwap', function(ev){
|
document.addEventListener('htmx:afterSwap', function(ev){
|
||||||
if (ev && ev.detail && ev.detail.target && ev.detail.target.id === 'random-result'){
|
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
|
// Refresh recent seeds asynchronously
|
||||||
fetch('/api/random/seeds').then(r=>r.json()).then(function(j){
|
fetch('/api/random/seeds').then(r=>r.json()).then(function(j){
|
||||||
try{
|
try{
|
||||||
|
|
@ -181,7 +729,8 @@
|
||||||
var msg = 'Too many requests';
|
var msg = 'Too many requests';
|
||||||
if(secs && !isNaN(secs)) msg += ' — try again in ' + secs + 's';
|
if(secs && !isNaN(secs)) msg += ' — try again in ' + secs + 's';
|
||||||
if(window.toast) { toast(msg); } else { alert(msg); }
|
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 */}
|
}catch(e){/* no-op */}
|
||||||
});
|
});
|
||||||
|
|
@ -210,8 +759,8 @@
|
||||||
favorites.forEach(function(s){
|
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;';
|
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(){
|
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 }) })
|
var payload = collectThemePayload({ seed: s-1 });
|
||||||
.then(r=>r.text()).then(html=>{ var target=document.getElementById('random-result'); if(target){ target.outerHTML=html; } });
|
submitRandomPayload(payload);
|
||||||
});
|
});
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
});
|
});
|
||||||
|
|
@ -229,10 +778,8 @@
|
||||||
b.setAttribute('aria-label','Rebuild using seed '+s);
|
b.setAttribute('aria-label','Rebuild using seed '+s);
|
||||||
b.addEventListener('click', function(){
|
b.addEventListener('click', function(){
|
||||||
// Post to reroll endpoint but treat as explicit seed build
|
// 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 }) })
|
var payload = collectThemePayload({ seed: s-1 });
|
||||||
.then(r=> r.text())
|
submitRandomPayload(payload);
|
||||||
.then(html=>{ var target=document.getElementById('random-result'); if(target){ target.outerHTML=html; } })
|
|
||||||
.catch(()=>{});
|
|
||||||
});
|
});
|
||||||
span.appendChild(b);
|
span.appendChild(b);
|
||||||
span.appendChild(favoriteButton(s, favorites || []));
|
span.appendChild(favoriteButton(s, favorites || []));
|
||||||
|
|
@ -260,13 +807,114 @@
|
||||||
|
|
||||||
// Persist last used theme in localStorage
|
// Persist last used theme in localStorage
|
||||||
try {
|
try {
|
||||||
var THEME_KEY='random_last_theme';
|
var THEME_KEY='random_last_primary_theme';
|
||||||
if(input){
|
var LEGACY_KEY='random_last_theme';
|
||||||
var prev = localStorage.getItem(THEME_KEY);
|
var SECONDARY_KEY='random_last_secondary_theme';
|
||||||
if(prev && !input.value){ input.value = prev; }
|
var TERTIARY_KEY='random_last_tertiary_theme';
|
||||||
input.addEventListener('change', function(){ localStorage.setItem(THEME_KEY, input.value || ''); });
|
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>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
35
config/random_theme_exclusions.yml
Normal file
35
config/random_theme_exclusions.yml
Normal 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.
|
||||||
59
docs/random_theme_exclusions.md
Normal file
59
docs/random_theme_exclusions.md
Normal 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`.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue