mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50: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
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"))
|
||||
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
app_module = importlib.reload(app_module)
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
payload = {"seed": 12345, "theme": "Goblin Kindred"}
|
||||
|
|
@ -20,3 +21,122 @@ def test_random_build_api_commander_and_seed(monkeypatch):
|
|||
assert data["seed"] == 12345
|
||||
assert isinstance(data.get("commander"), str)
|
||||
assert data.get("commander")
|
||||
assert "auto_fill_enabled" in data
|
||||
assert "auto_fill_secondary_enabled" in data
|
||||
assert "auto_fill_tertiary_enabled" in data
|
||||
assert "auto_fill_applied" in data
|
||||
assert "auto_filled_themes" in data
|
||||
assert "display_themes" in data
|
||||
|
||||
|
||||
def test_random_build_api_auto_fill_toggle(monkeypatch):
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
payload = {"seed": 54321, "primary_theme": "Aggro", "auto_fill_enabled": True}
|
||||
r = client.post('/api/random_build', json=payload)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["seed"] == 54321
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is True
|
||||
assert data.get("auto_fill_applied") in (True, False)
|
||||
assert isinstance(data.get("auto_filled_themes"), list)
|
||||
assert isinstance(data.get("display_themes"), list)
|
||||
|
||||
|
||||
def test_random_build_api_partial_auto_fill(monkeypatch):
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
payload = {
|
||||
"seed": 98765,
|
||||
"primary_theme": "Aggro",
|
||||
"auto_fill_secondary_enabled": True,
|
||||
"auto_fill_tertiary_enabled": False,
|
||||
}
|
||||
r = client.post('/api/random_build', json=payload)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["seed"] == 98765
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is False
|
||||
assert data.get("auto_fill_applied") in (True, False)
|
||||
assert isinstance(data.get("auto_filled_themes"), list)
|
||||
|
||||
|
||||
def test_random_build_api_tertiary_requires_secondary(monkeypatch):
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
payload = {
|
||||
"seed": 192837,
|
||||
"primary_theme": "Aggro",
|
||||
"auto_fill_secondary_enabled": False,
|
||||
"auto_fill_tertiary_enabled": True,
|
||||
}
|
||||
r = client.post('/api/random_build', json=payload)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["seed"] == 192837
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is True
|
||||
assert data.get("auto_fill_applied") in (True, False)
|
||||
assert isinstance(data.get("auto_filled_themes"), list)
|
||||
|
||||
|
||||
def test_random_build_api_reports_auto_filled_themes(monkeypatch):
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
import code.web.app as app_module
|
||||
import code.deck_builder.random_entrypoint as random_entrypoint
|
||||
import deck_builder.random_entrypoint as random_entrypoint_pkg
|
||||
|
||||
def fake_auto_fill(
|
||||
df,
|
||||
commander,
|
||||
rng,
|
||||
*,
|
||||
primary_theme,
|
||||
secondary_theme,
|
||||
tertiary_theme,
|
||||
allowed_pool,
|
||||
fill_secondary,
|
||||
fill_tertiary,
|
||||
):
|
||||
return "Tokens", "Sacrifice", ["Tokens", "Sacrifice"]
|
||||
|
||||
monkeypatch.setattr(random_entrypoint, "_auto_fill_missing_themes", fake_auto_fill)
|
||||
monkeypatch.setattr(random_entrypoint_pkg, "_auto_fill_missing_themes", fake_auto_fill)
|
||||
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
payload = {
|
||||
"seed": 654321,
|
||||
"primary_theme": "Aggro",
|
||||
"auto_fill_enabled": True,
|
||||
"auto_fill_secondary_enabled": True,
|
||||
"auto_fill_tertiary_enabled": True,
|
||||
}
|
||||
r = client.post('/api/random_build', json=payload)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["seed"] == 654321
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_applied") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is True
|
||||
assert data.get("auto_filled_themes") == ["Tokens", "Sacrifice"]
|
||||
|
|
|
|||
|
|
@ -1,32 +1,66 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_metrics_and_seed_history(monkeypatch):
|
||||
monkeypatch.setenv('RANDOM_MODES', '1')
|
||||
monkeypatch.setenv('RANDOM_UI', '1')
|
||||
monkeypatch.setenv('RANDOM_TELEMETRY', '1')
|
||||
monkeypatch.setenv('CSV_FILES_DIR', os.path.join('csv_files', 'testdata'))
|
||||
from code.web.app import app
|
||||
client = TestClient(app)
|
||||
monkeypatch.setenv("RANDOM_MODES", "1")
|
||||
monkeypatch.setenv("RANDOM_UI", "1")
|
||||
monkeypatch.setenv("RANDOM_TELEMETRY", "1")
|
||||
monkeypatch.setenv("CSV_FILES_DIR", os.path.join("csv_files", "testdata"))
|
||||
|
||||
# Build + reroll to generate metrics and seed history
|
||||
r1 = client.post('/api/random_full_build', json={'seed': 9090})
|
||||
assert r1.status_code == 200, r1.text
|
||||
r2 = client.post('/api/random_reroll', json={'seed': 9090})
|
||||
assert r2.status_code == 200, r2.text
|
||||
import code.web.app as app_module
|
||||
|
||||
# Metrics
|
||||
m = client.get('/status/random_metrics')
|
||||
assert m.status_code == 200, m.text
|
||||
mj = m.json()
|
||||
assert mj.get('ok') is True
|
||||
metrics = mj.get('metrics') or {}
|
||||
assert 'full_build' in metrics and 'reroll' in metrics
|
||||
# Reset in-memory telemetry so assertions are deterministic
|
||||
app_module.RANDOM_TELEMETRY = True
|
||||
app_module.RATE_LIMIT_ENABLED = False
|
||||
for bucket in app_module._RANDOM_METRICS.values():
|
||||
for key in bucket:
|
||||
bucket[key] = 0
|
||||
for key in list(app_module._RANDOM_USAGE_METRICS.keys()):
|
||||
app_module._RANDOM_USAGE_METRICS[key] = 0
|
||||
for key in list(app_module._RANDOM_FALLBACK_METRICS.keys()):
|
||||
app_module._RANDOM_FALLBACK_METRICS[key] = 0
|
||||
app_module._RANDOM_FALLBACK_REASONS.clear()
|
||||
app_module._RL_COUNTS.clear()
|
||||
|
||||
# Seed history
|
||||
sh = client.get('/api/random/seeds')
|
||||
assert sh.status_code == 200
|
||||
sj = sh.json()
|
||||
seeds = sj.get('seeds') or []
|
||||
assert any(s == 9090 for s in seeds) and sj.get('last') in seeds
|
||||
prev_ms = app_module.RANDOM_REROLL_THROTTLE_MS
|
||||
prev_seconds = app_module._REROLL_THROTTLE_SECONDS
|
||||
app_module.RANDOM_REROLL_THROTTLE_MS = 0
|
||||
app_module._REROLL_THROTTLE_SECONDS = 0.0
|
||||
|
||||
try:
|
||||
with TestClient(app_module.app) as client:
|
||||
# Build + reroll to generate metrics and seed history
|
||||
r1 = client.post("/api/random_full_build", json={"seed": 9090, "primary_theme": "Aggro"})
|
||||
assert r1.status_code == 200, r1.text
|
||||
r2 = client.post("/api/random_reroll", json={"seed": 9090})
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
# Metrics
|
||||
m = client.get("/status/random_metrics")
|
||||
assert m.status_code == 200, m.text
|
||||
mj = m.json()
|
||||
assert mj.get("ok") is True
|
||||
metrics = mj.get("metrics") or {}
|
||||
assert "full_build" in metrics and "reroll" in metrics
|
||||
|
||||
usage = mj.get("usage") or {}
|
||||
modes = usage.get("modes") or {}
|
||||
fallbacks = usage.get("fallbacks") or {}
|
||||
assert set(modes.keys()) >= {"theme", "reroll", "surprise", "reroll_same_commander"}
|
||||
assert modes.get("theme", 0) >= 2
|
||||
assert "none" in fallbacks
|
||||
assert isinstance(usage.get("fallback_reasons"), dict)
|
||||
|
||||
# Seed history
|
||||
sh = client.get("/api/random/seeds")
|
||||
assert sh.status_code == 200
|
||||
sj = sh.json()
|
||||
seeds = sj.get("seeds") or []
|
||||
assert any(s == 9090 for s in seeds) and sj.get("last") in seeds
|
||||
finally:
|
||||
app_module.RANDOM_REROLL_THROTTLE_MS = prev_ms
|
||||
app_module._REROLL_THROTTLE_SECONDS = prev_seconds
|
||||
|
|
|
|||
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")
|
||||
|
||||
|
||||
def test_api_random_reroll_auto_fill_metadata(client: TestClient):
|
||||
r1 = client.post("/api/random_full_build", json={"seed": 555, "primary_theme": "Aggro"})
|
||||
assert r1.status_code == 200, r1.text
|
||||
|
||||
r2 = client.post(
|
||||
"/api/random_reroll",
|
||||
json={"seed": 555, "primary_theme": "Aggro", "auto_fill_enabled": True},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
data = r2.json()
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is True
|
||||
assert data.get("auto_fill_applied") in (True, False)
|
||||
assert isinstance(data.get("auto_filled_themes"), list)
|
||||
assert data.get("requested_themes", {}).get("auto_fill_enabled") is True
|
||||
assert data.get("requested_themes", {}).get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("requested_themes", {}).get("auto_fill_tertiary_enabled") is True
|
||||
assert "display_themes" in data
|
||||
|
||||
|
||||
def test_api_random_reroll_secondary_only_auto_fill(client: TestClient):
|
||||
r1 = client.post(
|
||||
"/api/random_reroll",
|
||||
json={
|
||||
"seed": 777,
|
||||
"primary_theme": "Aggro",
|
||||
"auto_fill_secondary_enabled": True,
|
||||
"auto_fill_tertiary_enabled": False,
|
||||
},
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
data = r1.json()
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is False
|
||||
assert data.get("auto_fill_applied") in (True, False)
|
||||
assert isinstance(data.get("auto_filled_themes"), list)
|
||||
requested = data.get("requested_themes", {})
|
||||
assert requested.get("auto_fill_enabled") is True
|
||||
assert requested.get("auto_fill_secondary_enabled") is True
|
||||
assert requested.get("auto_fill_tertiary_enabled") is False
|
||||
|
||||
|
||||
def test_api_random_reroll_tertiary_requires_secondary(client: TestClient):
|
||||
r1 = client.post(
|
||||
"/api/random_reroll",
|
||||
json={
|
||||
"seed": 778,
|
||||
"primary_theme": "Aggro",
|
||||
"auto_fill_secondary_enabled": False,
|
||||
"auto_fill_tertiary_enabled": True,
|
||||
},
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
data = r1.json()
|
||||
assert data.get("auto_fill_enabled") is True
|
||||
assert data.get("auto_fill_secondary_enabled") is True
|
||||
assert data.get("auto_fill_tertiary_enabled") is True
|
||||
assert data.get("auto_fill_applied") in (True, False)
|
||||
assert isinstance(data.get("auto_filled_themes"), list)
|
||||
requested = data.get("requested_themes", {})
|
||||
assert requested.get("auto_fill_enabled") is True
|
||||
assert requested.get("auto_fill_secondary_enabled") is True
|
||||
assert requested.get("auto_fill_tertiary_enabled") is True
|
||||
|
||||
|
||||
def test_hx_random_reroll_returns_html(client: TestClient):
|
||||
headers = {"HX-Request": "true", "Content-Type": "application/json"}
|
||||
r = client.post("/hx/random_reroll", data=json.dumps({"seed": 42}), headers=headers)
|
||||
r = client.post("/hx/random_reroll", content=json.dumps({"seed": 42}), headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
# Accept either HTML fragment or JSON fallback
|
||||
content_type = r.headers.get("content-type", "")
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ def test_locked_reroll_generates_summary_and_compliance():
|
|||
start = time.time()
|
||||
# Locked reroll via HTMX path (form style)
|
||||
form_body = f"seed={seed}&commander={commander}&mode=reroll_same_commander"
|
||||
r2 = c.post('/hx/random_reroll', data=form_body, headers={'Content-Type':'application/x-www-form-urlencoded'})
|
||||
r2 = c.post('/hx/random_reroll', content=form_body, headers={'Content-Type':'application/x-www-form-urlencoded'})
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
# Look for new sidecar/compliance created after start
|
||||
|
|
|
|||
|
|
@ -23,14 +23,14 @@ def test_reroll_keeps_commander():
|
|||
# First reroll with commander lock
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
body = json.dumps({'seed': seed, 'commander': commander, 'mode': 'reroll_same_commander'})
|
||||
r2 = client.post('/hx/random_reroll', data=body, headers=headers)
|
||||
r2 = client.post('/hx/random_reroll', content=body, headers=headers)
|
||||
assert r2.status_code == 200
|
||||
html1 = r2.text
|
||||
assert commander in html1
|
||||
|
||||
# Second reroll should keep same commander (seed increments so prior +1 used on server)
|
||||
body2 = json.dumps({'seed': seed + 1, 'commander': commander, 'mode': 'reroll_same_commander'})
|
||||
r3 = client.post('/hx/random_reroll', data=body2, headers=headers)
|
||||
r3 = client.post('/hx/random_reroll', content=body2, headers=headers)
|
||||
assert r3.status_code == 200
|
||||
html2 = r3.text
|
||||
assert commander in html2
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ def test_reroll_keeps_commander_form_encoded():
|
|||
seed = data1['seed']
|
||||
|
||||
form_body = f"seed={seed}&commander={quote_plus(commander)}&mode=reroll_same_commander"
|
||||
r2 = client.post('/hx/random_reroll', data=form_body, headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
||||
r2 = client.post('/hx/random_reroll', content=form_body, headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
||||
assert r2.status_code == 200
|
||||
assert commander in r2.text
|
||||
|
||||
# second reroll with incremented seed
|
||||
form_body2 = f"seed={seed+1}&commander={quote_plus(commander)}&mode=reroll_same_commander"
|
||||
r3 = client.post('/hx/random_reroll', data=form_body2, headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
||||
r3 = client.post('/hx/random_reroll', content=form_body2, headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
||||
assert r3.status_code == 200
|
||||
assert commander in r3.text
|
||||
|
|
@ -19,7 +19,7 @@ def test_locked_reroll_single_export():
|
|||
commander = r.json()['commander']
|
||||
before_csvs = set(glob.glob('deck_files/*.csv'))
|
||||
form_body = f"seed={seed}&commander={commander}&mode=reroll_same_commander"
|
||||
r2 = c.post('/hx/random_reroll', data=form_body, headers={'Content-Type':'application/x-www-form-urlencoded'})
|
||||
r2 = c.post('/hx/random_reroll', content=form_body, headers={'Content-Type':'application/x-www-form-urlencoded'})
|
||||
assert r2.status_code == 200
|
||||
after_csvs = set(glob.glob('deck_files/*.csv'))
|
||||
new_csvs = after_csvs - before_csvs
|
||||
|
|
|
|||
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.responses import HTMLResponse, JSONResponse
|
||||
from typing import Any
|
||||
from ..app import ALLOW_MUST_HAVES # Import feature flag
|
||||
from ..services.build_utils import (
|
||||
step5_ctx_from_result,
|
||||
|
|
@ -2859,7 +2860,35 @@ async def build_permalink(request: Request):
|
|||
rb = sess.get("random_build") or {}
|
||||
if rb:
|
||||
# Only include known keys to avoid leaking unrelated session data
|
||||
inc = {k: rb.get(k) for k in ("seed", "theme", "constraints") if k in rb}
|
||||
inc: dict[str, Any] = {}
|
||||
for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
|
||||
if rb.get(key) is not None:
|
||||
inc[key] = rb.get(key)
|
||||
resolved_list = rb.get("resolved_themes")
|
||||
if isinstance(resolved_list, list):
|
||||
inc["resolved_themes"] = list(resolved_list)
|
||||
resolved_info = rb.get("resolved_theme_info")
|
||||
if isinstance(resolved_info, dict):
|
||||
inc["resolved_theme_info"] = dict(resolved_info)
|
||||
if rb.get("combo_fallback") is not None:
|
||||
inc["combo_fallback"] = bool(rb.get("combo_fallback"))
|
||||
if rb.get("synergy_fallback") is not None:
|
||||
inc["synergy_fallback"] = bool(rb.get("synergy_fallback"))
|
||||
if rb.get("fallback_reason") is not None:
|
||||
inc["fallback_reason"] = rb.get("fallback_reason")
|
||||
requested = rb.get("requested_themes")
|
||||
if isinstance(requested, dict):
|
||||
inc["requested_themes"] = dict(requested)
|
||||
if rb.get("auto_fill_enabled") is not None:
|
||||
inc["auto_fill_enabled"] = bool(rb.get("auto_fill_enabled"))
|
||||
if rb.get("auto_fill_applied") is not None:
|
||||
inc["auto_fill_applied"] = bool(rb.get("auto_fill_applied"))
|
||||
auto_filled = rb.get("auto_filled_themes")
|
||||
if isinstance(auto_filled, list):
|
||||
inc["auto_filled_themes"] = list(auto_filled)
|
||||
display = rb.get("display_themes")
|
||||
if isinstance(display, list):
|
||||
inc["display_themes"] = list(display)
|
||||
if inc:
|
||||
payload["random"] = inc
|
||||
except Exception:
|
||||
|
|
@ -2914,9 +2943,43 @@ async def build_from(request: Request, state: str | None = None) -> HTMLResponse
|
|||
try:
|
||||
r = data.get("random") or {}
|
||||
if r:
|
||||
sess["random_build"] = {
|
||||
k: r.get(k) for k in ("seed", "theme", "constraints") if k in r
|
||||
}
|
||||
rb_payload: dict[str, Any] = {}
|
||||
for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"):
|
||||
if r.get(key) is not None:
|
||||
rb_payload[key] = r.get(key)
|
||||
if isinstance(r.get("resolved_themes"), list):
|
||||
rb_payload["resolved_themes"] = list(r.get("resolved_themes") or [])
|
||||
if isinstance(r.get("resolved_theme_info"), dict):
|
||||
rb_payload["resolved_theme_info"] = dict(r.get("resolved_theme_info"))
|
||||
if r.get("combo_fallback") is not None:
|
||||
rb_payload["combo_fallback"] = bool(r.get("combo_fallback"))
|
||||
if r.get("synergy_fallback") is not None:
|
||||
rb_payload["synergy_fallback"] = bool(r.get("synergy_fallback"))
|
||||
if r.get("fallback_reason") is not None:
|
||||
rb_payload["fallback_reason"] = r.get("fallback_reason")
|
||||
if isinstance(r.get("requested_themes"), dict):
|
||||
requested_payload = dict(r.get("requested_themes"))
|
||||
if "auto_fill_enabled" in requested_payload:
|
||||
requested_payload["auto_fill_enabled"] = bool(requested_payload.get("auto_fill_enabled"))
|
||||
rb_payload["requested_themes"] = requested_payload
|
||||
if r.get("auto_fill_enabled") is not None:
|
||||
rb_payload["auto_fill_enabled"] = bool(r.get("auto_fill_enabled"))
|
||||
if r.get("auto_fill_applied") is not None:
|
||||
rb_payload["auto_fill_applied"] = bool(r.get("auto_fill_applied"))
|
||||
auto_filled = r.get("auto_filled_themes")
|
||||
if isinstance(auto_filled, list):
|
||||
rb_payload["auto_filled_themes"] = list(auto_filled)
|
||||
display = r.get("display_themes")
|
||||
if isinstance(display, list):
|
||||
rb_payload["display_themes"] = list(display)
|
||||
if "seed" in rb_payload:
|
||||
try:
|
||||
seed_int = int(rb_payload["seed"])
|
||||
rb_payload["seed"] = seed_int
|
||||
rb_payload.setdefault("recent_seeds", [seed_int])
|
||||
except Exception:
|
||||
rb_payload.setdefault("recent_seeds", [])
|
||||
sess["random_build"] = rb_payload
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<h3 style="margin-top:0">System summary</h3>
|
||||
<div id="sysSummary" class="muted">Loading…</div>
|
||||
<div id="themeSummary" style="margin-top:.5rem"></div>
|
||||
<div id="themeTokenStats" class="muted" style="margin-top:.5rem">Loading theme stats…</div>
|
||||
<div style="margin-top:.35rem">
|
||||
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
|
||||
</div>
|
||||
|
|
@ -76,6 +77,121 @@
|
|||
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
|
||||
}
|
||||
load();
|
||||
var tokenEl = document.getElementById('themeTokenStats');
|
||||
function renderTokens(payload){
|
||||
if (!tokenEl) return;
|
||||
try {
|
||||
if (!payload || payload.ok !== true) {
|
||||
tokenEl.textContent = 'Theme stats unavailable';
|
||||
return;
|
||||
}
|
||||
var stats = payload.stats || {};
|
||||
var top = Array.isArray(stats.top_tokens) ? stats.top_tokens.slice(0, 5) : [];
|
||||
var html = '';
|
||||
var commanders = (stats && stats.commanders != null) ? stats.commanders : '0';
|
||||
var withTags = (stats && stats.with_tags != null) ? stats.with_tags : '0';
|
||||
var uniqueTokens = (stats && stats.unique_tokens != null) ? stats.unique_tokens : '0';
|
||||
var assignments = (stats && stats.total_assignments != null) ? stats.total_assignments : '0';
|
||||
var avgTokens = (stats && stats.avg_tokens_per_commander != null) ? stats.avg_tokens_per_commander : '0';
|
||||
var medianTokens = (stats && stats.median_tokens_per_commander != null) ? stats.median_tokens_per_commander : '0';
|
||||
html += '<div><strong>Commanders indexed:</strong> ' + String(commanders) + ' (' + String(withTags) + ' with tags)</div>';
|
||||
html += '<div><strong>Theme tokens:</strong> ' + String(uniqueTokens) + ' unique; ' + String(assignments) + ' assignments</div>';
|
||||
html += '<div><strong>Tokens per commander:</strong> avg ' + String(avgTokens) + ', median ' + String(medianTokens) + '</div>';
|
||||
if (top.length) {
|
||||
var parts = [];
|
||||
top.forEach(function(item){
|
||||
parts.push(String(item.token) + ' (' + String(item.count) + ')');
|
||||
});
|
||||
html += '<div><strong>Top tokens:</strong> ' + parts.join(', ') + '</div>';
|
||||
}
|
||||
var pool = stats.random_pool || {};
|
||||
if (pool && typeof pool.size !== 'undefined'){
|
||||
var coveragePct = null;
|
||||
if (pool.coverage_ratio != null){
|
||||
var cov = Number(pool.coverage_ratio);
|
||||
if (!Number.isNaN(cov)){ coveragePct = (cov * 100).toFixed(1); }
|
||||
}
|
||||
html += '<div style="margin-top:0.35rem;"><strong>Curated random pool:</strong> ' + String(pool.size) + ' tokens';
|
||||
if (coveragePct !== null){ html += ' (' + coveragePct + '% of catalog tokens)'; }
|
||||
html += '</div>';
|
||||
var rules = pool.rules || {};
|
||||
var threshold = rules.overrepresented_share_threshold;
|
||||
if (threshold != null){
|
||||
var thrPct = Number(threshold);
|
||||
if (!Number.isNaN(thrPct)){ html += '<div style="font-size:11px;">Over-represented threshold: ≥ ' + (thrPct * 100).toFixed(1) + '% of commanders</div>'; }
|
||||
}
|
||||
var excludedCounts = pool.excluded_counts || {};
|
||||
var reasonKeys = Object.keys(excludedCounts);
|
||||
if (reasonKeys.length){
|
||||
var badges = reasonKeys.map(function(reason){
|
||||
return reason + ' (' + excludedCounts[reason] + ')';
|
||||
});
|
||||
html += '<div style="font-size:11px;">Exclusions: ' + badges.join(', ') + '</div>';
|
||||
}
|
||||
var samples = pool.excluded_samples || {};
|
||||
var sampleKeys = Object.keys(samples);
|
||||
if (sampleKeys.length){
|
||||
var sampleLines = [];
|
||||
sampleKeys.slice(0, 3).forEach(function(reason){
|
||||
var tokens = samples[reason] || [];
|
||||
var sampleTokens = (tokens || []).slice(0, 3);
|
||||
var remainder = Math.max((tokens || []).length - sampleTokens.length, 0);
|
||||
var tokenLabel = sampleTokens.join(', ');
|
||||
if (remainder > 0){ tokenLabel += ' +' + remainder; }
|
||||
sampleLines.push(reason + ': ' + tokenLabel);
|
||||
});
|
||||
html += '<div style="font-size:11px; opacity:0.75;">Samples → ' + sampleLines.join(' | ') + '</div>';
|
||||
}
|
||||
var manualDetail = pool.manual_exclusion_detail || {};
|
||||
var manualKeys = Object.keys(manualDetail);
|
||||
if (manualKeys.length){
|
||||
var manualSamples = manualKeys.slice(0, 3).map(function(token){
|
||||
var info = manualDetail[token] || {};
|
||||
var label = info.display || token;
|
||||
var cat = info.category ? (' [' + info.category + ']') : '';
|
||||
return label + cat;
|
||||
});
|
||||
var manualRemainder = Math.max(manualKeys.length - manualSamples.length, 0);
|
||||
var manualLine = manualSamples.join(', ');
|
||||
if (manualRemainder > 0){ manualLine += ' +' + manualRemainder; }
|
||||
html += '<div style="font-size:11px;">Manual exclusions: ' + manualLine + '</div>';
|
||||
}
|
||||
var manualGroups = Array.isArray(rules.manual_exclusions) ? rules.manual_exclusions : [];
|
||||
if (manualGroups.length){
|
||||
var categoryList = manualGroups.map(function(group){ return group.category || 'manual'; });
|
||||
html += '<div style="font-size:11px; opacity:0.75;">Manual categories: ' + categoryList.join(', ') + '</div>';
|
||||
}
|
||||
}
|
||||
var telemetry = stats.index_telemetry || {};
|
||||
if (telemetry && typeof telemetry.token_count !== 'undefined'){
|
||||
var hitRate = telemetry.hit_rate != null ? Number(telemetry.hit_rate) : null;
|
||||
var hitPct = (hitRate !== null && !Number.isNaN(hitRate)) ? (hitRate * 100).toFixed(1) : null;
|
||||
var teleLine = '<div style="font-size:11px; margin-top:0.25rem;">Tag index: ' + String(telemetry.token_count || 0) + ' tokens · lookups ' + String(telemetry.lookups || 0);
|
||||
if (hitPct !== null){ teleLine += ' · hit rate ' + hitPct + '%'; }
|
||||
if (telemetry.substring_checks){ teleLine += ' · substring checks ' + String(telemetry.substring_checks || 0); }
|
||||
teleLine += '</div>';
|
||||
html += teleLine;
|
||||
}
|
||||
tokenEl.innerHTML = html;
|
||||
} catch(_){
|
||||
tokenEl.textContent = 'Theme stats unavailable';
|
||||
}
|
||||
}
|
||||
function loadTokenStats(){
|
||||
if (!tokenEl) return;
|
||||
tokenEl.textContent = 'Loading theme stats…';
|
||||
fetch('/status/random_theme_stats', { cache: 'no-store' })
|
||||
.then(function(resp){
|
||||
if (resp.status === 404) {
|
||||
tokenEl.textContent = 'Diagnostics disabled (stats unavailable)';
|
||||
return null;
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.then(function(data){ if (data) renderTokens(data); })
|
||||
.catch(function(){ tokenEl.textContent = 'Theme stats unavailable'; });
|
||||
}
|
||||
loadTokenStats();
|
||||
// Theme status and reset
|
||||
try{
|
||||
var tEl = document.getElementById('themeSummary');
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
<div class="random-result" id="random-result">
|
||||
<style>
|
||||
.diag-badges{display:inline-flex; gap:4px; margin-left:8px; flex-wrap:wrap;}
|
||||
.diag-badge{background:var(--panel-alt,#334155); color:#fff; padding:2px 6px; border-radius:12px; font-size:10px; letter-spacing:.5px; line-height:1.2;}
|
||||
.diag-badge.warn{background:#8a6d3b;}
|
||||
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
|
||||
.diag-badges{display:inline-flex; gap:4px; margin-left:8px; flex-wrap:wrap; align-items:center;}
|
||||
.diag-badge{background:var(--panel-alt,#334155); color:#fff; padding:3px 7px; border-radius:12px; font-size:10px; letter-spacing:.5px; line-height:1.3; display:inline-flex; align-items:center; gap:4px;}
|
||||
.diag-badge.warn{background:#814c14;}
|
||||
.diag-badge.err{background:#7f1d1d;}
|
||||
.diag-badge.fallback{background:#4f46e5;}
|
||||
.diag-badge.fallback{background:#4338ca;}
|
||||
.diag-badge .badge-icon{font-size:12px; display:inline-block;}
|
||||
.btn-compact{font-size:11px; padding:2px 6px; line-height:1.2;}
|
||||
.fallback-notice{margin-top:8px; padding:10px 12px; border-radius:6px; font-size:13px; line-height:1.45; border-left:4px solid currentColor; display:flex; gap:8px; align-items:flex-start;}
|
||||
.fallback-notice.info{background:rgba(79,70,229,0.18); color:#2c2891; border:1px solid rgba(79,70,229,0.45);}
|
||||
.fallback-notice.warn{background:#fff0d6; color:#6c4505; border:1px solid #d89b32;}
|
||||
.fallback-notice.danger{background:#fef2f2; color:#7f1d1d; border:1px solid rgba(127,29,29,0.5);}
|
||||
.fallback-notice:focus-visible{outline:2px solid currentColor; outline-offset:2px;}
|
||||
.fallback-notice .notice-icon{font-size:16px; line-height:1; margin-top:2px;}
|
||||
</style>
|
||||
<div class="random-meta" style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
||||
<span class="seed">Seed: <strong>{{ seed }}</strong></span>
|
||||
|
|
@ -14,18 +22,90 @@
|
|||
<button class="btn btn-compact" type="button" aria-label="Copy permalink for this exact build" onclick="(async()=>{try{await navigator.clipboard.writeText(location.origin + '{{ permalink }}');(window.toast&&toast('Permalink copied'))||console.log('Permalink copied');}catch(e){alert('Copy failed');}})()">Copy Permalink</button>
|
||||
{% endif %}
|
||||
{% if show_diagnostics and diagnostics %}
|
||||
<span class="diag-badges" aria-label="Diagnostics" role="group">
|
||||
<span class="diag-badge" title="Attempts tried before acceptance">Att {{ diagnostics.attempts }}</span>
|
||||
<span class="diag-badge" title="Elapsed build time in milliseconds">{{ diagnostics.elapsed_ms }}ms</span>
|
||||
{% if diagnostics.timeout_hit %}<span class="diag-badge warn" title="Generation loop exceeded timeout limit before success">Timeout</span>{% endif %}
|
||||
{% if diagnostics.retries_exhausted %}<span class="diag-badge warn" title="All allotted attempts were used without an early acceptable candidate">Retries</span>{% endif %}
|
||||
{% if fallback or diagnostics.fallback %}<span class="diag-badge fallback" title="Original theme produced no candidates; Surprise mode fallback engaged">Fallback</span>{% endif %}
|
||||
<span class="diag-badges" aria-label="Diagnostics" role="status" aria-live="polite" aria-atomic="true">
|
||||
<span class="diag-badge" title="Attempts tried before acceptance" aria-label="Attempts tried before acceptance">
|
||||
<span class="badge-icon" aria-hidden="true">⟳</span>
|
||||
<span aria-hidden="true">Att {{ diagnostics.attempts }}</span>
|
||||
</span>
|
||||
<span class="diag-badge" title="Elapsed build time in milliseconds" aria-label="Elapsed build time in milliseconds">
|
||||
<span class="badge-icon" aria-hidden="true">⏱</span>
|
||||
<span aria-hidden="true">{{ diagnostics.elapsed_ms }}ms</span>
|
||||
</span>
|
||||
{% if diagnostics.timeout_hit %}
|
||||
<span class="diag-badge warn" title="Generation loop exceeded timeout limit before success" aria-label="Generation exceeded timeout limit before success">
|
||||
<span class="badge-icon" aria-hidden="true">⚠</span>
|
||||
<span aria-hidden="true">Timeout</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if diagnostics.retries_exhausted %}
|
||||
<span class="diag-badge warn" title="All allotted attempts were used without an early acceptable candidate" aria-label="All attempts were used without an early acceptable candidate">
|
||||
<span class="badge-icon" aria-hidden="true">↺</span>
|
||||
<span aria-hidden="true">Retries</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if fallback or diagnostics.fallback %}
|
||||
<span class="diag-badge fallback" title="Original theme produced no candidates; Surprise mode fallback engaged" aria-label="Fallback engaged after theme produced no candidates">
|
||||
<span class="badge-icon" aria-hidden="true">★</span>
|
||||
<span aria-hidden="true">Fallback</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% set display_list = display_themes or resolved_themes or [] %}
|
||||
{% set resolved_list = display_list %}
|
||||
{% set has_primary = primary_theme or secondary_theme or tertiary_theme %}
|
||||
{% if resolved_list or has_primary %}
|
||||
<div class="resolved-themes" style="margin-top:6px; font-size:13px; color:var(--text-muted,#94a3b8);" role="status" aria-live="polite">
|
||||
{% if resolved_list %}
|
||||
Resolved themes: <strong>{{ resolved_list|join(' + ') }}</strong>
|
||||
{% else %}
|
||||
Resolved themes: <strong>Full pool fallback</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if auto_fill_applied and auto_filled_themes %}
|
||||
<div class="auto-fill-note" style="margin-top:4px; font-size:12px; color:var(--text-muted,#94a3b8);" role="status" aria-live="polite">
|
||||
Auto-filled: <strong>{{ auto_filled_themes|join(', ') }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if fallback_reason %}
|
||||
{% if synergy_fallback and (not resolved_list) %}
|
||||
{% set notice_class = 'danger' %}
|
||||
{% elif synergy_fallback %}
|
||||
{% set notice_class = 'warn' %}
|
||||
{% else %}
|
||||
{% set notice_class = 'info' %}
|
||||
{% endif %}
|
||||
{% if notice_class == 'danger' %}
|
||||
{% set notice_icon = '⛔' %}
|
||||
{% elif notice_class == 'warn' %}
|
||||
{% set notice_icon = '⚠️' %}
|
||||
{% else %}
|
||||
{% set notice_icon = 'ℹ️' %}
|
||||
{% endif %}
|
||||
<div class="fallback-notice {{ notice_class }}" role="status" aria-live="assertive" aria-atomic="true" tabindex="0">
|
||||
<span class="notice-icon" aria-hidden="true">{{ notice_icon }}</span>
|
||||
<span>
|
||||
<strong>Heads up:</strong>
|
||||
<span id="fallback-reason-text">{{ fallback_reason }}.</span>
|
||||
<span class="sr-only">You can tweak secondary or tertiary themes for different mixes, or reroll to explore more options.</span>
|
||||
<span aria-hidden="true"> Try tweaking Secondary or Tertiary themes for different mixes, or reroll to explore more options.</span>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Hidden current seed so HTMX reroll button can include it via hx-include -->
|
||||
<input type="hidden" id="current-seed" name="seed" value="{{ seed }}" />
|
||||
<input type="hidden" id="current-commander" name="commander" value="{{ commander }}" />
|
||||
<input type="hidden" id="current-primary-theme" name="primary_theme" value="{{ primary_theme or '' }}" />
|
||||
<input type="hidden" id="current-secondary-theme" name="secondary_theme" value="{{ secondary_theme or '' }}" />
|
||||
<input type="hidden" id="current-tertiary-theme" name="tertiary_theme" value="{{ tertiary_theme or '' }}" />
|
||||
<input type="hidden" id="current-resolved-themes" name="resolved_themes" value="{{ resolved_list|join('||') }}" />
|
||||
<input type="hidden" id="current-auto-fill-enabled" name="auto_fill_enabled" value="{{ 'true' if auto_fill_enabled else 'false' }}" />
|
||||
<input type="hidden" id="current-auto-fill-secondary-enabled" name="auto_fill_secondary_enabled" value="{{ 'true' if auto_fill_secondary_enabled else 'false' }}" />
|
||||
<input type="hidden" id="current-auto-fill-tertiary-enabled" name="auto_fill_tertiary_enabled" value="{{ 'true' if auto_fill_tertiary_enabled else 'false' }}" />
|
||||
<input type="hidden" id="current-auto-filled-themes" name="auto_filled_themes" value="{{ auto_filled_themes|join('||') if auto_filled_themes else '' }}" />
|
||||
<input type="hidden" id="current-strict-theme-match" name="strict_theme_match" value="{{ 'true' if strict_theme_match else 'false' }}" />
|
||||
<div class="commander-block" style="display:flex; gap:14px; align-items:flex-start; margin-top:.75rem;">
|
||||
<div class="commander-thumb" style="flex:0 0 auto;">
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -6,25 +6,118 @@
|
|||
{% if not enable_ui %}
|
||||
<div class="notice" role="status">Random UI is disabled. Set <code>RANDOM_UI=1</code> to enable.</div>
|
||||
{% else %}
|
||||
<div class="controls" role="group" aria-label="Random controls" style="display:flex; gap:8px; align-items:center; flex-wrap: wrap;">
|
||||
<label for="random-theme" class="field-label" style="margin-right:6px;">Theme</label>
|
||||
<div style="position:relative;">
|
||||
<input id="random-theme" name="theme" type="text" placeholder="optional (e.g., Tokens)" aria-label="Theme (optional)" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="theme-suggest-box" aria-haspopup="listbox" />
|
||||
<div id="theme-suggest-box" role="listbox" style="display:none; position:absolute; top:100%; left:0; right:0; background:var(--panel,#1e293b); border:1px solid var(--border,#334155); z-index:20; max-height:220px; overflow-y:auto; box-shadow:0 4px 10px rgba(0,0,0,.4); font-size:13px;">
|
||||
<!-- suggestions injected here -->
|
||||
<style>
|
||||
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
|
||||
.theme-tooltip{position:relative; display:inline-flex; align-items:center;}
|
||||
.theme-tooltip button.help-icon{border:none; background:transparent; color:var(--text-muted,#94a3b8); font-size:14px; cursor:help; border-radius:50%; width:20px; height:20px; line-height:20px; text-align:center; transition:color .2s ease;}
|
||||
.theme-tooltip button.help-icon:focus-visible{outline:2px solid var(--accent,#6366f1); outline-offset:2px;}
|
||||
.theme-tooltip .tooltip-panel{position:absolute; top:100%; left:50%; transform:translate(-50%,8px); background:var(--panel,#111827); color:var(--text,#f8fafc); border:1px solid var(--border,#334155); border-radius:8px; padding:10px 12px; font-size:12px; width:220px; box-shadow:0 10px 25px rgba(15,23,42,0.35); opacity:0; pointer-events:none; transition:opacity .2s ease; z-index:30;}
|
||||
.theme-tooltip[data-open="true"] .tooltip-panel{opacity:1; pointer-events:auto;}
|
||||
.theme-tooltip .tooltip-panel p{margin:0; line-height:1.4;}
|
||||
.clear-themes-btn{background:transparent; border:none; color:var(--accent,#6366f1); font-size:12px; padding:2px 8px; border-radius:6px; cursor:pointer; text-decoration:underline; transition:color .15s ease, background .15s ease;}
|
||||
.clear-themes-btn:hover{color:var(--accent-strong,#818cf8);}
|
||||
.clear-themes-btn:focus-visible{outline:2px solid var(--accent,#6366f1); outline-offset:2px;}
|
||||
.strict-toggle{display:flex; align-items:center; gap:8px; font-size:12px; color:var(--text-muted,#94a3b8);}
|
||||
</style>
|
||||
<div class="controls" role="group" aria-label="Random controls" style="display:flex; flex-direction:column; gap:12px;">
|
||||
<fieldset class="theme-section" role="group" aria-labelledby="theme-group-label" style="border:1px solid var(--border,#1f2937); border-radius:10px; padding:12px 16px; display:flex; flex-direction:column; gap:10px; min-width:320px; flex:0 0 auto;">
|
||||
<legend id="theme-group-label" style="font-size:13px; font-weight:600; letter-spacing:.4px; display:flex; align-items:center; gap:6px; justify-content:space-between; flex-wrap:wrap;">
|
||||
<span class="theme-legend-label" style="display:inline-flex; align-items:center; gap:6px;">
|
||||
Themes
|
||||
<span class="theme-tooltip" data-open="false">
|
||||
<button type="button" class="help-icon" aria-expanded="false" aria-controls="theme-tooltip-panel" aria-describedby="theme-tooltip-text">?</button>
|
||||
<span id="theme-tooltip-text" class="sr-only">Explain theme fallback order</span>
|
||||
<div id="theme-tooltip-panel" class="tooltip-panel" role="dialog" aria-modal="false">
|
||||
<p>We attempt your Primary + Secondary + Tertiary first. If that has no hits, we relax to Primary + Secondary, then Primary + Tertiary, Primary only, synergy overlap, and finally the full pool.</p>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" id="random-clear-themes" class="clear-themes-btn" aria-describedby="theme-group-label">Clear themes</button>
|
||||
</legend>
|
||||
<div style="display:flex; flex-direction:column; gap:10px;" aria-describedby="theme-help">
|
||||
<div class="theme-row" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
||||
<label for="random-primary-theme" class="field-label" style="min-width:110px; font-weight:600;">Primary</label>
|
||||
<div style="position:relative; flex:1 1 260px; min-width:220px;">
|
||||
<input id="random-primary-theme" name="primary_theme" type="text" placeholder="e.g. Aggro, Goblin Kindred" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="primary-theme-suggest-box" aria-controls="primary-theme-suggest-box" aria-describedby="theme-help" aria-haspopup="listbox" />
|
||||
<div id="primary-theme-suggest-box" role="listbox" style="display:none; position:absolute; top:100%; left:0; right:0; background:var(--panel,#1e293b); border:1px solid var(--border,#334155); z-index:22; max-height:220px; overflow-y:auto; box-shadow:0 4px 10px rgba(0,0,0,.4); font-size:13px;">
|
||||
<!-- suggestions injected here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-row" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
||||
<label for="random-secondary-theme" class="field-label" style="min-width:110px; font-weight:600;">Secondary <span style="font-size:11px; color:var(--text-muted,#94a3b8); font-weight:400;">(optional)</span></label>
|
||||
<div style="position:relative; flex:1 1 260px; min-width:220px;">
|
||||
<input id="random-secondary-theme" name="secondary_theme" type="text" placeholder="Optional secondary" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="secondary-theme-suggest-box" aria-controls="secondary-theme-suggest-box" aria-describedby="theme-help" aria-haspopup="listbox" />
|
||||
<div id="secondary-theme-suggest-box" role="listbox" style="display:none; position:absolute; top:100%; left:0; right:0; background:var(--panel,#1e293b); border:1px solid var(--border,#334155); z-index:21; max-height:220px; overflow-y:auto; box-shadow:0 4px 10px rgba(0,0,0,.4); font-size:13px;">
|
||||
<!-- suggestions injected here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-row" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
||||
<label for="random-tertiary-theme" class="field-label" style="min-width:110px; font-weight:600;">Tertiary <span style="font-size:11px; color:var(--text-muted,#94a3b8); font-weight:400;">(optional)</span></label>
|
||||
<div style="position:relative; flex:1 1 260px; min-width:220px;">
|
||||
<input id="random-tertiary-theme" name="tertiary_theme" type="text" placeholder="Optional tertiary" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="tertiary-theme-suggest-box" aria-controls="tertiary-theme-suggest-box" aria-describedby="theme-help" aria-haspopup="listbox" />
|
||||
<div id="tertiary-theme-suggest-box" role="listbox" style="display:none; position:absolute; top:100%; left:0; right:0; background:var(--panel,#1e293b); border:1px solid var(--border,#334155); z-index:20; max-height:220px; overflow-y:auto; box-shadow:0 4px 10px rgba(0,0,0,.4); font-size:13px;">
|
||||
<!-- suggestions injected here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auto-fill-toggle" style="display:flex; flex-direction:column; gap:6px; margin-top:4px;">
|
||||
<input type="hidden" id="random-auto-fill-state" name="auto_fill_enabled" value="false" />
|
||||
<input type="hidden" id="random-auto-fill-secondary-state" name="auto_fill_secondary_enabled" value="false" />
|
||||
<input type="hidden" id="random-auto-fill-tertiary-state" name="auto_fill_tertiary_enabled" value="false" />
|
||||
<div style="display:flex; align-items:flex-start; gap:12px;">
|
||||
<div style="min-width:110px; font-weight:600; margin-top:2px; display:flex; align-items:center; gap:6px;">
|
||||
<span>Auto-fill themes</span>
|
||||
<span class="theme-tooltip auto-fill-tooltip" data-open="false">
|
||||
<button type="button" class="help-icon" aria-expanded="false" aria-controls="auto-fill-tooltip-panel" aria-describedby="auto-fill-tooltip-text">?</button>
|
||||
<span id="auto-fill-tooltip-text" class="sr-only">Auto-fill only fills empty slots; manual entries always take priority.</span>
|
||||
<div id="auto-fill-tooltip-panel" class="tooltip-panel" role="dialog" aria-modal="false">
|
||||
<p>We only auto-fill the theme slots you leave empty. Manual entries always take priority.</p>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div style="display:flex; flex-direction:column; gap:4px;">
|
||||
<div style="display:flex; align-items:center; gap:16px; flex-wrap:wrap;">
|
||||
<label for="random-auto-fill-secondary" style="display:inline-flex; align-items:center; gap:6px; font-size:12px;">
|
||||
<input id="random-auto-fill-secondary" type="checkbox" aria-describedby="auto-fill-tooltip-text" />
|
||||
<span>Fill Secondary</span>
|
||||
</label>
|
||||
<label for="random-auto-fill-tertiary" style="display:inline-flex; align-items:center; gap:6px; font-size:12px;">
|
||||
<input id="random-auto-fill-tertiary" type="checkbox" aria-describedby="auto-fill-tooltip-text" />
|
||||
<span>Fill Tertiary</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strict-toggle" style="margin-top:4px;">
|
||||
<input type="hidden" id="random-strict-theme-hidden" name="strict_theme_match" value="{{ 'true' if strict_theme_match|default(false) else 'false' }}" />
|
||||
<label for="random-strict-theme" style="display:inline-flex; align-items:center; gap:6px; cursor:pointer;">
|
||||
<input id="random-strict-theme" type="checkbox" aria-describedby="random-strict-theme-help" {% if strict_theme_match|default(false) %}checked{% endif %} />
|
||||
<span>Require exact theme match (no fallbacks)</span>
|
||||
</label>
|
||||
<span id="random-strict-theme-help" class="sr-only">When enabled, themes must match exactly. No fallback themes will be used.</span>
|
||||
</div>
|
||||
<input type="hidden" id="random-legacy-theme" name="theme" value="" />
|
||||
</fieldset>
|
||||
<div class="action-row" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
||||
{% if show_diagnostics %}
|
||||
<div class="diagnostics" style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; font-size:11px;">
|
||||
<label for="rand-attempts" style="font-size:11px;">Attempts</label>
|
||||
<input id="rand-attempts" name="attempts" type="number" min="1" max="25" value="{{ random_max_attempts }}" style="width:60px; font-size:11px;" title="Override max attempts" />
|
||||
<label for="rand-timeout" style="font-size:11px;">Timeout(ms)</label>
|
||||
<input id="rand-timeout" name="timeout_ms" type="number" min="100" max="15000" step="100" value="{{ random_timeout_ms }}" style="width:80px; font-size:11px;" title="Override generation timeout in milliseconds" />
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="button-row" style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
|
||||
<!-- Added hx-trigger with delay to provide debounce without custom JS recursion -->
|
||||
<button id="btn-surprise" class="btn" hx-post="/hx/random_reroll" hx-vals='{"mode":"surprise"}' hx-include="#random-primary-theme,#random-secondary-theme,#random-tertiary-theme,#random-legacy-theme,#random-auto-fill-state,#random-auto-fill-secondary-state,#random-auto-fill-tertiary-state,#random-strict-theme-hidden{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Surprise me">Surprise me</button>
|
||||
<button id="btn-reroll" class="btn" hx-post="/hx/random_reroll" hx-vals='{"mode":"reroll_same_commander"}' hx-include="#current-seed,#current-commander,#current-primary-theme,#current-secondary-theme,#current-tertiary-theme,#current-resolved-themes,#current-auto-fill-enabled,#current-auto-fill-secondary-enabled,#current-auto-fill-tertiary-enabled,#current-strict-theme-match,#random-primary-theme,#random-secondary-theme,#random-tertiary-theme,#random-legacy-theme,#random-auto-fill-state,#random-auto-fill-secondary-state,#random-auto-fill-tertiary-state,#random-strict-theme-hidden{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Reroll" disabled>Reroll</button>
|
||||
<span id="spinner" role="status" aria-live="polite" style="display:none;">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if show_diagnostics %}
|
||||
<label for="rand-attempts" style="font-size:11px;">Attempts</label>
|
||||
<input id="rand-attempts" name="attempts" type="number" min="1" max="25" value="{{ random_max_attempts }}" style="width:60px; font-size:11px;" title="Override max attempts" />
|
||||
<label for="rand-timeout" style="font-size:11px;">Timeout(ms)</label>
|
||||
<input id="rand-timeout" name="timeout_ms" type="number" min="100" max="15000" step="100" value="{{ random_timeout_ms }}" style="width:80px; font-size:11px;" title="Override generation timeout in milliseconds" />
|
||||
{% endif %}
|
||||
<!-- Added hx-trigger with delay to provide debounce without custom JS recursion -->
|
||||
<button id="btn-surprise" class="btn" hx-post="/hx/random_reroll" hx-vals='{"mode":"surprise"}' hx-include="#random-theme{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click delay:150ms" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Surprise me">Surprise me</button>
|
||||
<button id="btn-reroll" class="btn" hx-post="/hx/random_reroll" hx-vals='{"mode":"reroll_same_commander"}' hx-include="#current-seed,#current-commander,#random-theme{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click delay:150ms" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Reroll" disabled>Reroll</button>
|
||||
<button id="btn-share" class="btn" type="button" aria-label="Copy permalink" onclick="(async ()=>{try{const r=await fetch('/build/permalink'); const j=await r.json(); const url=(j.permalink? location.origin + j.permalink : location.href); await navigator.clipboard.writeText(url); (window.toast && toast('Permalink copied')) || alert('Permalink copied');}catch(e){console.error(e); alert('Failed to copy permalink');}})()">Share</button>
|
||||
<span id="spinner" role="status" aria-live="polite" style="display:none; margin-left:8px;">Loading…</span>
|
||||
</div>
|
||||
<div id="rate-limit-banner" role="status" aria-live="polite" style="display:none; margin-top:8px; padding:6px 8px; border:1px solid #cc9900; background:#fff8e1; color:#5f4200; border-radius:4px;">
|
||||
Too many requests. Please wait…
|
||||
|
|
@ -40,78 +133,334 @@
|
|||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
// Typeahead: simple debounce + /themes/suggest
|
||||
var input = document.getElementById('random-theme');
|
||||
var listBox = document.getElementById('theme-suggest-box');
|
||||
var to = null;
|
||||
// Typeahead: debounce + /themes/suggest shared across inputs
|
||||
var legacyInput = document.getElementById('random-legacy-theme');
|
||||
var AUTO_FILL_KEY = 'random_auto_fill_enabled';
|
||||
var AUTO_FILL_SECONDARY_KEY = 'random_auto_fill_secondary_enabled';
|
||||
var AUTO_FILL_TERTIARY_KEY = 'random_auto_fill_tertiary_enabled';
|
||||
var THEME_KEY = 'random_last_primary_theme';
|
||||
var LEGACY_KEY = 'random_last_theme';
|
||||
var SECONDARY_KEY = 'random_last_secondary_theme';
|
||||
var TERTIARY_KEY = 'random_last_tertiary_theme';
|
||||
var themeInputs = {
|
||||
primary: {
|
||||
input: document.getElementById('random-primary-theme'),
|
||||
list: document.getElementById('primary-theme-suggest-box'),
|
||||
syncLegacy: true,
|
||||
},
|
||||
secondary: {
|
||||
input: document.getElementById('random-secondary-theme'),
|
||||
list: document.getElementById('secondary-theme-suggest-box'),
|
||||
},
|
||||
tertiary: {
|
||||
input: document.getElementById('random-tertiary-theme'),
|
||||
list: document.getElementById('tertiary-theme-suggest-box'),
|
||||
}
|
||||
};
|
||||
var primaryInput = themeInputs.primary.input;
|
||||
var secondaryInput = themeInputs.secondary.input;
|
||||
var tertiaryInput = themeInputs.tertiary.input;
|
||||
var clearThemesButton = document.getElementById('random-clear-themes');
|
||||
var autoFillHidden = document.getElementById('random-auto-fill-state');
|
||||
var autoFillSecondaryHidden = document.getElementById('random-auto-fill-secondary-state');
|
||||
var autoFillTertiaryHidden = document.getElementById('random-auto-fill-tertiary-state');
|
||||
var autoFillSecondaryInput = document.getElementById('random-auto-fill-secondary');
|
||||
var autoFillTertiaryInput = document.getElementById('random-auto-fill-tertiary');
|
||||
var strictHidden = document.getElementById('random-strict-theme-hidden');
|
||||
var strictCheckbox = document.getElementById('random-strict-theme');
|
||||
var STRICT_KEY = 'random_strict_theme_match';
|
||||
var debounceTimers = new Map();
|
||||
var cache = new Map(); // simple in-memory cache of q -> [names]
|
||||
var REROLL_CACHE_STORAGE_KEY = 'random_theme_suggest_cache_v1';
|
||||
var throttleRaw = "{{ random_reroll_throttle_ms | default(350) }}";
|
||||
var REROLL_THROTTLE_MS = parseInt(throttleRaw, 10);
|
||||
if (isNaN(REROLL_THROTTLE_MS) || REROLL_THROTTLE_MS < 0) {
|
||||
REROLL_THROTTLE_MS = 0;
|
||||
}
|
||||
var throttleTimerId = null;
|
||||
var throttleUnlockAt = 0;
|
||||
var lastRandomRequestAt = 0;
|
||||
var rateLimitIntervalId = null;
|
||||
var activeIndex = -1; // keyboard highlight
|
||||
function hideList(){ if(listBox){ listBox.style.display='none'; input.setAttribute('aria-expanded','false'); activeIndex=-1; } }
|
||||
var activeKey = null;
|
||||
try {
|
||||
var persistedCache = sessionStorage.getItem(REROLL_CACHE_STORAGE_KEY);
|
||||
if (persistedCache) {
|
||||
var parsedCache = JSON.parse(persistedCache);
|
||||
if (Array.isArray(parsedCache)) {
|
||||
parsedCache.forEach(function(entry) {
|
||||
if (!entry || typeof entry.q !== 'string') {
|
||||
return;
|
||||
}
|
||||
var key = entry.q.toLowerCase();
|
||||
var results = Array.isArray(entry.results) ? entry.results : [];
|
||||
cache.set(key, results);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore storage failures */ }
|
||||
|
||||
function enforceTertiaryRequirement(){
|
||||
if(autoFillTertiaryInput && autoFillSecondaryInput){
|
||||
if(autoFillTertiaryInput.checked && !autoFillSecondaryInput.checked){
|
||||
autoFillSecondaryInput.checked = true;
|
||||
}
|
||||
}
|
||||
if(autoFillSecondaryInput && autoFillTertiaryInput){
|
||||
var secChecked = !!autoFillSecondaryInput.checked;
|
||||
autoFillTertiaryInput.disabled = !secChecked;
|
||||
if(!secChecked && autoFillTertiaryInput.checked){
|
||||
autoFillTertiaryInput.checked = false;
|
||||
}
|
||||
} else if(autoFillTertiaryInput){
|
||||
autoFillTertiaryInput.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function currentAutoFillFlags(){
|
||||
enforceTertiaryRequirement();
|
||||
var secondaryChecked = !!(autoFillSecondaryInput && autoFillSecondaryInput.checked);
|
||||
var tertiaryChecked = !!(secondaryChecked && autoFillTertiaryInput && autoFillTertiaryInput.checked);
|
||||
return {
|
||||
secondary: secondaryChecked,
|
||||
tertiary: tertiaryChecked,
|
||||
};
|
||||
}
|
||||
|
||||
function syncAutoFillHidden(){
|
||||
var flags = currentAutoFillFlags();
|
||||
if(autoFillSecondaryHidden){ autoFillSecondaryHidden.value = flags.secondary ? 'true' : 'false'; }
|
||||
if(autoFillTertiaryHidden){ autoFillTertiaryHidden.value = flags.tertiary ? 'true' : 'false'; }
|
||||
if(autoFillHidden){ autoFillHidden.value = (flags.secondary || flags.tertiary) ? 'true' : 'false'; }
|
||||
}
|
||||
|
||||
function persistAutoFillPreferences(){
|
||||
try{
|
||||
var flags = currentAutoFillFlags();
|
||||
localStorage.setItem(AUTO_FILL_SECONDARY_KEY, flags.secondary ? '1' : '0');
|
||||
localStorage.setItem(AUTO_FILL_TERTIARY_KEY, flags.tertiary ? '1' : '0');
|
||||
localStorage.setItem(AUTO_FILL_KEY, (flags.secondary || flags.tertiary) ? '1' : '0');
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
|
||||
function syncStrictHidden(){
|
||||
if(strictHidden){
|
||||
var checked = !!(strictCheckbox && strictCheckbox.checked);
|
||||
strictHidden.value = checked ? 'true' : 'false';
|
||||
}
|
||||
}
|
||||
|
||||
function persistStrictPreference(){
|
||||
if(!strictCheckbox) return;
|
||||
try{
|
||||
localStorage.setItem(STRICT_KEY, strictCheckbox.checked ? '1' : '0');
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
|
||||
function hideAllLists(){
|
||||
Object.keys(themeInputs).forEach(function(key){
|
||||
var cfg = themeInputs[key];
|
||||
if(cfg.list){ cfg.list.style.display='none'; }
|
||||
if(cfg.input){
|
||||
cfg.input.setAttribute('aria-expanded','false');
|
||||
cfg.input.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
});
|
||||
activeIndex = -1;
|
||||
activeKey = null;
|
||||
}
|
||||
function syncLegacy(){
|
||||
if(!legacyInput) return;
|
||||
legacyInput.value = primaryInput && primaryInput.value ? primaryInput.value : '';
|
||||
}
|
||||
function clearThemeInputs(){
|
||||
if(primaryInput){ primaryInput.value = ''; }
|
||||
if(secondaryInput){ secondaryInput.value = ''; }
|
||||
if(tertiaryInput){ tertiaryInput.value = ''; }
|
||||
hideAllLists();
|
||||
syncLegacy();
|
||||
try{
|
||||
localStorage.setItem(THEME_KEY, '');
|
||||
localStorage.setItem(LEGACY_KEY, '');
|
||||
localStorage.setItem(SECONDARY_KEY, '');
|
||||
localStorage.setItem(TERTIARY_KEY, '');
|
||||
}catch(e){ /* ignore */ }
|
||||
if(primaryInput){ primaryInput.dispatchEvent(new Event('change')); }
|
||||
if(secondaryInput){ secondaryInput.dispatchEvent(new Event('change')); }
|
||||
if(tertiaryInput){ tertiaryInput.dispatchEvent(new Event('change')); }
|
||||
if(primaryInput){ primaryInput.focus(); }
|
||||
}
|
||||
function collectThemePayload(extra){
|
||||
syncAutoFillHidden();
|
||||
syncStrictHidden();
|
||||
var payload = Object.assign({}, extra || {});
|
||||
var primaryVal = primaryInput && primaryInput.value ? primaryInput.value.trim() : null;
|
||||
var secondaryVal = secondaryInput && secondaryInput.value ? secondaryInput.value.trim() : null;
|
||||
var tertiaryVal = tertiaryInput && tertiaryInput.value ? tertiaryInput.value.trim() : null;
|
||||
payload.theme = primaryVal;
|
||||
payload.primary_theme = primaryVal;
|
||||
payload.secondary_theme = secondaryVal || null;
|
||||
payload.tertiary_theme = tertiaryVal || null;
|
||||
var flags = currentAutoFillFlags();
|
||||
if(!autoFillSecondaryInput && autoFillSecondaryHidden){
|
||||
var secVal = (autoFillSecondaryHidden.value || '').toLowerCase();
|
||||
flags.secondary = (secVal === 'true' || secVal === '1' || secVal === 'on');
|
||||
}
|
||||
if(!autoFillTertiaryInput && autoFillTertiaryHidden){
|
||||
var terVal = (autoFillTertiaryHidden.value || '').toLowerCase();
|
||||
flags.tertiary = (terVal === 'true' || terVal === '1' || terVal === 'on');
|
||||
}
|
||||
if(flags.tertiary && !flags.secondary){
|
||||
flags.secondary = true;
|
||||
}
|
||||
if(!flags.secondary){
|
||||
flags.tertiary = false;
|
||||
}
|
||||
payload.auto_fill_secondary_enabled = flags.secondary;
|
||||
payload.auto_fill_tertiary_enabled = flags.tertiary;
|
||||
payload.auto_fill_enabled = flags.secondary || flags.tertiary;
|
||||
var strictFlag = false;
|
||||
if(strictCheckbox){
|
||||
strictFlag = !!strictCheckbox.checked;
|
||||
} else if(strictHidden){
|
||||
var strictVal = (strictHidden.value || '').toLowerCase();
|
||||
strictFlag = (strictVal === 'true' || strictVal === '1' || strictVal === 'on');
|
||||
}
|
||||
payload.strict_theme_match = strictFlag;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function submitRandomPayload(payload){
|
||||
if(!payload) payload = {};
|
||||
if(!canTriggerRandomRequest()){
|
||||
return;
|
||||
}
|
||||
markRandomRequestStarted();
|
||||
var spinner = document.getElementById('spinner');
|
||||
if(spinner){ spinner.style.display = 'inline-block'; }
|
||||
try{
|
||||
var res = await fetch('/hx/random_reroll', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if(res.status === 429){
|
||||
var ra = res.headers ? res.headers.get('Retry-After') : null;
|
||||
var secs = ra ? parseInt(ra, 10) : null;
|
||||
if(window.toast){ try{ toast('Too many requests'); }catch(e){} }
|
||||
if(secs && !isNaN(secs)){ applyRetryAfterSeconds(secs); }
|
||||
else { showRateLimitBanner(null, 'Too many requests'); }
|
||||
return;
|
||||
}
|
||||
if(!res.ok){
|
||||
if(window.toast){ try{ toast('Failed to load random build'); }catch(e){} }
|
||||
return;
|
||||
}
|
||||
var html = await res.text();
|
||||
var target = document.getElementById('random-result');
|
||||
if(target){ target.outerHTML = html; }
|
||||
hideRateLimitBanner();
|
||||
}catch(err){
|
||||
if(window.toast){ try{ toast('Error contacting server'); }catch(e){} }
|
||||
}finally{
|
||||
if(spinner){ spinner.style.display = 'none'; }
|
||||
enableRandomButtonsIfReady();
|
||||
}
|
||||
}
|
||||
function highlight(text, q){
|
||||
try{ if(!q) return text; var i=text.toLowerCase().indexOf(q.toLowerCase()); if(i===-1) return text; return text.substring(0,i)+'<mark style="background:#4f46e5; color:#fff; padding:0 2px; border-radius:2px;">'+text.substring(i,i+q.length)+'</mark>'+text.substring(i+q.length);}catch(e){return text;}}
|
||||
function renderList(items, q){
|
||||
if(!listBox) return; listBox.innerHTML=''; activeIndex=-1;
|
||||
if(!items || !items.length){ hideList(); return; }
|
||||
function renderList(key, items, q){
|
||||
var cfg = themeInputs[key];
|
||||
if(!cfg || !cfg.list){ return; }
|
||||
var list = cfg.list;
|
||||
list.innerHTML='';
|
||||
activeIndex = -1;
|
||||
if(!items || !items.length){
|
||||
list.style.display='none';
|
||||
if(cfg.input){
|
||||
cfg.input.setAttribute('aria-expanded','false');
|
||||
cfg.input.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
activeKey = null;
|
||||
return;
|
||||
}
|
||||
items.slice(0,50).forEach(function(it, idx){
|
||||
var div=document.createElement('div');
|
||||
div.setAttribute('role','option');
|
||||
div.setAttribute('data-value', it);
|
||||
var optionId = key + '-theme-option-' + idx;
|
||||
div.id = optionId;
|
||||
div.innerHTML=highlight(it, q);
|
||||
div.style.cssText='padding:4px 8px; cursor:pointer;';
|
||||
div.addEventListener('mouseenter', function(){ setActive(idx); });
|
||||
div.addEventListener('mousedown', function(ev){ ev.preventDefault(); pick(it); });
|
||||
listBox.appendChild(div);
|
||||
div.addEventListener('mousedown', function(ev){ ev.preventDefault(); pick(key, it); });
|
||||
list.appendChild(div);
|
||||
});
|
||||
listBox.style.display='block';
|
||||
input.setAttribute('aria-expanded','true');
|
||||
list.style.display='block';
|
||||
if(cfg.input){
|
||||
cfg.input.setAttribute('aria-expanded','true');
|
||||
cfg.input.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
activeKey = key;
|
||||
}
|
||||
function currentList(){
|
||||
if(!activeKey) return null;
|
||||
var cfg = themeInputs[activeKey];
|
||||
return cfg ? cfg.list : null;
|
||||
}
|
||||
function setActive(idx){
|
||||
if(!listBox) return; var children=[...listBox.children];
|
||||
var list = currentList();
|
||||
if(!list) return;
|
||||
var children=[...list.children];
|
||||
children.forEach(function(c,i){ c.style.background = (i===idx) ? 'rgba(99,102,241,0.35)' : 'transparent'; });
|
||||
var cfg = activeKey ? themeInputs[activeKey] : null;
|
||||
if(cfg && cfg.input){
|
||||
if(idx >= 0 && children[idx]){
|
||||
cfg.input.setAttribute('aria-activedescendant', children[idx].id || '');
|
||||
} else {
|
||||
cfg.input.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
}
|
||||
activeIndex = idx;
|
||||
}
|
||||
function move(delta){
|
||||
if(!listBox || listBox.style.display==='none'){ return; }
|
||||
var children=[...listBox.children]; if(!children.length) return;
|
||||
var list = currentList();
|
||||
if(!list || list.style.display==='none'){ return; }
|
||||
var children=[...list.children]; if(!children.length) return;
|
||||
var next = activeIndex + delta; if(next < 0) next = children.length -1; if(next >= children.length) next = 0;
|
||||
setActive(next);
|
||||
var el = children[next]; if(el && el.scrollIntoView){ el.scrollIntoView({block:'nearest'}); }
|
||||
}
|
||||
function pick(value){ input.value = value; hideList(); input.dispatchEvent(new Event('change')); }
|
||||
function updateList(items, q){ renderList(items, q); }
|
||||
function showRateLimitBanner(seconds){
|
||||
var b = document.getElementById('rate-limit-banner');
|
||||
var btn1 = document.getElementById('btn-surprise');
|
||||
var btn2 = document.getElementById('btn-reroll');
|
||||
if(!b){ return; }
|
||||
var secs = (typeof seconds === 'number' && !isNaN(seconds) && seconds > 0) ? Math.floor(seconds) : null;
|
||||
var base = 'Too many requests';
|
||||
var update = function(){
|
||||
if(secs !== null){ b.textContent = base + ' — try again in ' + secs + 's'; }
|
||||
else { b.textContent = base + ' — please try again shortly'; }
|
||||
};
|
||||
update();
|
||||
b.style.display = 'block';
|
||||
if(btn1) btn1.disabled = true; if(btn2) btn2.disabled = true;
|
||||
if(secs !== null){
|
||||
var t = setInterval(function(){
|
||||
secs -= 1; update();
|
||||
if(secs <= 0){ clearInterval(t); b.style.display = 'none'; if(btn1) btn1.disabled = false; if(btn2) btn2.disabled = false; }
|
||||
}, 1000);
|
||||
}
|
||||
function pick(key, value){
|
||||
var cfg = themeInputs[key];
|
||||
if(!cfg || !cfg.input) return;
|
||||
cfg.input.value = value;
|
||||
if(key === 'primary'){ syncLegacy(); }
|
||||
hideAllLists();
|
||||
cfg.input.dispatchEvent(new Event('change'));
|
||||
cfg.input.removeAttribute('aria-activedescendant');
|
||||
cfg.input.focus();
|
||||
}
|
||||
function persistSuggestCache(){
|
||||
try{
|
||||
if(typeof sessionStorage === 'undefined'){ return; }
|
||||
var entries = [];
|
||||
cache.forEach(function(results, q){
|
||||
entries.push({ q: q, results: Array.isArray(results) ? results.slice(0, 40) : [] });
|
||||
});
|
||||
if(entries.length > 75){
|
||||
entries = entries.slice(entries.length - 75);
|
||||
}
|
||||
sessionStorage.setItem(REROLL_CACHE_STORAGE_KEY, JSON.stringify(entries));
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
|
||||
function highlightMatch(item, q){
|
||||
try{
|
||||
var idx = item.toLowerCase().indexOf(q.toLowerCase());
|
||||
if(idx === -1) return item;
|
||||
return item.substring(0,idx) + '[[' + item.substring(idx, idx+q.length) + ']]' + item.substring(idx+q.length);
|
||||
}catch(e){ return item; }
|
||||
}
|
||||
async function fetchSuggest(q){
|
||||
async function fetchSuggest(q, key){
|
||||
try{
|
||||
var cachedKey = (q || '').toLowerCase();
|
||||
var u = '/themes/api/suggest' + (q? ('?q=' + encodeURIComponent(q)) : '');
|
||||
if(cache.has(q)) { updateList(cache.get(q)); return; }
|
||||
if(cache.has(cachedKey)) { renderList(key, cache.get(cachedKey), q); return; }
|
||||
var r = await fetch(u);
|
||||
if(r.status === 429){
|
||||
var ra = r.headers.get('Retry-After');
|
||||
|
|
@ -120,45 +469,244 @@
|
|||
if(secs && !isNaN(secs)) msg += ' — retry in ' + secs + 's';
|
||||
if(window.toast) { toast(msg); } else { console.warn(msg); }
|
||||
showRateLimitBanner(secs);
|
||||
return updateList([]);
|
||||
hideAllLists();
|
||||
return;
|
||||
}
|
||||
if(!r.ok) return updateList([]);
|
||||
if(!r.ok){ renderList(key, [], q); return; }
|
||||
var j = await r.json();
|
||||
var items = (j && j.themes) || [];
|
||||
cache.set(q, items);
|
||||
// cap cache size to 50
|
||||
if(cache.size > 50){
|
||||
cache.set(cachedKey, items);
|
||||
// cap cache size to 60
|
||||
if(cache.size > 60){
|
||||
var firstKey = cache.keys().next().value; cache.delete(firstKey);
|
||||
}
|
||||
updateList(items, q);
|
||||
persistSuggestCache();
|
||||
renderList(key, items, q);
|
||||
}catch(e){ /* no-op */ }
|
||||
}
|
||||
if(input){
|
||||
input.addEventListener('input', function(){
|
||||
var q = input.value || '';
|
||||
if(to) clearTimeout(to);
|
||||
if(!q || q.length < 2){ hideList(); return; }
|
||||
to = setTimeout(function(){ fetchSuggest(q); }, 150);
|
||||
function attachInput(key){
|
||||
var cfg = themeInputs[key];
|
||||
if(!cfg || !cfg.input) return;
|
||||
cfg.input.addEventListener('input', function(){
|
||||
if(key === 'primary'){ syncLegacy(); }
|
||||
var q = cfg.input.value || '';
|
||||
var existingTimer = debounceTimers.get(key);
|
||||
if(existingTimer) clearTimeout(existingTimer);
|
||||
if(!q || q.length < 2){ hideAllLists(); return; }
|
||||
var timerId = setTimeout(function(){ fetchSuggest(q, key); }, 150);
|
||||
debounceTimers.set(key, timerId);
|
||||
});
|
||||
input.addEventListener('keydown', function(ev){
|
||||
cfg.input.addEventListener('focus', function(){
|
||||
var q = cfg.input.value || '';
|
||||
if(q && q.length >= 2){ fetchSuggest(q, key); }
|
||||
});
|
||||
cfg.input.addEventListener('keydown', function(ev){
|
||||
if(ev.key === 'ArrowDown'){ ev.preventDefault(); move(1); }
|
||||
else if(ev.key === 'ArrowUp'){ ev.preventDefault(); move(-1); }
|
||||
else if(ev.key === 'Enter'){ if(activeIndex >=0 && listBox && listBox.children[activeIndex]){ ev.preventDefault(); pick(listBox.children[activeIndex].getAttribute('data-value')); } }
|
||||
else if(ev.key === 'Escape'){ hideList(); }
|
||||
else if(ev.key === 'Enter'){
|
||||
var list = currentList();
|
||||
if(list && activeKey && activeIndex >= 0 && list.children[activeIndex]){
|
||||
ev.preventDefault();
|
||||
pick(activeKey, list.children[activeIndex].getAttribute('data-value'));
|
||||
} else {
|
||||
hideAllLists();
|
||||
}
|
||||
}
|
||||
else if(ev.key === 'Escape'){ hideAllLists(); }
|
||||
});
|
||||
document.addEventListener('click', function(ev){ if(!listBox) return; if(ev.target === input || listBox.contains(ev.target)){ return; } hideList(); });
|
||||
}
|
||||
// Relying on hx-trigger delay (150ms) for soft debounce. Added hx-disabled-elt to avoid rapid spamming.
|
||||
Object.keys(themeInputs).forEach(attachInput);
|
||||
document.addEventListener('click', function(ev){
|
||||
var target = ev.target;
|
||||
var insideInput = Object.keys(themeInputs).some(function(key){ var cfg = themeInputs[key]; return cfg.input && cfg.input === target; });
|
||||
var insideList = Object.keys(themeInputs).some(function(key){ var cfg = themeInputs[key]; return cfg.list && cfg.list.contains(target); });
|
||||
if(!insideInput && !insideList){ hideAllLists(); }
|
||||
});
|
||||
if(clearThemesButton){
|
||||
clearThemesButton.addEventListener('click', function(){
|
||||
clearThemeInputs();
|
||||
if(window.toast){ try{ toast('Themes cleared'); }catch(e){ /* ignore */ } }
|
||||
});
|
||||
}
|
||||
if(primaryInput){ primaryInput.addEventListener('change', syncLegacy); syncLegacy(); }
|
||||
function disableRandomButtons(){
|
||||
var btn1 = document.getElementById('btn-surprise');
|
||||
var btn2 = document.getElementById('btn-reroll');
|
||||
if(btn1) btn1.disabled = true;
|
||||
if(btn2) btn2.disabled = true;
|
||||
}
|
||||
|
||||
function enableRandomButtonsIfReady(){
|
||||
var btn1 = document.getElementById('btn-surprise');
|
||||
var btn2 = document.getElementById('btn-reroll');
|
||||
var now = Date.now();
|
||||
if(REROLL_THROTTLE_MS > 0 && now < throttleUnlockAt){
|
||||
scheduleThrottleUnlock();
|
||||
return;
|
||||
}
|
||||
if(throttleTimerId){
|
||||
clearTimeout(throttleTimerId);
|
||||
throttleTimerId = null;
|
||||
}
|
||||
if(btn1) btn1.disabled = false;
|
||||
if(btn2){
|
||||
btn2.disabled = !document.getElementById('current-seed');
|
||||
}
|
||||
hideRateLimitBanner();
|
||||
}
|
||||
|
||||
function scheduleThrottleUnlock(){
|
||||
if(REROLL_THROTTLE_MS <= 0){ return; }
|
||||
if(throttleTimerId){ clearTimeout(throttleTimerId); }
|
||||
var delay = Math.max(0, throttleUnlockAt - Date.now());
|
||||
if(delay === 0){
|
||||
enableRandomButtonsIfReady();
|
||||
return;
|
||||
}
|
||||
throttleTimerId = setTimeout(function(){ enableRandomButtonsIfReady(); }, delay);
|
||||
}
|
||||
|
||||
function showRateLimitBanner(seconds, message){
|
||||
var b = document.getElementById('rate-limit-banner');
|
||||
if(!b){ return; }
|
||||
var secs = (typeof seconds === 'number' && !isNaN(seconds) && seconds > 0) ? Math.floor(seconds) : null;
|
||||
var base = message || 'Please wait before trying again';
|
||||
var update = function(){
|
||||
if(secs !== null){ b.textContent = base + ' — try again in ' + secs + 's'; }
|
||||
else { b.textContent = base + ' — please try again shortly'; }
|
||||
};
|
||||
update();
|
||||
b.style.display = 'block';
|
||||
disableRandomButtons();
|
||||
if(rateLimitIntervalId){
|
||||
clearInterval(rateLimitIntervalId);
|
||||
rateLimitIntervalId = null;
|
||||
}
|
||||
if(secs !== null){
|
||||
rateLimitIntervalId = setInterval(function(){
|
||||
secs -= 1; update();
|
||||
if(secs <= 0){
|
||||
if(rateLimitIntervalId){
|
||||
clearInterval(rateLimitIntervalId);
|
||||
rateLimitIntervalId = null;
|
||||
}
|
||||
hideRateLimitBanner();
|
||||
enableRandomButtonsIfReady();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function hideRateLimitBanner(){
|
||||
var b = document.getElementById('rate-limit-banner');
|
||||
if(b){ b.style.display = 'none'; }
|
||||
if(rateLimitIntervalId){
|
||||
clearInterval(rateLimitIntervalId);
|
||||
rateLimitIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function remainingThrottleMs(){
|
||||
if(REROLL_THROTTLE_MS <= 0) return 0;
|
||||
return Math.max(0, throttleUnlockAt - Date.now());
|
||||
}
|
||||
|
||||
function canTriggerRandomRequest(){
|
||||
if(REROLL_THROTTLE_MS <= 0) return true;
|
||||
var remaining = remainingThrottleMs();
|
||||
if(remaining <= 0){ return true; }
|
||||
showRateLimitBanner(Math.ceil(remaining / 1000), 'Hold up — reroll throttle active');
|
||||
scheduleThrottleUnlock();
|
||||
return false;
|
||||
}
|
||||
|
||||
function markRandomRequestStarted(){
|
||||
lastRandomRequestAt = Date.now();
|
||||
if(REROLL_THROTTLE_MS <= 0){ return; }
|
||||
throttleUnlockAt = lastRandomRequestAt + REROLL_THROTTLE_MS;
|
||||
disableRandomButtons();
|
||||
scheduleThrottleUnlock();
|
||||
}
|
||||
|
||||
function applyRetryAfterSeconds(seconds){
|
||||
if(!seconds || isNaN(seconds) || seconds <= 0){ return; }
|
||||
throttleUnlockAt = Date.now() + (seconds * 1000);
|
||||
disableRandomButtons();
|
||||
scheduleThrottleUnlock();
|
||||
showRateLimitBanner(seconds, 'Too many requests');
|
||||
}
|
||||
|
||||
document.addEventListener('htmx:configRequest', function(ev){
|
||||
var detail = ev && ev.detail;
|
||||
if(!detail) return;
|
||||
var path = detail.path || '';
|
||||
if(typeof path !== 'string') return;
|
||||
if(path.indexOf('/random_reroll') === -1){ return; }
|
||||
if(!canTriggerRandomRequest()){
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
markRandomRequestStarted();
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(){
|
||||
// Safety: ensure buttons are always re-enabled after request completes
|
||||
var b1=document.getElementById('btn-surprise'); var b2=document.getElementById('btn-reroll');
|
||||
if(b1) b1.disabled=false; if(b2 && document.getElementById('current-seed')) b2.disabled=false;
|
||||
enableRandomButtonsIfReady();
|
||||
});
|
||||
// (No configRequest hook needed; using hx-vals + hx-include for simple form-style submission.)
|
||||
// Enable reroll once a result exists
|
||||
document.addEventListener('htmx:afterSwap', function(ev){
|
||||
if (ev && ev.detail && ev.detail.target && ev.detail.target.id === 'random-result'){
|
||||
var rr = document.getElementById('btn-reroll'); if (rr) rr.disabled = false;
|
||||
var rr = document.getElementById('btn-reroll');
|
||||
if(rr){
|
||||
if(remainingThrottleMs() > 0){
|
||||
rr.disabled = true;
|
||||
scheduleThrottleUnlock();
|
||||
} else {
|
||||
rr.disabled = false;
|
||||
}
|
||||
}
|
||||
var hiddenAuto = document.getElementById('current-auto-fill-enabled');
|
||||
var aggregated = null;
|
||||
if(hiddenAuto){
|
||||
var aggVal = (hiddenAuto.value || '').toLowerCase();
|
||||
if(aggVal){ aggregated = (aggVal === 'true' || aggVal === '1' || aggVal === 'on'); }
|
||||
}
|
||||
var hiddenSecondary = document.getElementById('current-auto-fill-secondary-enabled');
|
||||
var hiddenTertiary = document.getElementById('current-auto-fill-tertiary-enabled');
|
||||
if(autoFillSecondaryInput){
|
||||
var secNext = null;
|
||||
if(hiddenSecondary){
|
||||
var secVal = (hiddenSecondary.value || '').toLowerCase();
|
||||
secNext = (secVal === 'true' || secVal === '1' || secVal === 'on');
|
||||
} else if(aggregated !== null){
|
||||
secNext = aggregated;
|
||||
}
|
||||
if(secNext !== null){ autoFillSecondaryInput.checked = !!secNext; }
|
||||
}
|
||||
if(autoFillTertiaryInput){
|
||||
var terNext = null;
|
||||
if(hiddenTertiary){
|
||||
var terVal = (hiddenTertiary.value || '').toLowerCase();
|
||||
terNext = (terVal === 'true' || terVal === '1' || terVal === 'on');
|
||||
} else if(aggregated !== null){
|
||||
terNext = aggregated;
|
||||
}
|
||||
if(terNext !== null){ autoFillTertiaryInput.checked = !!terNext; }
|
||||
}
|
||||
enforceTertiaryRequirement();
|
||||
var currentStrict = document.getElementById('current-strict-theme-match');
|
||||
if(strictCheckbox && currentStrict){
|
||||
var strictVal = (currentStrict.value || '').toLowerCase();
|
||||
strictCheckbox.checked = (strictVal === 'true' || strictVal === '1' || strictVal === 'on');
|
||||
} else if(strictHidden && currentStrict){
|
||||
var hiddenVal = (currentStrict.value || '').toLowerCase();
|
||||
strictHidden.value = (hiddenVal === 'true' || hiddenVal === '1' || hiddenVal === 'on') ? 'true' : 'false';
|
||||
}
|
||||
syncStrictHidden();
|
||||
persistStrictPreference();
|
||||
syncAutoFillHidden();
|
||||
persistAutoFillPreferences();
|
||||
hideRateLimitBanner();
|
||||
// Refresh recent seeds asynchronously
|
||||
fetch('/api/random/seeds').then(r=>r.json()).then(function(j){
|
||||
try{
|
||||
|
|
@ -181,7 +729,8 @@
|
|||
var msg = 'Too many requests';
|
||||
if(secs && !isNaN(secs)) msg += ' — try again in ' + secs + 's';
|
||||
if(window.toast) { toast(msg); } else { alert(msg); }
|
||||
showRateLimitBanner(secs);
|
||||
if(secs && !isNaN(secs)) applyRetryAfterSeconds(secs);
|
||||
else showRateLimitBanner(null, 'Too many requests');
|
||||
}
|
||||
}catch(e){/* no-op */}
|
||||
});
|
||||
|
|
@ -210,8 +759,8 @@
|
|||
favorites.forEach(function(s){
|
||||
var btn=document.createElement('button'); btn.type='button'; btn.className='btn'; btn.textContent=s; btn.style.cssText='font-size:10px; margin-right:4px; padding:2px 5px;';
|
||||
btn.addEventListener('click', function(){
|
||||
fetch('/hx/random_reroll', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ seed: s-1, theme: document.getElementById('random-theme').value || null }) })
|
||||
.then(r=>r.text()).then(html=>{ var target=document.getElementById('random-result'); if(target){ target.outerHTML=html; } });
|
||||
var payload = collectThemePayload({ seed: s-1 });
|
||||
submitRandomPayload(payload);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
});
|
||||
|
|
@ -229,10 +778,8 @@
|
|||
b.setAttribute('aria-label','Rebuild using seed '+s);
|
||||
b.addEventListener('click', function(){
|
||||
// Post to reroll endpoint but treat as explicit seed build
|
||||
fetch('/hx/random_reroll', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ seed: s-1, theme: document.getElementById('random-theme').value || null }) })
|
||||
.then(r=> r.text())
|
||||
.then(html=>{ var target=document.getElementById('random-result'); if(target){ target.outerHTML=html; } })
|
||||
.catch(()=>{});
|
||||
var payload = collectThemePayload({ seed: s-1 });
|
||||
submitRandomPayload(payload);
|
||||
});
|
||||
span.appendChild(b);
|
||||
span.appendChild(favoriteButton(s, favorites || []));
|
||||
|
|
@ -260,13 +807,114 @@
|
|||
|
||||
// Persist last used theme in localStorage
|
||||
try {
|
||||
var THEME_KEY='random_last_theme';
|
||||
if(input){
|
||||
var prev = localStorage.getItem(THEME_KEY);
|
||||
if(prev && !input.value){ input.value = prev; }
|
||||
input.addEventListener('change', function(){ localStorage.setItem(THEME_KEY, input.value || ''); });
|
||||
var THEME_KEY='random_last_primary_theme';
|
||||
var LEGACY_KEY='random_last_theme';
|
||||
var SECONDARY_KEY='random_last_secondary_theme';
|
||||
var TERTIARY_KEY='random_last_tertiary_theme';
|
||||
if(primaryInput){
|
||||
var prev = localStorage.getItem(THEME_KEY) || localStorage.getItem(LEGACY_KEY);
|
||||
if(prev && !primaryInput.value){ primaryInput.value = prev; }
|
||||
primaryInput.addEventListener('change', function(){
|
||||
localStorage.setItem(THEME_KEY, primaryInput.value || '');
|
||||
localStorage.setItem(LEGACY_KEY, primaryInput.value || '');
|
||||
syncLegacy();
|
||||
});
|
||||
syncLegacy();
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
if(secondaryInput){
|
||||
var prevSecondary = localStorage.getItem(SECONDARY_KEY);
|
||||
if(prevSecondary && !secondaryInput.value){ secondaryInput.value = prevSecondary; }
|
||||
secondaryInput.addEventListener('change', function(){
|
||||
localStorage.setItem(SECONDARY_KEY, secondaryInput.value || '');
|
||||
});
|
||||
}
|
||||
if(tertiaryInput){
|
||||
var prevTertiary = localStorage.getItem(TERTIARY_KEY);
|
||||
if(prevTertiary && !tertiaryInput.value){ tertiaryInput.value = prevTertiary; }
|
||||
tertiaryInput.addEventListener('change', function(){
|
||||
localStorage.setItem(TERTIARY_KEY, tertiaryInput.value || '');
|
||||
});
|
||||
}
|
||||
var legacyAutoFill = null;
|
||||
try { legacyAutoFill = localStorage.getItem(AUTO_FILL_KEY); } catch(e){ legacyAutoFill = null; }
|
||||
function restoreAutoFillCheckbox(input, storedValue){
|
||||
if(!input) return;
|
||||
if(storedValue !== null){
|
||||
input.checked = (storedValue === '1' || storedValue === 'true' || storedValue === 'on');
|
||||
}
|
||||
input.addEventListener('change', function(){
|
||||
enforceTertiaryRequirement();
|
||||
syncAutoFillHidden();
|
||||
persistAutoFillPreferences();
|
||||
});
|
||||
}
|
||||
var storedSecondary = null;
|
||||
try { storedSecondary = localStorage.getItem(AUTO_FILL_SECONDARY_KEY); } catch(e){ storedSecondary = null; }
|
||||
if(storedSecondary === null){ storedSecondary = legacyAutoFill; }
|
||||
restoreAutoFillCheckbox(autoFillSecondaryInput, storedSecondary);
|
||||
|
||||
var storedTertiary = null;
|
||||
try { storedTertiary = localStorage.getItem(AUTO_FILL_TERTIARY_KEY); } catch(e){ storedTertiary = null; }
|
||||
if(storedTertiary === null){ storedTertiary = legacyAutoFill; }
|
||||
restoreAutoFillCheckbox(autoFillTertiaryInput, storedTertiary);
|
||||
|
||||
enforceTertiaryRequirement();
|
||||
syncAutoFillHidden();
|
||||
persistAutoFillPreferences();
|
||||
|
||||
if(strictCheckbox){
|
||||
var storedStrict = null;
|
||||
try { storedStrict = localStorage.getItem(STRICT_KEY); } catch(e){ storedStrict = null; }
|
||||
if(storedStrict !== null){
|
||||
strictCheckbox.checked = (storedStrict === '1' || storedStrict === 'true' || storedStrict === 'on');
|
||||
}
|
||||
syncStrictHidden();
|
||||
strictCheckbox.addEventListener('change', function(){
|
||||
syncStrictHidden();
|
||||
persistStrictPreference();
|
||||
});
|
||||
persistStrictPreference();
|
||||
} else {
|
||||
syncStrictHidden();
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
syncStrictHidden();
|
||||
|
||||
(function(){
|
||||
var tooltipWrappers = document.querySelectorAll('.theme-tooltip');
|
||||
if(!tooltipWrappers.length) return;
|
||||
tooltipWrappers.forEach(function(tooltipWrapper){
|
||||
var button = tooltipWrapper.querySelector('button.help-icon');
|
||||
var panel = tooltipWrapper.querySelector('.tooltip-panel');
|
||||
if(!button || !panel) return;
|
||||
function setOpen(state){
|
||||
tooltipWrapper.dataset.open = state ? 'true' : 'false';
|
||||
button.setAttribute('aria-expanded', state ? 'true' : 'false');
|
||||
}
|
||||
function handleDocumentClick(ev){
|
||||
if(!tooltipWrapper.contains(ev.target)){ setOpen(false); }
|
||||
}
|
||||
button.addEventListener('click', function(){
|
||||
var isOpen = tooltipWrapper.dataset.open === 'true';
|
||||
setOpen(!isOpen);
|
||||
if(!isOpen){ panel.focus({preventScroll:true}); }
|
||||
});
|
||||
button.addEventListener('keypress', function(ev){
|
||||
if(ev.key === 'Enter' || ev.key === ' '){ ev.preventDefault(); button.click(); }
|
||||
});
|
||||
tooltipWrapper.addEventListener('mouseenter', function(){ setOpen(true); });
|
||||
tooltipWrapper.addEventListener('mouseleave', function(){ setOpen(false); });
|
||||
button.addEventListener('focus', function(){ setOpen(true); });
|
||||
button.addEventListener('blur', function(){
|
||||
setTimeout(function(){
|
||||
if(!tooltipWrapper.contains(document.activeElement)){ setOpen(false); }
|
||||
}, 0);
|
||||
});
|
||||
panel.setAttribute('tabindex','-1');
|
||||
panel.addEventListener('keydown', function(ev){ if(ev.key === 'Escape'){ setOpen(false); button.focus(); } });
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue