mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
Merge pull request #23 from mwisnowski/features/commander-browser
feat(web): refine commander search and theme UX
This commit is contained in:
commit
f48e335e17
12 changed files with 764 additions and 116 deletions
|
|
@ -25,6 +25,12 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
- Commander hover preview collapses to a card-only view when browsing commanders, and all theme chips display without the previous “+ more” overflow badge.
|
||||
- Added a Content Security Policy upgrade directive so proxied HTTPS deployments safely rewrite commander pagination requests to HTTPS, preventing mixed-content blocks.
|
||||
- Commander thumbnails use a fixed-width 160px frame (scaling down on small screens) to eliminate inconsistent image sizing across the catalog.
|
||||
- Commander browser search now separates commander name and theme inputs, introduces fuzzy theme suggestions, and tightens commander name matching to near-exact results.
|
||||
- Commander browser no longer auto-scrolls when typing in search fields, keeping focus anchored near the filters.
|
||||
- Commander theme chips feature larger typography, multi-line wrapping, and a mobile-friendly tap dialog for reading summaries.
|
||||
- Theme dialog now prefers full editorial descriptions, so longer summaries display completely on mobile.
|
||||
- Commander theme labels now unescape leading punctuation (e.g., +2/+2 Counters) to avoid stray backslashes in the UI.
|
||||
- Theme summary dialog now opens when clicking theme chips on desktop as well as mobile.
|
||||
- Commander list pagination controls now appear above and below the results and automatically scroll to the top when switching pages for quicker navigation.
|
||||
- Mobile commander rows now feature larger thumbnails and a centered preview modal with expanded card art for improved readability.
|
||||
- Preview performance CI check now waits for `/healthz` and retries theme catalog pagination fetches to dodge transient 500s during cold starts.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@
|
|||
- Commander hover preview shows card-only panel in browser context and removes the “+ more” overflow badge from theme chips.
|
||||
- Content Security Policy upgrade directive ensures HTMX pagination requests remain HTTPS-safe behind proxies.
|
||||
- Commander thumbnails adopt a fixed-width 160px frame (responsive on small screens) for consistent layout.
|
||||
- Commander browser now separates name vs theme search, adds fuzzy theme suggestions, and tightens commander name matching to near-exact results.
|
||||
- Commander search results stay put while filtering; typing no longer auto-scrolls the page away from the filter controls.
|
||||
- Commander theme chips are larger, wrap cleanly, and display an accessible summary dialog when tapped on mobile.
|
||||
- Theme dialogs now surface the full editorial description when available, improving longer summaries on small screens.
|
||||
- Commander theme names unescape leading punctuation (e.g., +2/+2 Counters) so labels render without stray backslashes.
|
||||
- Theme summary dialog also opens on desktop clicks, giving parity with mobile behavior.
|
||||
- Mobile commander rows now feature larger thumbnails and a centered preview modal with expanded card art for improved readability.
|
||||
- Preview performance CI check now waits for service health and retries catalog pagination fetches to smooth out transient 500s on cold boots.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -69,3 +71,61 @@ def test_commander_catalog_cache_invalidation(tmp_path: Path, monkeypatch: pytes
|
|||
updated = loader.load_commander_catalog()
|
||||
assert updated is not first
|
||||
assert "zada-hedron-grinder" in updated.by_slug
|
||||
|
||||
|
||||
def test_commander_theme_labels_unescape(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
custom_dir = tmp_path / "csv_custom"
|
||||
custom_dir.mkdir()
|
||||
csv_path = custom_dir / "commander_cards.csv"
|
||||
with csv_path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(
|
||||
[
|
||||
"name",
|
||||
"faceName",
|
||||
"edhrecRank",
|
||||
"colorIdentity",
|
||||
"colors",
|
||||
"manaCost",
|
||||
"manaValue",
|
||||
"type",
|
||||
"creatureTypes",
|
||||
"text",
|
||||
"power",
|
||||
"toughness",
|
||||
"keywords",
|
||||
"themeTags",
|
||||
"layout",
|
||||
"side",
|
||||
]
|
||||
)
|
||||
theme_value = json.dumps([r"\+2/\+2 Counters", "+1/+1 Counters"])
|
||||
writer.writerow(
|
||||
[
|
||||
"Escape Tester",
|
||||
"Escape Tester",
|
||||
"1234",
|
||||
"R",
|
||||
"R",
|
||||
"{3}{R}",
|
||||
"4",
|
||||
"Legendary Creature — Archer",
|
||||
"['Archer']",
|
||||
"Test",
|
||||
"2",
|
||||
"2",
|
||||
"",
|
||||
theme_value,
|
||||
"normal",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
_set_csv_dir(monkeypatch, custom_dir)
|
||||
|
||||
catalog = loader.load_commander_catalog()
|
||||
assert len(catalog.entries) == 1
|
||||
|
||||
record = catalog.entries[0]
|
||||
assert record.themes == ("+2/+2 Counters", "+1/+1 Counters")
|
||||
assert "+2/+2 counters" in record.theme_tokens
|
||||
|
|
|
|||
|
|
@ -76,7 +76,15 @@ def _install_paginated_catalog(monkeypatch: pytest.MonkeyPatch, total: int) -> N
|
|||
search_haystack=f"{name.lower()}"
|
||||
)
|
||||
records.append(record)
|
||||
fake_catalog = SimpleNamespace(entries=tuple(records))
|
||||
_install_custom_catalog(monkeypatch, records)
|
||||
|
||||
|
||||
def _install_custom_catalog(monkeypatch: pytest.MonkeyPatch, records: list) -> None:
|
||||
fake_catalog = SimpleNamespace(
|
||||
entries=tuple(records),
|
||||
by_slug={record.slug: record for record in records},
|
||||
)
|
||||
|
||||
def loader() -> SimpleNamespace:
|
||||
return fake_catalog
|
||||
|
||||
|
|
@ -120,13 +128,7 @@ def test_commanders_show_all_themes_without_overflow(client: TestClient, monkeyp
|
|||
themes=themes,
|
||||
theme_tokens=tuple(theme.lower() for theme in themes),
|
||||
)
|
||||
fake_catalog = SimpleNamespace(entries=(enriched,))
|
||||
|
||||
def loader() -> SimpleNamespace:
|
||||
return fake_catalog
|
||||
|
||||
monkeypatch.setattr(commander_catalog_loader, "load_commander_catalog", loader)
|
||||
monkeypatch.setattr(commanders, "load_commander_catalog", loader)
|
||||
_install_custom_catalog(monkeypatch, [enriched])
|
||||
|
||||
response = client.get("/commanders")
|
||||
assert response.status_code == 200
|
||||
|
|
@ -135,3 +137,155 @@ def test_commanders_show_all_themes_without_overflow(client: TestClient, monkeyp
|
|||
assert "commander-theme-chip-more" not in body # no overflow badge rendered
|
||||
for name in themes:
|
||||
assert name in body
|
||||
|
||||
|
||||
def _commander_fixture(sample, *, name: str, slug: str, themes: tuple[str, ...] = ()):
|
||||
return replace(
|
||||
sample,
|
||||
name=name,
|
||||
face_name=name,
|
||||
display_name=name,
|
||||
slug=slug,
|
||||
themes=themes,
|
||||
theme_tokens=tuple(theme.lower() for theme in themes),
|
||||
search_haystack="|".join([name.lower(), *[theme.lower() for theme in themes]]),
|
||||
)
|
||||
|
||||
|
||||
def test_commanders_search_ignores_theme_tokens(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
catalog = load_commander_catalog()
|
||||
sample = catalog.entries[0]
|
||||
target = _commander_fixture(
|
||||
sample,
|
||||
name="Avatar Aang // Aang, Master of Elements",
|
||||
slug="avatar-aang",
|
||||
themes=("Elemental", "Avatar"),
|
||||
)
|
||||
other = _commander_fixture(
|
||||
sample,
|
||||
name="Generic Guardian",
|
||||
slug="generic-guardian",
|
||||
themes=("Avatar", "Guardian"),
|
||||
)
|
||||
_install_custom_catalog(monkeypatch, [target, other])
|
||||
|
||||
response = client.get("/commanders", params={"q": "Avatar Aang"})
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
assert 'data-commander-slug="avatar-aang"' in body
|
||||
assert 'data-commander-slug="generic-guardian"' not in body
|
||||
|
||||
|
||||
def test_commanders_search_supports_token_reordering(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
catalog = load_commander_catalog()
|
||||
sample = catalog.entries[0]
|
||||
target = _commander_fixture(
|
||||
sample,
|
||||
name="Avatar Aang // Aang, Master of Elements",
|
||||
slug="avatar-aang",
|
||||
themes=("Elemental",),
|
||||
)
|
||||
fallback = _commander_fixture(
|
||||
sample,
|
||||
name="Master of Avatar Arts",
|
||||
slug="master-of-avatar-arts",
|
||||
themes=("Avatar",),
|
||||
)
|
||||
_install_custom_catalog(monkeypatch, [target, fallback])
|
||||
|
||||
response = client.get("/commanders", params={"q": "Aang Avatar"})
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
assert 'data-commander-slug="avatar-aang"' in body
|
||||
assert 'data-commander-slug="master-of-avatar-arts"' not in body
|
||||
|
||||
|
||||
def test_commanders_theme_search_filters(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
catalog = load_commander_catalog()
|
||||
sample = catalog.entries[0]
|
||||
aggro_commander = _commander_fixture(
|
||||
sample,
|
||||
name="Aggro Ace",
|
||||
slug="aggro-ace",
|
||||
themes=("Aggro", "Combat"),
|
||||
)
|
||||
control_commander = _commander_fixture(
|
||||
sample,
|
||||
name="Control Keeper",
|
||||
slug="control-keeper",
|
||||
themes=("Control", "Value"),
|
||||
)
|
||||
_install_custom_catalog(monkeypatch, [aggro_commander, control_commander])
|
||||
|
||||
response = client.get("/commanders", params={"theme": "Aggo"})
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
assert 'data-commander-slug="aggro-ace"' in body
|
||||
assert 'data-commander-slug="control-keeper"' not in body
|
||||
assert 'data-theme-suggestion="Aggro"' in body
|
||||
assert 'id="theme-suggestions"' in body
|
||||
assert 'option value="Aggro"' in body
|
||||
|
||||
|
||||
def test_commanders_theme_recommendations_render_in_fragment(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
catalog = load_commander_catalog()
|
||||
sample = catalog.entries[0]
|
||||
aggro_commander = _commander_fixture(
|
||||
sample,
|
||||
name="Aggro Ace",
|
||||
slug="aggro-ace",
|
||||
themes=("Aggro", "Combat"),
|
||||
)
|
||||
control_commander = _commander_fixture(
|
||||
sample,
|
||||
name="Control Keeper",
|
||||
slug="control-keeper",
|
||||
themes=("Control", "Value"),
|
||||
)
|
||||
_install_custom_catalog(monkeypatch, [aggro_commander, control_commander])
|
||||
|
||||
response = client.get(
|
||||
"/commanders",
|
||||
params={"theme": "Aggo"},
|
||||
headers={"HX-Request": "true"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
assert 'data-theme-suggestion="Aggro"' in body
|
||||
assert 'data-commander-slug="aggro-ace"' in body
|
||||
|
||||
|
||||
def test_commander_name_fuzzy_tightened(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
catalog = load_commander_catalog()
|
||||
sample = catalog.entries[0]
|
||||
finneas = _commander_fixture(
|
||||
sample,
|
||||
name="Finneas, Ace Archer",
|
||||
slug="finneas-ace-archer",
|
||||
themes=("Aggro", "Counters"),
|
||||
)
|
||||
torgal = _commander_fixture(
|
||||
sample,
|
||||
name="Torgal, A Fine Hound",
|
||||
slug="torgal-a-fine-hound",
|
||||
themes=("Aggro", "Combat"),
|
||||
)
|
||||
gorbag = _commander_fixture(
|
||||
sample,
|
||||
name="Gorbag of Minas Morgul",
|
||||
slug="gorbag-of-minas-morgul",
|
||||
themes=("Aggro", "Treasure"),
|
||||
)
|
||||
_install_custom_catalog(monkeypatch, [finneas, torgal, gorbag])
|
||||
|
||||
response = client.get("/commanders", params={"q": "Finneas"})
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
assert 'data-commander-slug="finneas-ace-archer"' in body
|
||||
assert 'data-commander-slug="torgal-a-fine-hound"' not in body
|
||||
assert 'data-commander-slug="gorbag-of-minas-morgul"' not in body
|
||||
|
|
|
|||
|
|
@ -108,9 +108,9 @@ SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False)
|
|||
ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), True)
|
||||
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
||||
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
||||
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), False)
|
||||
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), False) # initial snapshot (legacy)
|
||||
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), False)
|
||||
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
|
||||
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy)
|
||||
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True)
|
||||
THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False)
|
||||
def _as_int(val: str | None, default: int) -> int:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from difflib import SequenceMatcher
|
||||
from math import ceil
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
from urllib.parse import urlencode
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
|
@ -16,6 +18,11 @@ from ..services.telemetry import log_commander_page_view
|
|||
router = APIRouter(prefix="/commanders", tags=["commanders"])
|
||||
|
||||
PAGE_SIZE = 20
|
||||
_THEME_MATCH_THRESHOLD = 0.52
|
||||
_THEME_RECOMMENDATION_FLOOR = 0.35
|
||||
_THEME_RECOMMENDATION_LIMIT = 6
|
||||
_MIN_NAME_MATCH_SCORE = 0.8
|
||||
_WORD_PATTERN = re.compile(r"[a-z0-9]+")
|
||||
|
||||
_WUBRG_ORDER: tuple[str, ...] = ("W", "U", "B", "R", "G")
|
||||
_COLOR_NAMES: dict[str, str] = {
|
||||
|
|
@ -76,6 +83,12 @@ class CommanderView:
|
|||
partner_summary: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThemeRecommendation:
|
||||
name: str
|
||||
score: float
|
||||
|
||||
|
||||
def _is_htmx(request: Request) -> bool:
|
||||
return request.headers.get("HX-Request", "").lower() == "true"
|
||||
|
||||
|
|
@ -169,22 +182,203 @@ def _record_to_view(record: CommanderRecord, theme_info: Mapping[str, CommanderT
|
|||
)
|
||||
|
||||
|
||||
def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None) -> list[CommanderRecord]:
|
||||
def _normalize_search_text(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
tokens = _WORD_PATTERN.findall(value.lower())
|
||||
if not tokens:
|
||||
return ""
|
||||
return " ".join(tokens)
|
||||
|
||||
|
||||
def _commander_name_candidates(record: CommanderRecord) -> tuple[str, ...]:
|
||||
seen: set[str] = set()
|
||||
candidates: list[str] = []
|
||||
for raw in (record.display_name, record.face_name, record.name):
|
||||
normalized = _normalize_search_text(raw)
|
||||
if not normalized:
|
||||
continue
|
||||
if normalized not in seen:
|
||||
seen.add(normalized)
|
||||
candidates.append(normalized)
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def _partial_ratio(a: str, b: str) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
shorter, longer = (a, b) if len(a) <= len(b) else (b, a)
|
||||
length = len(shorter)
|
||||
if length == 0:
|
||||
return 0.0
|
||||
best = 0.0
|
||||
window_count = len(longer) - length + 1
|
||||
for start in range(max(1, window_count)):
|
||||
end = start + length
|
||||
if end > len(longer):
|
||||
segment = longer[-length:]
|
||||
else:
|
||||
segment = longer[start:end]
|
||||
score = SequenceMatcher(None, shorter, segment).ratio()
|
||||
if score > best:
|
||||
best = score
|
||||
if best >= 0.99:
|
||||
break
|
||||
return best
|
||||
|
||||
|
||||
def _token_scores(query_tokens: tuple[str, ...], candidate_tokens: tuple[str, ...]) -> tuple[float, float]:
|
||||
if not query_tokens or not candidate_tokens:
|
||||
return 0.0, 0.0
|
||||
totals: list[float] = []
|
||||
for token in query_tokens:
|
||||
best = 0.0
|
||||
for candidate in candidate_tokens:
|
||||
score = SequenceMatcher(None, token, candidate).ratio()
|
||||
if score > best:
|
||||
best = score
|
||||
if best >= 0.99:
|
||||
break
|
||||
totals.append(best)
|
||||
average = sum(totals) / len(totals) if totals else 0.0
|
||||
minimum = min(totals) if totals else 0.0
|
||||
return average, minimum
|
||||
|
||||
|
||||
def _commander_name_match_score(query: str, record: CommanderRecord) -> float:
|
||||
normalized_query = _normalize_search_text(query)
|
||||
if not normalized_query:
|
||||
return 0.0
|
||||
query_tokens = tuple(normalized_query.split())
|
||||
best_score = 0.0
|
||||
for candidate in _commander_name_candidates(record):
|
||||
candidate_tokens = tuple(candidate.split())
|
||||
base_score = SequenceMatcher(None, normalized_query, candidate).ratio()
|
||||
partial = _partial_ratio(normalized_query, candidate)
|
||||
token_average, token_minimum = _token_scores(query_tokens, candidate_tokens)
|
||||
|
||||
substring_bonus = 0.0
|
||||
if candidate.startswith(normalized_query):
|
||||
substring_bonus = 1.0
|
||||
elif query_tokens and all(token in candidate_tokens for token in query_tokens):
|
||||
substring_bonus = 0.92
|
||||
elif normalized_query in candidate:
|
||||
substring_bonus = 0.88
|
||||
elif query_tokens and all(token in candidate for token in query_tokens):
|
||||
substring_bonus = 0.8
|
||||
elif query_tokens and any(token in candidate for token in query_tokens):
|
||||
substring_bonus = 0.65
|
||||
|
||||
score = max(base_score, partial, token_average, substring_bonus)
|
||||
if query_tokens and token_minimum < 0.45 and not candidate.startswith(normalized_query) and normalized_query not in candidate:
|
||||
score = min(score, token_minimum)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
if best_score >= 0.999:
|
||||
break
|
||||
return best_score
|
||||
|
||||
|
||||
def _collect_theme_names(records: Sequence[CommanderRecord]) -> tuple[str, ...]:
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for rec in records:
|
||||
for theme_name in rec.themes:
|
||||
if not theme_name:
|
||||
continue
|
||||
if theme_name not in seen:
|
||||
seen.add(theme_name)
|
||||
ordered.append(theme_name)
|
||||
ordered.sort(key=lambda name: name.lower())
|
||||
return tuple(ordered)
|
||||
|
||||
|
||||
def _theme_match_score(normalized_query: str, query_tokens: tuple[str, ...], candidate: str) -> float:
|
||||
normalized_candidate = _normalize_search_text(candidate)
|
||||
if not normalized_candidate:
|
||||
return 0.0
|
||||
candidate_tokens = tuple(normalized_candidate.split())
|
||||
base_score = SequenceMatcher(None, normalized_query, normalized_candidate).ratio()
|
||||
partial = _partial_ratio(normalized_query, normalized_candidate)
|
||||
token_average, token_minimum = _token_scores(query_tokens, candidate_tokens)
|
||||
|
||||
substring_bonus = 0.0
|
||||
if normalized_candidate.startswith(normalized_query):
|
||||
substring_bonus = 1.0
|
||||
elif normalized_query in normalized_candidate:
|
||||
substring_bonus = 0.9
|
||||
elif query_tokens and all(token in candidate_tokens for token in query_tokens):
|
||||
substring_bonus = 0.85
|
||||
elif query_tokens and any(token in candidate_tokens for token in query_tokens):
|
||||
substring_bonus = 0.7
|
||||
|
||||
score = max(base_score, partial, token_average, substring_bonus)
|
||||
if query_tokens and token_minimum < 0.4 and not normalized_candidate.startswith(normalized_query) and normalized_query not in normalized_candidate:
|
||||
score = min(score, max(token_minimum, 0.0))
|
||||
return score
|
||||
|
||||
|
||||
def _best_theme_match_score(normalized_query: str, query_tokens: tuple[str, ...], record: CommanderRecord) -> float:
|
||||
best = 0.0
|
||||
for theme_name in record.themes:
|
||||
score = _theme_match_score(normalized_query, query_tokens, theme_name)
|
||||
if score > best:
|
||||
best = score
|
||||
if best >= 0.999:
|
||||
break
|
||||
return best
|
||||
|
||||
|
||||
def _build_theme_recommendations(theme_query: str | None, theme_names: Sequence[str]) -> tuple[ThemeRecommendation, ...]:
|
||||
normalized_query = _normalize_search_text(theme_query)
|
||||
if not normalized_query:
|
||||
return tuple()
|
||||
query_tokens = tuple(normalized_query.split())
|
||||
scored: list[ThemeRecommendation] = []
|
||||
for name in theme_names:
|
||||
score = _theme_match_score(normalized_query, query_tokens, name)
|
||||
if score <= 0.0:
|
||||
continue
|
||||
scored.append(ThemeRecommendation(name=name, score=score))
|
||||
if not scored:
|
||||
return tuple()
|
||||
scored.sort(key=lambda item: (-item.score, item.name.lower()))
|
||||
filtered = [item for item in scored if item.score >= _THEME_RECOMMENDATION_FLOOR]
|
||||
if not filtered:
|
||||
filtered = scored
|
||||
return tuple(filtered[:_THEME_RECOMMENDATION_LIMIT])
|
||||
|
||||
|
||||
def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None, theme: str | None) -> list[CommanderRecord]:
|
||||
items = list(records)
|
||||
color_code = _canon_color_code(color)
|
||||
if color_code:
|
||||
items = [rec for rec in items if _record_color_code(rec) == color_code]
|
||||
if q:
|
||||
lowered = q.lower().strip()
|
||||
if lowered:
|
||||
tokens = [tok for tok in lowered.split() if tok]
|
||||
if tokens:
|
||||
filtered: list[CommanderRecord] = []
|
||||
for rec in items:
|
||||
haystack = rec.search_haystack or ""
|
||||
if all(tok in haystack for tok in tokens):
|
||||
filtered.append(rec)
|
||||
items = filtered
|
||||
normalized_query = _normalize_search_text(q)
|
||||
if normalized_query:
|
||||
filtered: list[tuple[float, CommanderRecord]] = []
|
||||
for rec in items:
|
||||
score = _commander_name_match_score(normalized_query, rec)
|
||||
if score >= _MIN_NAME_MATCH_SCORE:
|
||||
filtered.append((score, rec))
|
||||
if filtered:
|
||||
filtered.sort(key=lambda pair: (-pair[0], pair[1].display_name.lower()))
|
||||
items = [rec for _, rec in filtered]
|
||||
else:
|
||||
items = []
|
||||
normalized_theme_query = _normalize_search_text(theme)
|
||||
if normalized_theme_query and items:
|
||||
theme_tokens = tuple(normalized_theme_query.split())
|
||||
filtered_by_theme: list[tuple[float, CommanderRecord]] = []
|
||||
for rec in items:
|
||||
score = _best_theme_match_score(normalized_theme_query, theme_tokens, rec)
|
||||
if score >= _THEME_MATCH_THRESHOLD:
|
||||
filtered_by_theme.append((score, rec))
|
||||
if filtered_by_theme:
|
||||
filtered_by_theme.sort(key=lambda pair: (-pair[0], pair[1].display_name.lower()))
|
||||
items = [rec for _, rec in filtered_by_theme]
|
||||
else:
|
||||
items = []
|
||||
return items
|
||||
|
||||
|
||||
|
|
@ -226,7 +420,11 @@ def _build_theme_info(records: Sequence[CommanderRecord]) -> dict[str, Commander
|
|||
try:
|
||||
data = idx.summary_by_slug.get(slug)
|
||||
if data:
|
||||
summary = data.get("short_description") or data.get("description")
|
||||
description = data.get("description") if isinstance(data, dict) else None
|
||||
short_description = data.get("short_description") if isinstance(data, dict) else None
|
||||
summary = description or short_description
|
||||
if (summary is None or not summary.strip()) and short_description:
|
||||
summary = short_description
|
||||
except Exception:
|
||||
summary = None
|
||||
info[name] = CommanderTheme(name=name, slug=slug, summary=summary)
|
||||
|
|
@ -237,6 +435,7 @@ def _build_theme_info(records: Sequence[CommanderRecord]) -> dict[str, Commander
|
|||
async def commanders_index(
|
||||
request: Request,
|
||||
q: str | None = Query(default=None, alias="q"),
|
||||
theme: str | None = Query(default=None, alias="theme"),
|
||||
color: str | None = Query(default=None, alias="color"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
) -> HTMLResponse:
|
||||
|
|
@ -247,7 +446,10 @@ async def commanders_index(
|
|||
entries = catalog.entries
|
||||
except FileNotFoundError:
|
||||
error = "Commander catalog is unavailable. Ensure csv_files/commander_cards.csv exists."
|
||||
filtered = _filter_commanders(entries, q, color)
|
||||
theme_names = _collect_theme_names(entries)
|
||||
theme_query = (theme or "").strip()
|
||||
filtered = _filter_commanders(entries, q, color, theme_query)
|
||||
theme_recommendations = _build_theme_recommendations(theme_query, theme_names)
|
||||
total_filtered = len(filtered)
|
||||
page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1
|
||||
if page > page_count:
|
||||
|
|
@ -268,6 +470,8 @@ async def commanders_index(
|
|||
params: dict[str, str] = {}
|
||||
if q:
|
||||
params["q"] = q
|
||||
if theme_query:
|
||||
params["theme"] = theme_query
|
||||
if canon_color:
|
||||
params["color"] = canon_color
|
||||
params["page"] = str(page_value)
|
||||
|
|
@ -289,8 +493,11 @@ async def commanders_index(
|
|||
"request": request,
|
||||
"commanders": views,
|
||||
"query": q or "",
|
||||
"theme_query": theme_query,
|
||||
"color": canon_color,
|
||||
"color_options": color_options,
|
||||
"theme_options": theme_names,
|
||||
"theme_recommendations": theme_recommendations,
|
||||
"total_count": len(entries),
|
||||
"result_count": len(views),
|
||||
"result_total": total_filtered,
|
||||
|
|
@ -305,7 +512,7 @@ async def commanders_index(
|
|||
"next_page": next_page,
|
||||
"prev_url": prev_url,
|
||||
"next_url": next_url,
|
||||
"is_filtered": bool((q or "").strip() or (color or "").strip()),
|
||||
"is_filtered": bool((q or "").strip() or (color or "").strip() or theme_query),
|
||||
"error": error,
|
||||
"return_url": return_url,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ _COLOR_ALIAS = {
|
|||
}
|
||||
_WUBRG_ORDER: Tuple[str, ...] = ("W", "U", "B", "R", "G")
|
||||
_SCYRFALL_BASE = "https://api.scryfall.com/cards/named?format=image"
|
||||
_THEME_ESCAPE_PATTERN = re.compile(r"\\([+/\\-])")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -214,7 +215,8 @@ def _row_to_record(row: Mapping[str, object], used_slugs: Iterable[str]) -> Comm
|
|||
power = _clean_str(row.get("power")) or None
|
||||
toughness = _clean_str(row.get("toughness")) or None
|
||||
keywords = tuple(_split_to_list(row.get("keywords")))
|
||||
themes = tuple(_parse_literal_list(row.get("themeTags")))
|
||||
raw_themes = _parse_literal_list(row.get("themeTags"))
|
||||
themes = tuple(filter(None, (_clean_theme_label(theme) for theme in raw_themes)))
|
||||
theme_tokens = tuple(dict.fromkeys(t.lower() for t in themes if t))
|
||||
edhrec_rank = _parse_int(row.get("edhrecRank"))
|
||||
layout = _clean_str(row.get("layout")) or "normal"
|
||||
|
|
@ -317,6 +319,14 @@ def _parse_literal_list(value: object) -> List[str]:
|
|||
return [part for part in parts if part]
|
||||
|
||||
|
||||
def _clean_theme_label(value: str) -> str:
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
return ""
|
||||
text = text.replace("\ufeff", "")
|
||||
return _THEME_ESCAPE_PATTERN.sub(r"\1", text)
|
||||
|
||||
|
||||
def _split_to_list(value: object) -> List[str]:
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
|
|
|
|||
|
|
@ -13,16 +13,25 @@
|
|||
method="get"
|
||||
hx-get="/commanders"
|
||||
hx-target="#commander-results"
|
||||
hx-trigger="submit, change from:#commander-color, keyup changed delay:300ms from:#commander-search"
|
||||
hx-trigger="submit, change from:#commander-color, keyup changed delay:300ms from:#commander-search, keyup changed delay:300ms from:#commander-theme"
|
||||
hx-include="#commander-filter-form"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#commander-loading"
|
||||
novalidate
|
||||
>
|
||||
<label>
|
||||
<span class="filter-label">Search</span>
|
||||
<input type="search" id="commander-search" name="q" value="{{ query }}" placeholder="Search commanders, themes, or text..." autocomplete="off" />
|
||||
<span class="filter-label">Commander name</span>
|
||||
<input type="search" id="commander-search" name="q" value="{{ query }}" placeholder="Search commander names..." autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="filter-label">Theme</span>
|
||||
<input type="search" id="commander-theme" name="theme" value="{{ theme_query }}" placeholder="Search themes..." list="theme-suggestions" autocomplete="off" />
|
||||
</label>
|
||||
<datalist id="theme-suggestions">
|
||||
{% for name in theme_options[:200] %}
|
||||
<option value="{{ name }}"></option>
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
<label>
|
||||
<span class="filter-label">Color identity</span>
|
||||
<select id="commander-color" name="color">
|
||||
|
|
@ -89,9 +98,9 @@
|
|||
.commander-name { margin:0; font-size:1.25rem; }
|
||||
.color-identity { display:flex; align-items:center; gap:.35rem; }
|
||||
.commander-context { margin:0; font-size:.95rem; }
|
||||
.commander-themes { display:flex; flex-wrap:wrap; gap:.4rem; }
|
||||
.commander-themes { display:flex; flex-wrap:wrap; gap:.45rem; width:100%; }
|
||||
.commander-themes-empty { font-size:.85rem; }
|
||||
.commander-theme-chip { display:inline-flex; align-items:center; gap:.25rem; padding:4px 10px; border-radius:9999px; border:1px solid var(--border); background:rgba(148,163,184,.15); font-size:.75rem; letter-spacing:.03em; color:inherit; cursor:pointer; transition:background .15s ease, border-color .15s ease, transform .15s ease; appearance:none; font:inherit; }
|
||||
.commander-theme-chip { display:inline-flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:.2rem; padding:4px 12px; border-radius:9999px; border:1px solid var(--border); background:rgba(148,163,184,.15); font-size:.85rem; line-height:1.3; font-weight:600; letter-spacing:.03em; color:inherit; cursor:pointer; transition:background .18s ease, border-color .18s ease, transform .18s ease; appearance:none; font-family:inherit; white-space:normal; text-align:center; min-width:0; max-width:min(100%, 24ch); word-break:break-word; overflow-wrap: anywhere; }
|
||||
.commander-theme-chip:focus-visible { outline:2px solid var(--ring); outline-offset:2px; }
|
||||
.commander-theme-chip:hover { background:rgba(148,163,184,.25); border-color:rgba(148,163,184,.45); transform:translateY(-1px); }
|
||||
.commander-partners { display:flex; flex-wrap:wrap; gap:.4rem; font-size:.85rem; }
|
||||
|
|
@ -105,6 +114,12 @@
|
|||
.commander-pagination .commander-page-btn[disabled],
|
||||
.commander-pagination .commander-page-btn.disabled { opacity:.55; cursor:default; pointer-events:none; }
|
||||
.commander-pagination-status { font-size:.85rem; color:var(--muted); }
|
||||
.theme-recommendations { display:flex; flex-wrap:wrap; align-items:center; gap:.6rem 1rem; margin-top:.75rem; }
|
||||
.theme-recommendations-label { font-size:.8rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); }
|
||||
.theme-recommendations-chips { display:flex; flex-wrap:wrap; gap:.4rem; }
|
||||
.theme-suggestion-chip { display:inline-flex; align-items:center; gap:.25rem; padding:4px 10px; border-radius:9999px; border:1px solid var(--border); background:rgba(94,106,136,.18); font-size:.75rem; letter-spacing:.03em; color:inherit; cursor:pointer; transition:background .15s ease, border-color .15s ease, transform .15s ease; }
|
||||
.theme-suggestion-chip:hover { background:rgba(94,106,136,.28); border-color:rgba(148,163,184,.45); transform:translateY(-1px); }
|
||||
.theme-suggestion-chip:focus-visible { outline:2px solid var(--ring); outline-offset:2px; }
|
||||
|
||||
.commander-loading { display:none; margin-top:1rem; }
|
||||
.commander-loading.htmx-request { display:block; }
|
||||
|
|
@ -124,6 +139,17 @@
|
|||
0% { background-position:100% 0; }
|
||||
100% { background-position:-100% 0; }
|
||||
}
|
||||
.commander-theme-dialog { position:fixed; inset:0; display:none; align-items:center; justify-content:center; padding:1.5rem; background:rgba(15,23,42,.55); backdrop-filter:blur(6px); z-index:1300; }
|
||||
.commander-theme-dialog[data-open="true"] { display:flex; }
|
||||
.commander-theme-dialog__panel { background:var(--panel); color:var(--text); border-radius:16px; width:min(92vw, 420px); border:1px solid rgba(148,163,184,.35); box-shadow:0 24px 60px rgba(15,23,42,.42); padding:1.5rem; display:flex; flex-direction:column; gap:1rem; }
|
||||
.commander-theme-dialog__title { margin:0; font-size:1.45rem; line-height:1.2; }
|
||||
.commander-theme-dialog__body { margin:0; font-size:1.2rem; line-height:1.65; white-space:normal; word-break:break-word; }
|
||||
.commander-theme-dialog__close { align-self:flex-end; min-width:0; }
|
||||
@media (max-width: 640px) {
|
||||
.commander-theme-dialog__panel { width:min(94vw, 360px); padding:1.25rem; }
|
||||
.commander-theme-dialog__title { font-size:1.3rem; }
|
||||
.commander-theme-dialog__body { font-size:1.1rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.commander-row { flex-direction:column; }
|
||||
|
|
@ -148,10 +174,106 @@
|
|||
if (!pageInput) return;
|
||||
|
||||
const resetPage = () => { pageInput.value = '1'; };
|
||||
const setLastTrigger = (value) => { form.dataset.lastTrigger = value; };
|
||||
const searchField = document.getElementById('commander-search');
|
||||
const colorField = document.getElementById('commander-color');
|
||||
if (searchField) searchField.addEventListener('input', resetPage);
|
||||
if (colorField) colorField.addEventListener('change', resetPage);
|
||||
const themeField = document.getElementById('commander-theme');
|
||||
if (searchField) {
|
||||
searchField.addEventListener('input', () => {
|
||||
resetPage();
|
||||
setLastTrigger('search');
|
||||
});
|
||||
}
|
||||
if (colorField) {
|
||||
colorField.addEventListener('change', () => {
|
||||
resetPage();
|
||||
setLastTrigger('color');
|
||||
});
|
||||
}
|
||||
if (themeField) {
|
||||
themeField.addEventListener('input', () => {
|
||||
resetPage();
|
||||
setLastTrigger('theme');
|
||||
});
|
||||
}
|
||||
form.addEventListener('submit', () => {
|
||||
if (!form.dataset.lastTrigger) {
|
||||
setLastTrigger('submit');
|
||||
}
|
||||
});
|
||||
|
||||
const coarseQuery = window.matchMedia('(pointer: coarse)');
|
||||
const prefersThemeModal = () => (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768;
|
||||
|
||||
let themeDialog;
|
||||
let themeDialogTitle;
|
||||
let themeDialogBody;
|
||||
let themeDialogClose;
|
||||
|
||||
function closeThemeDialog() {
|
||||
if (!themeDialog || themeDialog.dataset.open !== 'true') return;
|
||||
themeDialog.dataset.open = 'false';
|
||||
themeDialog.setAttribute('aria-hidden', 'true');
|
||||
const invoker = themeDialog.__lastInvoker;
|
||||
themeDialog.__lastInvoker = null;
|
||||
if (invoker && typeof invoker.focus === 'function') {
|
||||
try {
|
||||
invoker.focus({ preventScroll: true });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
const ensureThemeDialog = () => {
|
||||
if (themeDialog) return themeDialog;
|
||||
themeDialog = document.getElementById('commander-theme-dialog');
|
||||
if (!themeDialog) {
|
||||
themeDialog = document.createElement('div');
|
||||
themeDialog.id = 'commander-theme-dialog';
|
||||
themeDialog.className = 'commander-theme-dialog';
|
||||
themeDialog.setAttribute('aria-hidden', 'true');
|
||||
themeDialog.innerHTML = `
|
||||
<div class="commander-theme-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="commander-theme-dialog-title">
|
||||
<h3 class="commander-theme-dialog__title" id="commander-theme-dialog-title"></h3>
|
||||
<p class="commander-theme-dialog__body"></p>
|
||||
<button type="button" class="btn commander-theme-dialog__close">Close</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(themeDialog);
|
||||
}
|
||||
themeDialogTitle = themeDialog.querySelector('.commander-theme-dialog__title');
|
||||
themeDialogBody = themeDialog.querySelector('.commander-theme-dialog__body');
|
||||
themeDialogClose = themeDialog.querySelector('.commander-theme-dialog__close');
|
||||
if (themeDialogClose && !themeDialogClose.__bound) {
|
||||
themeDialogClose.__bound = true;
|
||||
themeDialogClose.addEventListener('click', () => closeThemeDialog());
|
||||
}
|
||||
if (!themeDialog.__backdropBound) {
|
||||
themeDialog.__backdropBound = true;
|
||||
themeDialog.addEventListener('click', (evt) => {
|
||||
if (evt.target === themeDialog) {
|
||||
closeThemeDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
return themeDialog;
|
||||
};
|
||||
|
||||
const openThemeDialog = (name, summary, invoker) => {
|
||||
ensureThemeDialog();
|
||||
if (!themeDialog) return;
|
||||
themeDialog.setAttribute('aria-hidden', 'false');
|
||||
themeDialog.dataset.open = 'true';
|
||||
themeDialog.__lastInvoker = invoker || null;
|
||||
if (themeDialogTitle) themeDialogTitle.textContent = name || 'Theme';
|
||||
if (themeDialogBody) themeDialogBody.textContent = summary && summary.trim() ? summary : 'Summary unavailable.';
|
||||
requestAnimationFrame(() => {
|
||||
if (themeDialogClose) {
|
||||
try {
|
||||
themeDialogClose.focus({ preventScroll: true });
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updatePageFromResults = (container) => {
|
||||
if (!container) return;
|
||||
|
|
@ -170,6 +292,11 @@
|
|||
const container = document.getElementById('commander-results');
|
||||
const searchEl = document.getElementById('commander-search');
|
||||
if (!container) return;
|
||||
const lastTrigger = form.dataset.lastTrigger || '';
|
||||
form.dataset.lastTrigger = '';
|
||||
if (lastTrigger === 'search' || lastTrigger === 'theme') {
|
||||
return;
|
||||
}
|
||||
const invoker = event.detail && event.detail.elt ? event.detail.elt : null;
|
||||
const fromBottom = invoker && invoker.closest && invoker.closest('[data-bottom-controls]');
|
||||
// If not from bottom, check whether the top of the results is already within view; if so, skip scroll
|
||||
|
|
@ -196,6 +323,39 @@
|
|||
});
|
||||
|
||||
updatePageFromResults(document.getElementById('commander-results'));
|
||||
|
||||
document.body.addEventListener('click', (event) => {
|
||||
const suggestion = event.target && event.target.closest ? event.target.closest('[data-theme-suggestion]') : null;
|
||||
if (suggestion) {
|
||||
if (!themeField) return;
|
||||
event.preventDefault();
|
||||
const value = suggestion.getAttribute('data-theme-suggestion') || '';
|
||||
themeField.value = value;
|
||||
resetPage();
|
||||
setLastTrigger('theme');
|
||||
themeField.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
try {
|
||||
form.requestSubmit();
|
||||
} catch (_) {
|
||||
form.submit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const chip = event.target && event.target.closest ? event.target.closest('.commander-theme-chip') : null;
|
||||
if (chip) {
|
||||
event.preventDefault();
|
||||
const name = chip.getAttribute('data-theme-name') || chip.textContent.trim();
|
||||
const summary = chip.getAttribute('data-theme-summary') || 'Summary unavailable.';
|
||||
openThemeDialog(name, summary, chip);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeThemeDialog();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,16 @@
|
|||
No commander data available.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if theme_query and theme_recommendations %}
|
||||
<div class="theme-recommendations" data-theme-recommendations>
|
||||
<span class="theme-recommendations-label">Suggested themes:</span>
|
||||
<div class="theme-recommendations-chips">
|
||||
{% for suggestion in theme_recommendations %}
|
||||
<button type="button" class="theme-suggestion-chip" data-theme-suggestion="{{ suggestion.name }}" data-theme-score="{{ '%.2f'|format(suggestion.score) }}">{{ suggestion.name }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if commanders %}
|
||||
{% set pagination_position = 'top' %}
|
||||
{% include "commanders/pagination_controls.html" %}
|
||||
|
|
|
|||
|
|
@ -25,14 +25,17 @@
|
|||
<div class="commander-themes" role="list">
|
||||
{% for theme in entry.themes %}
|
||||
{% set summary = theme.summary or 'Summary unavailable' %}
|
||||
<button type="button"
|
||||
class="commander-theme-chip"
|
||||
<button type="button"
|
||||
class="commander-theme-chip"
|
||||
role="listitem"
|
||||
data-theme-name="{{ theme.name }}"
|
||||
data-theme-slug="{{ theme.slug }}"
|
||||
data-theme-summary="{{ summary }}"
|
||||
title="{{ summary }}"
|
||||
aria-label="{{ theme.name }} theme: {{ summary }}">
|
||||
title="{{ summary }}"
|
||||
aria-label="{{ theme.name }} theme: {{ summary }}"
|
||||
data-hover-simple-target="theme"
|
||||
data-hover-simple-name="{{ theme.name }}"
|
||||
data-hover-simple-summary="{{ summary }}">
|
||||
{{ theme.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -39,23 +39,19 @@ services:
|
|||
# RARITY_DIVERSITY_OVER_PENALTY: "-0.5"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Random Build (Alpha) Feature Flags
|
||||
# RANDOM_MODES: backend enablement (seeded selection endpoints)
|
||||
# RANDOM_UI: enable Surprise/Reroll controls in UI
|
||||
# RANDOM_MAX_ATTEMPTS: safety cap on retries for constraints
|
||||
# RANDOM_TIMEOUT_MS: per-attempt timeout (ms) before giving up
|
||||
# Random Mode Feature Flags
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Random Modes (feature flags)
|
||||
RANDOM_MODES: "1" # 1=enable random build endpoints and backend features
|
||||
RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
|
||||
# RANDOM_MODES: "1" # 1=enable random build endpoints and backend features
|
||||
# RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
|
||||
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
|
||||
RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms
|
||||
RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme)
|
||||
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
|
||||
RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override
|
||||
RANDOM_TERTIARY_THEME: "" # optional tertiary theme slug override
|
||||
RANDOM_AUTO_FILL: "0" # when 1, auto-fill missing secondary/tertiary themes
|
||||
RANDOM_AUTO_FILL: "1" # when 1, auto-fill missing secondary/tertiary themes
|
||||
RANDOM_AUTO_FILL_SECONDARY: "" # override secondary auto-fill (blank inherits from RANDOM_AUTO_FILL)
|
||||
RANDOM_AUTO_FILL_TERTIARY: "" # override tertiary auto-fill (blank inherits from RANDOM_AUTO_FILL)
|
||||
RANDOM_STRICT_THEME_MATCH: "0" # require strict theme matches for commanders when 1
|
||||
|
|
|
|||
|
|
@ -5,24 +5,29 @@ services:
|
|||
image: mwisnowski/mtg-python-deckbuilder:latest
|
||||
container_name: mtg-deckbuilder-web
|
||||
ports:
|
||||
- "8080:8080" # Host:Container — open http://localhost:8080
|
||||
- "8080:8080"
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
TERM: "xterm-256color"
|
||||
DEBIAN_FRONTEND: "noninteractive"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core UI Feature Toggles
|
||||
# (Enable/disable visibility of sections; most default to off in code)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# UI features/flags
|
||||
SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
|
||||
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide
|
||||
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf
|
||||
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
|
||||
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
|
||||
ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
|
||||
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide
|
||||
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
|
||||
ENABLE_PRESETS: "0" # 1=show presets section
|
||||
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||
ALLOW_MUST_HAVES: "1" # Include/Exclude feature enable
|
||||
WEB_THEME_PICKER_DIAGNOSTICS: "0" # 1=enable extra theme catalog diagnostics fields, uncapped synergies & /themes/metrics
|
||||
# Sampling experiments (optional)
|
||||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||
SHOW_MISC_POOL: "0"
|
||||
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
|
||||
# Sampling experiments
|
||||
# SPLASH_ADAPTIVE: "0" # 1=enable adaptive splash penalty scaling by commander color count
|
||||
# SPLASH_ADAPTIVE_SCALE: "1:1.0,2:1.0,3:1.0,4:0.6,5:0.35" # override default scaling
|
||||
# Rarity weighting (advanced; default weights tuned for variety)
|
||||
|
|
@ -36,81 +41,106 @@ services:
|
|||
# RARITY_DIVERSITY_OVER_PENALTY: "-0.5"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Random Build (Alpha) Feature Flags
|
||||
# Random Mode Feature Flags
|
||||
# ------------------------------------------------------------------
|
||||
RANDOM_MODES: "0" # 1=backend random build endpoints
|
||||
RANDOM_UI: "0" # 1=UI Surprise/Reroll controls
|
||||
RANDOM_MAX_ATTEMPTS: "5" # Retry cap for constrained random builds
|
||||
RANDOM_TIMEOUT_MS: "5000" # Per-attempt timeout (ms)
|
||||
|
||||
# Random Modes (feature flags)
|
||||
RANDOM_MODES: "1" # 1=enable random build endpoints and backend features
|
||||
RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
|
||||
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
|
||||
RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms
|
||||
RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme)
|
||||
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
|
||||
RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override
|
||||
RANDOM_TERTIARY_THEME: "" # optional tertiary theme slug override
|
||||
RANDOM_AUTO_FILL: "0" # when 1, auto-fill missing secondary/tertiary themes
|
||||
RANDOM_AUTO_FILL: "1" # when 1, auto-fill missing secondary/tertiary themes
|
||||
RANDOM_AUTO_FILL_SECONDARY: "" # override secondary auto-fill (blank inherits from RANDOM_AUTO_FILL)
|
||||
RANDOM_AUTO_FILL_TERTIARY: "" # override tertiary auto-fill (blank inherits from RANDOM_AUTO_FILL)
|
||||
RANDOM_STRICT_THEME_MATCH: "0" # require strict theme matches when 1
|
||||
RANDOM_STRICT_THEME_MATCH: "0" # require strict theme matches for commanders when 1
|
||||
RANDOM_CONSTRAINTS: "" # inline JSON constraints for random builds (optional)
|
||||
RANDOM_CONSTRAINTS_PATH: "" # path to JSON constraints file (takes precedence)
|
||||
RANDOM_SEED: "" # deterministic random seed (int or string)
|
||||
RANDOM_OUTPUT_JSON: "" # path or directory for random build payload export
|
||||
RANDOM_OUTPUT_JSON: "" # path or directory for random build output payload
|
||||
# RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT: "1" # (now defaults to 1 automatically for random builds; set to 0 to force legacy double-export behavior)
|
||||
|
||||
# Theming
|
||||
THEME: "system" # system|light|dark default theme
|
||||
THEME: "dark" # system|light|dark
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Setup / Tagging / Catalog
|
||||
# Setup / Tagging / Catalog Controls
|
||||
# WEB_AUTO_SETUP: auto-run initial tagging & theme generation when needed
|
||||
# WEB_AUTO_REFRESH_DAYS: refresh card data if older than N days (0=never)
|
||||
# WEB_TAG_PARALLEL + WEB_TAG_WORKERS: parallel tag extraction
|
||||
# THEME_CATALOG_MODE: merge (Phase B) | legacy | build | phaseb (merge synonyms)
|
||||
# WEB_AUTO_ENFORCE: 1=run bracket/legal compliance auto-export JSON after builds
|
||||
# WEB_CUSTOM_EXPORT_BASE: override export path base (optional)
|
||||
# APP_VERSION: surfaced in UI/health endpoints
|
||||
# ------------------------------------------------------------------
|
||||
WEB_AUTO_SETUP: "1" # Auto-run setup/tagging on demand
|
||||
WEB_AUTO_REFRESH_DAYS: "7" # Refresh card data if stale (days; 0=never)
|
||||
WEB_TAG_PARALLEL: "1" # Parallel tag extraction on
|
||||
WEB_TAG_WORKERS: "4" # Worker count (CPU bound; tune as needed)
|
||||
THEME_CATALOG_MODE: "merge" # Phase B merged theme builder
|
||||
THEME_YAML_FAST_SKIP: "0" # 1=allow skipping YAML export on fast path (default 0 = always export)
|
||||
|
||||
# Setup/Tagging performance
|
||||
WEB_AUTO_SETUP: "1" # 1=auto-run setup/tagging when needed
|
||||
WEB_AUTO_REFRESH_DAYS: "7" # Refresh cards.csv if older than N days; 0=never
|
||||
WEB_TAG_PARALLEL: "1" # 1=parallelize tagging
|
||||
WEB_TAG_WORKERS: "4" # Worker count when parallel tagging
|
||||
THEME_CATALOG_MODE: "merge" # Use merged Phase B catalog builder (with YAML export)
|
||||
THEME_YAML_FAST_SKIP: "0" # 1=allow skipping per-theme YAML on fast path (rare; default always export)
|
||||
# Live YAML scan interval in seconds for change detection (dev convenience)
|
||||
# THEME_CATALOG_YAML_SCAN_INTERVAL_SEC: "2.0"
|
||||
# Prewarm common theme filters at startup (speeds first interactions)
|
||||
# WEB_THEME_FILTER_PREWARM: "0"
|
||||
WEB_AUTO_ENFORCE: "0" # 1=auto compliance JSON export after builds
|
||||
WEB_CUSTOM_EXPORT_BASE: "" # Optional export base override
|
||||
APP_VERSION: "v2.3.2" # Displayed in footer/health
|
||||
WEB_AUTO_ENFORCE: "0" # 1=auto-run compliance export after builds
|
||||
WEB_CUSTOM_EXPORT_BASE: "" # Optional: custom base dir for deck export artifacts
|
||||
APP_VERSION: "2.3.2" # Displayed version label (set per release/tag)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Misc Land Selection Tuning (Step 7)
|
||||
# Misc / Land Selection (Step 7) Environment Tuning
|
||||
# Uncomment to fine-tune utility land heuristics. Theme weighting allows
|
||||
# matching candidate lands to selected themes for bias.
|
||||
# ------------------------------------------------------------------
|
||||
# MISC_LAND_DEBUG: "1" # Write debug CSVs (diagnostics only)
|
||||
# MISC_LAND_EDHREC_KEEP_PERCENT_MIN: "0.75"
|
||||
# MISC_LAND_EDHREC_KEEP_PERCENT_MAX: "1.0"
|
||||
# MISC_LAND_EDHREC_KEEP_PERCENT: "0.80" # Fallback if MIN/MAX unset
|
||||
# MISC_LAND_THEME_MATCH_BASE: "1.4"
|
||||
# MISC_LAND_THEME_MATCH_PER_EXTRA: "0.15"
|
||||
# MISC_LAND_THEME_MATCH_CAP: "2.0"
|
||||
|
||||
# Misc land tuning (utility land selection – Step 7)
|
||||
# MISC_LAND_DEBUG: "1" # 1=write misc land debug CSVs (post-filter, candidates); off by default unless SHOW_DIAGNOSTICS=1
|
||||
# MISC_LAND_EDHREC_KEEP_PERCENT_MIN: "0.75" # Lower bound (0–1). When both MIN & MAX set, a random keep % in [MIN,MAX] is rolled each build
|
||||
# MISC_LAND_EDHREC_KEEP_PERCENT_MAX: "1.0" # Upper bound (0–1) for dynamic EDHREC keep range
|
||||
# MISC_LAND_EDHREC_KEEP_PERCENT: "0.80" # Legacy single fixed keep % (used only if MIN/MAX not both provided)
|
||||
# (Optional theme weighting overrides)
|
||||
# MISC_LAND_THEME_MATCH_BASE: "1.4" # Multiplier if at least one theme tag matches
|
||||
# MISC_LAND_THEME_MATCH_PER_EXTRA: "0.15" # Increment per extra matching tag beyond first
|
||||
# MISC_LAND_THEME_MATCH_CAP: "2.0" # Cap for total theme multiplier
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path Overrides
|
||||
# Deck Export / Directory Overrides (headless & web browsing paths)
|
||||
# DECK_EXPORTS / DECK_CONFIG: override mount points inside container
|
||||
# OWNED_CARDS_DIR / CARD_LIBRARY_DIR: inventory upload path (alias preserved)
|
||||
# ------------------------------------------------------------------
|
||||
# DECK_EXPORTS: "/app/deck_files"
|
||||
# DECK_CONFIG: "/app/config"
|
||||
# OWNED_CARDS_DIR: "/app/owned_cards"
|
||||
# CARD_LIBRARY_DIR: "/app/owned_cards" # legacy alias
|
||||
|
||||
# Paths (optional overrides)
|
||||
# DECK_EXPORTS: "/app/deck_files" # Where the deck browser looks for exports
|
||||
# DECK_CONFIG: "/app/config" # Where the config browser looks for *.json
|
||||
# OWNED_CARDS_DIR: "/app/owned_cards" # Preferred path for owned inventory uploads
|
||||
# CARD_LIBRARY_DIR: "/app/owned_cards" # Back-compat alias for OWNED_CARDS_DIR
|
||||
# CSV base directory override (useful for testing with frozen snapshots)
|
||||
# CSV_FILES_DIR: "/app/csv_files"
|
||||
# Inject a one-off synthetic CSV for index testing without altering shards
|
||||
# CARD_INDEX_EXTRA_CSV: ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Headless / CLI Mode (optional automation)
|
||||
# Headless / Non-interactive Build Configuration
|
||||
# Provide commander or tag indices/names; toggles for which phases to include
|
||||
# Counts optionally tune land/fetch/ramp/etc targets.
|
||||
# ------------------------------------------------------------------
|
||||
# DECK_MODE: "headless"
|
||||
# HEADLESS_EXPORT_JSON: "1"
|
||||
# DECK_COMMANDER: ""
|
||||
# DECK_PRIMARY_CHOICE: "1"
|
||||
# DECK_SECONDARY_CHOICE: ""
|
||||
# DECK_TERTIARY_CHOICE: ""
|
||||
# DECK_PRIMARY_TAG: ""
|
||||
|
||||
# Headless-only settings
|
||||
# DECK_MODE: "headless" # Auto-run headless flow in CLI mode
|
||||
# HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON
|
||||
# DECK_COMMANDER: "" # Commander name query
|
||||
# DECK_PRIMARY_CHOICE: "1" # Primary tag index (1-based)
|
||||
# DECK_SECONDARY_CHOICE: "" # Optional secondary index
|
||||
# DECK_TERTIARY_CHOICE: "" # Optional tertiary index
|
||||
# DECK_PRIMARY_TAG: "" # Or tag names instead of indices
|
||||
# DECK_SECONDARY_TAG: ""
|
||||
# DECK_TERTIARY_TAG: ""
|
||||
# DECK_BRACKET_LEVEL: "3"
|
||||
# DECK_BRACKET_LEVEL: "3" # 1–5
|
||||
# DECK_ADD_LANDS: "1"
|
||||
# DECK_ADD_CREATURES: "1"
|
||||
# DECK_ADD_NON_CREATURE_SPELLS: "1"
|
||||
|
|
@ -123,32 +153,37 @@ services:
|
|||
# DECK_DUAL_COUNT: ""
|
||||
# DECK_TRIPLE_COUNT: ""
|
||||
# DECK_UTILITY_COUNT: ""
|
||||
# DECK_TAG_MODE: "AND"
|
||||
# DECK_TAG_MODE: "AND" # AND|OR (if supported)
|
||||
# HEADLESS_RANDOM_MODE: "0" # 1=force headless random mode instead of scripted build
|
||||
|
||||
# Entrypoint knobs (only if you change the entrypoint behavior)
|
||||
# APP_MODE: "web" # web|cli — selects uvicorn vs CLI
|
||||
# HOST: "0.0.0.0" # Uvicorn bind host
|
||||
# PORT: "8080" # Uvicorn port
|
||||
# WORKERS: "1" # Uvicorn workers
|
||||
# (HOST/PORT honored by entrypoint; WORKERS for multi-worker uvicorn if desired)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Entrypoint / Server knobs
|
||||
# Testing / Diagnostics Specific (rarely changed in compose)
|
||||
# SHOW_MISC_POOL: "1" # (already above) expose misc pool debug UI if implemented
|
||||
# ------------------------------------------------------------------
|
||||
# APP_MODE: "web" # web|cli
|
||||
# HOST: "0.0.0.0" # Bind host
|
||||
# PORT: "8080" # Uvicorn port
|
||||
# WORKERS: "1" # Uvicorn workers
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Editorial / Theme Catalog Controls (advanced / optional)
|
||||
# These are primarily for maintainers refining automated theme
|
||||
# descriptions & popularity analytics. Leave commented for normal use.
|
||||
# Editorial / Theme Catalog Controls
|
||||
# These drive automated description generation, popularity bucketing,
|
||||
# YAML backfilling, and regression / metrics exports. Normally only
|
||||
# used during catalog curation or CI.
|
||||
# ------------------------------------------------------------------
|
||||
# EDITORIAL_SEED: "1234" # Deterministic seed for reproducible ordering.
|
||||
# EDITORIAL_AGGRESSIVE_FILL: "0" # 1=borrow extra synergies for sparse themes.
|
||||
# EDITORIAL_POP_BOUNDARIES: "50,120,250,600" # Override popularity bucket thresholds (4 ints).
|
||||
# EDITORIAL_POP_EXPORT: "0" # 1=emit theme_popularity_metrics.json.
|
||||
# EDITORIAL_BACKFILL_YAML: "0" # 1=write description/popularity back to YAML (missing only).
|
||||
# EDITORIAL_INCLUDE_FALLBACK_SUMMARY: "0" # 1=include fallback description usage summary in JSON.
|
||||
# EDITORIAL_REQUIRE_DESCRIPTION: "0" # (lint) 1=fail if any theme lacks description.
|
||||
# EDITORIAL_REQUIRE_POPULARITY: "0" # (lint) 1=fail if any theme lacks popularity bucket.
|
||||
# EDITORIAL_MIN_EXAMPLES: "0" # (future) minimum curated examples target.
|
||||
# EDITORIAL_MIN_EXAMPLES_ENFORCE: "0" # (future) enforce above threshold vs warn.
|
||||
# EDITORIAL_SEED: "1234" # Deterministic seed for description & inference ordering.
|
||||
# EDITORIAL_AGGRESSIVE_FILL: "0" # 1=borrow extra synergies for sparse themes (<2 curated/enforced).
|
||||
# EDITORIAL_POP_BOUNDARIES: "50,120,250,600" # Override popularity bucket boundaries (4 comma ints).
|
||||
# EDITORIAL_POP_EXPORT: "0" # 1=emit theme_popularity_metrics.json alongside theme_list.json.
|
||||
# EDITORIAL_BACKFILL_YAML: "0" # 1=enable YAML metadata backfill (description/popularity) on build.
|
||||
# EDITORIAL_INCLUDE_FALLBACK_SUMMARY: "0" # 1=include description_fallback_summary block in JSON output.
|
||||
# EDITORIAL_REQUIRE_DESCRIPTION: "0" # (lint script) 1=fail if a theme lacks description.
|
||||
# EDITORIAL_REQUIRE_POPULARITY: "0" # (lint script) 1=fail if a theme lacks popularity bucket.
|
||||
# EDITORIAL_MIN_EXAMPLES: "0" # (future) minimum curated example commanders/cards (guard rails).
|
||||
# EDITORIAL_MIN_EXAMPLES_ENFORCE: "0" # (future) 1=enforce above threshold; else warn only.
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Theme Preview Cache & Redis (optional)
|
||||
|
|
@ -171,11 +206,12 @@ services:
|
|||
# THEME_PREVIEW_TTL_STEPS: "2,4,2,3,1" # step counts for band progression
|
||||
# Redis backend (optional)
|
||||
# THEME_PREVIEW_REDIS_URL: "redis://redis:6379/0"
|
||||
# THEME_PREVIEW_REDIS_DISABLE: "0" # 1=force disable redis even if URL is set
|
||||
# THEME_PREVIEW_REDIS_DISABLE: "0" # 1=force disable redis even if URL is set
|
||||
volumes:
|
||||
- ${PWD}/deck_files:/app/deck_files
|
||||
- ${PWD}/logs:/app/logs
|
||||
- ${PWD}/csv_files:/app/csv_files
|
||||
- ${PWD}/config:/app/config
|
||||
- ${PWD}/owned_cards:/app/owned_cards
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
restart: "no"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue