From 8e57588f401d7539f37ac466d2e0dcb4e756af61 Mon Sep 17 00:00:00 2001 From: matt Date: Tue, 30 Sep 2025 15:49:08 -0700 Subject: [PATCH] feat(web): launch commander browser with deck builder CTA --- .gitignore | 39 +- CHANGELOG.md | 13 +- README.md | Bin 121656 -> 123182 bytes RELEASE_NOTES_TEMPLATE.md | 23 +- code/tests/test_commander_build_cta.py | 97 ++++ code/tests/test_commander_catalog_loader.py | 71 +++ code/tests/test_commander_telemetry.py | 56 +++ code/tests/test_commanders_route.py | 137 ++++++ code/tests/test_diagnostics.py | 32 ++ code/tests/test_home_actions_buttons.py | 61 +++ code/web/app.py | 6 + code/web/routes/build.py | 36 ++ code/web/routes/commanders.py | 323 +++++++++++++ code/web/services/commander_catalog_loader.py | 423 ++++++++++++++++++ code/web/services/telemetry.py | 106 +++++ code/web/static/styles.css | 15 +- code/web/templates/base.html | 58 ++- code/web/templates/build/index.html | 72 ++- code/web/templates/commanders/index.html | 201 +++++++++ .../templates/commanders/list_fragment.html | 38 ++ .../commanders/pagination_controls.html | 37 ++ .../templates/commanders/row_wireframe.html | 56 +++ code/web/templates/diagnostics/index.html | 1 + code/web/templates/home.html | 10 +- code/web/templates/partials/_macros.html | 16 + csv_files/testdata/commander_cards.csv | 6 +- docs/commander_catalog.md | 72 +++ 27 files changed, 1960 insertions(+), 45 deletions(-) create mode 100644 code/tests/test_commander_build_cta.py create mode 100644 code/tests/test_commander_catalog_loader.py create mode 100644 code/tests/test_commander_telemetry.py create mode 100644 code/tests/test_commanders_route.py create mode 100644 code/tests/test_home_actions_buttons.py create mode 100644 code/web/routes/commanders.py create mode 100644 code/web/services/commander_catalog_loader.py create mode 100644 code/web/services/telemetry.py create mode 100644 code/web/templates/commanders/index.html create mode 100644 code/web/templates/commanders/list_fragment.html create mode 100644 code/web/templates/commanders/pagination_controls.html create mode 100644 code/web/templates/commanders/row_wireframe.html create mode 100644 docs/commander_catalog.md diff --git a/.gitignore b/.gitignore index 0d8dc59..251e7d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,39 @@ +*.bkp *.csv *.json *.log *.txt -.mypy_cache/ -.venv/ -.pytest_cache/ -test.py !requirements.txt + +RELEASE_NOTES.md +test.py +!test_exclude_cards.txt +!test_include_exclude_config.json + +.mypy_cache/ +.pytest_cache/ +.venv/ __pycache__/ + +.github/*.md + +config/themes/catalog/ +config/themes/ +!config/themes/*.yml +!config/card_lists/*.json +!config/deck.json + # Keep main CSV datasets out of Git, but allow the tiny deterministic fixtures used by CI. csv_files/* !csv_files/testdata/ !csv_files/testdata/**/* + +deck_files/ dist/ + logs/ !logs/ logs/* !logs/perf/ logs/perf/* -!logs/perf/theme_preview_warm_baseline.json -deck_files/ -config/themes/catalog/ -!config/card_lists/*.json -!config/themes/*.yml -config/themes/theme_list.json -!config/deck.json -!test_exclude_cards.txt -!test_include_exclude_config.json -RELEASE_NOTES.md -*.bkp -.github/*.md \ No newline at end of file +!logs/perf/theme_preview_warm_baseline.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f7eb01..7d3fe75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,19 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Added -- _No changes yet._ +- Commander browser skeleton page at `/commanders` with HTMX-capable filtering and catalog-backed commander rows. +- Shared color-identity macro and accessible theme chips powering the commander browser UI. +- Commander browser QA walkthrough documenting desktop and mobile validation steps (`docs/qa/commander_browser_walkthrough.md`). +- Home screen actions now surface Commander Browser and Diagnostics shortcuts when the corresponding feature flags are enabled. +- Manual QA pass (2025-09-30) recorded in project docs, covering desktop/mobile flows and edge cases. ### Changed -- _No changes yet._ +- Commander browser now paginates results in 20-commander pages with accessible navigation controls and range summaries to keep the catalog responsive. +- 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 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. ### Fixed - _No changes yet._ diff --git a/README.md b/README.md index 44f520ae889041e886fc179791f7474f98fb9bd6..ace8ce16bf2f5d8b54225a6d486763d41d36e865 100644 GIT binary patch delta 1063 zcmcIjO=}ZT6unOZMWq%qO=GR%tBcw~Q*<3%)M`aBDyFWJnaO86$;8a0Z5G;vAX$oU z@z(wUsR-(#LhBC@`~yl?uKfp|dnY4;;7%Sh^WJ^;{1N|7j7{E3KYRHz~ker?bH`F zZ@(R=8Pk7}HLtGBOcmjsrqk3mufIBv3-Au9i-L6V;6(PLJh;acTg~Pu|6DzQ%57pe>8e~_( zT7~#-(k*&~fa`P>DY)aT{}w`Pf;$y*G`4ucGy&3;+3tvUBJxDQ@$bStux>+9iL?Vu g9{=J6N!@^vn;3&xS{BVTKR-FoN8X2#r}lS$0by0xFaQ7m delta 18 acmZ2?h<(R4_6;3L&2<^u>oOQOQ~&^0K?t@0 diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 2b112fb..b2fbfca 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,9 +1,24 @@ # MTG Python Deckbuilder ${VERSION} ## Summary -- Theme catalog pagination buttons now re-run HTMX processing after fragment fetches so “Next” works reliably in the picker and simple catalog views. -- Docker entrypoint seeds default theme catalog configuration files into volume-backed deployments, keeping Docker Hub images ready out of the box. +- Introduced the Commander Browser with HTMX-powered pagination, theme surfacing, and direct Create Deck integration. +- Shared color-identity macro and accessible theme chips power the new commander rows. +- Manual QA walkthrough (desktop + mobile) recorded on 2025‑09‑30 with edge-case checks. +- Home dashboard aligns its quick actions with feature flags, exposing Commanders, Diagnostics, Random, Logs, and Setup where enabled. + +## Added +- Commander browser skeleton page at `/commanders` with catalog-backed rows and accessible theme chips. +- Documented QA checklist and results for the commander browser launch in `docs/qa/commander_browser_walkthrough.md`. +- Shared color-identity macro for reusable mana dots across commander rows and other templates. +- Home dashboard Commander/Diagnostics shortcuts gated by feature flags so all primary destinations have quick actions. +- Manual QA pass entered into project docs (2025-09-30) outlining desktop, mobile, and edge-case validations. + +## Changed +- Commander list paginates in 20-item pages, with navigation controls mirrored above and below the results and automatic scroll-to-top. +- 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. +- Mobile commander rows now feature larger thumbnails and a centered preview modal with expanded card art for improved readability. ## Fixed -- Theme catalog Next pagination button now reprocesses HTMX fragments after manual fetches, restoring navigation controls in theme picker and simple catalog modes. -- Docker entrypoint seeds default `config/themes` YAML files (synergy pairs, clusters, whitelist, etc.) into mounted volumes so Docker Hub deployments have the expected baseline data. \ No newline at end of file +- Documented friendly handling for missing `commander_cards.csv` data during manual QA drills to prevent white-screen failures. \ No newline at end of file diff --git a/code/tests/test_commander_build_cta.py b/code/tests/test_commander_build_cta.py new file mode 100644 index 0000000..d61387a --- /dev/null +++ b/code/tests/test_commander_build_cta.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from pathlib import Path +import html as _html +import re +from urllib.parse import parse_qs, urlparse + +import pytest +from fastapi.testclient import TestClient + +from code.web.app import app # type: ignore +from code.web.services.commander_catalog_loader import clear_commander_catalog_cache + + +@pytest.fixture +def client(monkeypatch): + csv_dir = Path("csv_files/testdata").resolve() + monkeypatch.setenv("CSV_FILES_DIR", str(csv_dir)) + clear_commander_catalog_cache() + with TestClient(app) as test_client: + yield test_client + clear_commander_catalog_cache() + + +def test_commander_row_has_build_cta_with_return_url(client: TestClient) -> None: + # Load the commanders page + resp = client.get("/commanders", params={"q": "atraxa"}) + assert resp.status_code == 200 + body = resp.text + # Ensure the Build link includes the builder path with commander and return params + match = re.search(r'href="(/build\?[^\"]+)"', body) + assert match is not None + href = _html.unescape(match.group(1)) + assert href.startswith("/build?commander=") + parsed = urlparse(href) + params = parse_qs(parsed.query) + assert "return" in params + return_value = params["return"][0] + assert return_value.startswith("/commanders") + parsed_return = urlparse(return_value) + assert parsed_return.path.rstrip("/") == "/commanders" + parsed_return_params = parse_qs(parsed_return.query) + assert parsed_return_params.get("q") == ["atraxa"] + # Ensure no absolute scheme slipped through + assert not return_value.startswith("http") + + +def test_build_page_includes_back_link_for_safe_return(client: TestClient) -> None: + resp = client.get("/build", params={"return": "/commanders?page=2&color=W"}) + assert resp.status_code == 200 + body = resp.text + match = re.search(r'href="(/commanders[^\"]+)"', body) + assert match is not None + href = _html.unescape(match.group(1)) + parsed = urlparse(href) + assert parsed.path == "/commanders" + params = parse_qs(parsed.query) + assert params.get("page") == ["2"] + assert params.get("color") == ["W"] + + +def test_build_page_ignores_external_return(client: TestClient) -> None: + resp = client.get("/build", params={"return": "https://evil.example.com"}) + assert resp.status_code == 200 + body = resp.text + assert "Back to Commanders" not in body + + +def test_commander_launch_preselects_commander_and_requires_theme(client: TestClient) -> None: + commander_name = "Atraxa, Praetors' Voice" + resp = client.get( + "/build", + params={"commander": commander_name, "return": "/commanders?page=2"}, + ) + assert resp.status_code == 200 + body = resp.text + init_match = re.search(r']*data-commander="([^"]+)"', body) + assert init_match is not None + assert _html.unescape(init_match.group(1)) == commander_name + assert "Back to Commanders" in body + + step2 = client.get("/build/step2") + assert step2.status_code == 200 + step2_body = step2.text + assert commander_name in _html.unescape(step2_body) + assert 'name="primary_tag"' in step2_body + + submit = client.post( + "/build/step2", + data={ + "commander": commander_name, + "bracket": "3", + "tag_mode": "AND", + }, + ) + assert submit.status_code == 200 + assert "Please choose a primary theme." in submit.text diff --git a/code/tests/test_commander_catalog_loader.py b/code/tests/test_commander_catalog_loader.py new file mode 100644 index 0000000..7ae3a0f --- /dev/null +++ b/code/tests/test_commander_catalog_loader.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import time +from pathlib import Path + +import pytest + +from web.services import commander_catalog_loader as loader + + +FIXTURE_DIR = Path(__file__).resolve().parents[2] / "csv_files" / "testdata" + + +def _set_csv_dir(monkeypatch: pytest.MonkeyPatch, path: Path) -> None: + monkeypatch.setenv("CSV_FILES_DIR", str(path)) + loader.clear_commander_catalog_cache() + + +def test_commander_catalog_basic_normalization(monkeypatch: pytest.MonkeyPatch) -> None: + _set_csv_dir(monkeypatch, FIXTURE_DIR) + + catalog = loader.load_commander_catalog() + + assert catalog.source_path.name == "commander_cards.csv" + assert len(catalog.entries) == 4 + + krenko = catalog.by_slug["krenko-mob-boss"] + assert krenko.display_name == "Krenko, Mob Boss" + assert krenko.color_identity == ("R",) + assert krenko.color_identity_key == "R" + assert not krenko.is_colorless + assert krenko.themes == ("Goblin Kindred",) + assert "goblin kindred" in krenko.theme_tokens + assert "version=small" in krenko.image_small_url + assert "exact=Krenko%2C%20Mob%20Boss" in krenko.image_small_url + + traxos = catalog.by_slug["traxos-scourge-of-kroog"] + assert traxos.is_colorless + assert traxos.color_identity == () + assert traxos.color_identity_key == "C" + + atraxa = catalog.by_slug["atraxa-praetors-voice"] + assert atraxa.color_identity == ("W", "U", "B", "G") + assert atraxa.color_identity_key == "WUBG" + assert atraxa.is_partner is False + assert atraxa.supports_backgrounds is False + + +def test_commander_catalog_cache_invalidation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + fixture_csv = FIXTURE_DIR / "commander_cards.csv" + work_dir = tmp_path / "csv" + work_dir.mkdir() + target_csv = work_dir / "commander_cards.csv" + target_csv.write_text(fixture_csv.read_text(encoding="utf-8"), encoding="utf-8") + + _set_csv_dir(monkeypatch, work_dir) + + first = loader.load_commander_catalog() + again = loader.load_commander_catalog() + assert again is first + + time.sleep(1.1) # ensure mtime tick on systems with 1s resolution + target_csv.write_text( + fixture_csv.read_text(encoding="utf-8") + + "\"Zada, Hedron Grinder\",\"Zada, Hedron Grinder\",9999,R,R,{3}{R},4,\"Legendary Creature — Goblin\",\"['Goblin']\",\"Test\",3,3,,\"['Goblin Kindred']\",normal,\n", + encoding="utf-8", + ) + + updated = loader.load_commander_catalog() + assert updated is not first + assert "zada-hedron-grinder" in updated.by_slug diff --git a/code/tests/test_commander_telemetry.py b/code/tests/test_commander_telemetry.py new file mode 100644 index 0000000..d566da4 --- /dev/null +++ b/code/tests/test_commander_telemetry.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from code.web.app import app # type: ignore +from code.web.services import telemetry +from code.web.services.commander_catalog_loader import clear_commander_catalog_cache + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch): + csv_dir = Path("csv_files/testdata").resolve() + monkeypatch.setenv("CSV_FILES_DIR", str(csv_dir)) + clear_commander_catalog_cache() + with TestClient(app) as test_client: + yield test_client + clear_commander_catalog_cache() + + +def test_commander_page_logs_event(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + events: list[dict] = [] + + def capture(_logger, payload): + events.append(payload) + + monkeypatch.setattr(telemetry, "_emit", capture) + + response = client.get("/commanders", params={"q": "atraxa"}) + assert response.status_code == 200 + assert events, "expected telemetry events to be emitted" + event = events[-1] + assert event["event"] == "commander_browser.page_view" + assert event["page"] == 1 + assert event["query"]["q"] == "atraxa" + assert event["is_htmx"] is False + + +def test_commander_create_deck_logs_event(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + events: list[dict] = [] + + def capture(_logger, payload): + events.append(payload) + + monkeypatch.setattr(telemetry, "_emit", capture) + + response = client.get("/build", params={"commander": "Atraxa", "return": "/commanders"}) + assert response.status_code == 200 + assert events, "expected telemetry events to be emitted" + event = events[-1] + assert event["event"] == "commander_browser.create_deck" + assert event["commander"] == "Atraxa" + assert event["has_return"] is True + assert event["return_url"] == "/commanders" diff --git a/code/tests/test_commanders_route.py b/code/tests/test_commanders_route.py new file mode 100644 index 0000000..14a93f0 --- /dev/null +++ b/code/tests/test_commanders_route.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient + +from code.web.app import app # type: ignore +from code.web.routes import commanders +from code.web.services import commander_catalog_loader +from code.web.services.commander_catalog_loader import clear_commander_catalog_cache, load_commander_catalog + + +@pytest.fixture +def client(monkeypatch): + csv_dir = Path("csv_files/testdata").resolve() + monkeypatch.setenv("CSV_FILES_DIR", str(csv_dir)) + clear_commander_catalog_cache() + with TestClient(app) as test_client: + yield test_client + clear_commander_catalog_cache() + + +def test_commanders_page_renders(client: TestClient) -> None: + response = client.get("/commanders") + assert response.status_code == 200 + body = response.text + assert "data-commander-slug=\"atraxa-praetors-voice\"" in body + assert "data-commander-slug=\"krenko-mob-boss\"" in body + assert "data-theme-summary=\"" in body + assert 'id="commander-loading"' in body + + +def test_commanders_search_filters(client: TestClient) -> None: + response = client.get("/commanders", params={"q": "krenko"}) + assert response.status_code == 200 + body = response.text + assert "data-commander-slug=\"krenko-mob-boss\"" in body + assert "data-commander-slug=\"atraxa-praetors-voice\"" not in body + + +def test_commanders_color_filter(client: TestClient) -> None: + response = client.get("/commanders", params={"color": "W"}) + assert response.status_code == 200 + body = response.text + assert "data-commander-slug=\"isamaru-hound-of-konda\"" in body + assert "data-commander-slug=\"krenko-mob-boss\"" not in body + + +def test_commanders_htmx_fragment(client: TestClient) -> None: + response = client.get( + "/commanders", + params={"q": "atraxa"}, + headers={"HX-Request": "true"}, + ) + assert response.status_code == 200 + body = response.text + assert "commander-row" in body + assert "
None: + base_catalog = load_commander_catalog() + sample = base_catalog.entries[0] + records = [] + for index in range(total): + name = f"Pagination Test {index:02d}" + record = replace( + sample, + name=name, + face_name=name, + display_name=name, + slug=f"pagination-test-{index:02d}", + search_haystack=f"{name.lower()}" + ) + records.append(record) + fake_catalog = SimpleNamespace(entries=tuple(records)) + def loader() -> SimpleNamespace: + return fake_catalog + + monkeypatch.setattr(commander_catalog_loader, "load_commander_catalog", loader) + monkeypatch.setattr(commanders, "load_commander_catalog", loader) + + +def test_commanders_pagination_limits_results(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + _install_paginated_catalog(monkeypatch, total=35) + + response = client.get("/commanders") + assert response.status_code == 200 + body = response.text + + assert "Page 1 of 2" in body + assert "Showing 1 – 20 of 35" in body + assert body.count('href="/commanders?page=2"') == 2 + assert body.count('data-commander-slug="pagination-test-') == 20 + + +def test_commanders_second_page_shows_remaining_results(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + _install_paginated_catalog(monkeypatch, total=35) + + response = client.get("/commanders", params={"page": 2}) + assert response.status_code == 200 + body = response.text + + assert "Page 2 of 2" in body + assert 'data-commander-slug="pagination-test-00"' not in body + assert 'data-commander-slug="pagination-test-20"' in body + assert 'data-commander-slug="pagination-test-34"' in body + assert 'href="/commanders?page=1"' in body + + +def test_commanders_show_all_themes_without_overflow(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + catalog = load_commander_catalog() + sample = catalog.entries[0] + themes = tuple(f"Theme {idx}" for idx in range(1, 9)) + enriched = replace( + sample, + 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) + + response = client.get("/commanders") + assert response.status_code == 200 + body = response.text + + assert "commander-theme-chip-more" not in body # no overflow badge rendered + for name in themes: + assert name in body diff --git a/code/tests/test_diagnostics.py b/code/tests/test_diagnostics.py index 2ae5dfa..aad58c0 100644 --- a/code/tests/test_diagnostics.py +++ b/code/tests/test_diagnostics.py @@ -8,6 +8,18 @@ fastapi = pytest.importorskip("fastapi") # skip tests if FastAPI isn't installe def load_app_with_env(**env: str) -> types.ModuleType: + for key in ( + "SHOW_LOGS", + "SHOW_DIAGNOSTICS", + "SHOW_SETUP", + "SHOW_COMMANDERS", + "ENABLE_THEMES", + "ENABLE_PWA", + "ENABLE_PRESETS", + "APP_VERSION", + "THEME", + ): + os.environ.pop(key, None) for k, v in env.items(): os.environ[k] = v import code.web.app as app_module # type: ignore @@ -67,6 +79,7 @@ def test_status_sys_summary_and_flags(): SHOW_LOGS="1", SHOW_DIAGNOSTICS="1", SHOW_SETUP="1", + SHOW_COMMANDERS="1", ENABLE_THEMES="1", ENABLE_PWA="1", ENABLE_PRESETS="1", @@ -84,8 +97,27 @@ def test_status_sys_summary_and_flags(): assert flags.get("SHOW_LOGS") is True assert flags.get("SHOW_DIAGNOSTICS") is True assert flags.get("SHOW_SETUP") is True + assert flags.get("SHOW_COMMANDERS") is True # Theme-related flags assert flags.get("ENABLE_THEMES") is True assert flags.get("ENABLE_PWA") is True assert flags.get("ENABLE_PRESETS") is True assert flags.get("DEFAULT_THEME") == "dark" + + +def test_commanders_nav_hidden_when_flag_disabled(): + app_module = load_app_with_env(SHOW_COMMANDERS="0") + client = TestClient(app_module.app) + r = client.get("/") + assert r.status_code == 200 + body = r.text + assert ' types.ModuleType: + for key in ( + "SHOW_LOGS", + "SHOW_DIAGNOSTICS", + "SHOW_SETUP", + "SHOW_COMMANDERS", + "ENABLE_THEMES", + "ENABLE_PWA", + "ENABLE_PRESETS", + "APP_VERSION", + "THEME", + "RANDOM_UI", + ): + os.environ.pop(key, None) + for k, v in env.items(): + os.environ[k] = v + import code.web.app as app_module # type: ignore + importlib.reload(app_module) + return app_module + + +def test_home_actions_show_all_enabled_buttons(): + app_module = load_app_with_env( + SHOW_LOGS="1", + SHOW_DIAGNOSTICS="1", + SHOW_SETUP="1", + SHOW_COMMANDERS="1", + RANDOM_UI="1", + ) + client = TestClient(app_module.app) + response = client.get("/") + body = response.text + assert 'href="/setup"' in body + assert 'href="/commanders"' in body + assert 'href="/random"' in body + assert 'href="/diagnostics"' in body + assert 'href="/logs"' in body + + +def test_home_actions_hides_disabled_sections(): + app_module = load_app_with_env( + SHOW_LOGS="0", + SHOW_DIAGNOSTICS="0", + SHOW_SETUP="0", + SHOW_COMMANDERS="0", + RANDOM_UI="0", + ) + client = TestClient(app_module.app) + response = client.get("/") + body = response.text + assert 'href="/setup"' not in body + assert 'href="/commanders"' not in body + assert 'href="/random"' not in body + assert 'href="/diagnostics"' not in body + assert 'href="/logs"' not in body diff --git a/code/web/app.py b/code/web/app.py index b89755b..3103c45 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -103,6 +103,7 @@ def _as_bool(val: str | None, default: bool = False) -> bool: SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False) SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True) SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False) +SHOW_COMMANDERS = _as_bool(os.getenv("SHOW_COMMANDERS"), True) SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False) ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), False) ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False) @@ -231,6 +232,7 @@ templates.env.globals.update({ "show_logs": SHOW_LOGS, "show_setup": SHOW_SETUP, "show_diagnostics": SHOW_DIAGNOSTICS, + "show_commanders": SHOW_COMMANDERS, "virtualize": SHOW_VIRTUALIZE, "enable_themes": ENABLE_THEMES, "enable_pwa": ENABLE_PWA, @@ -815,6 +817,7 @@ async def status_sys(): "flags": { "SHOW_LOGS": bool(SHOW_LOGS), "SHOW_SETUP": bool(SHOW_SETUP), + "SHOW_COMMANDERS": bool(SHOW_COMMANDERS), "SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS), "ENABLE_THEMES": bool(ENABLE_THEMES), "ENABLE_PWA": bool(ENABLE_PWA), @@ -2128,12 +2131,14 @@ from .routes import decks as decks_routes # noqa: E402 from .routes import setup as setup_routes # noqa: E402 from .routes import owned as owned_routes # noqa: E402 from .routes import themes as themes_routes # noqa: E402 +from .routes import commanders as commanders_routes # noqa: E402 app.include_router(build_routes.router) app.include_router(config_routes.router) app.include_router(decks_routes.router) app.include_router(setup_routes.router) app.include_router(owned_routes.router) app.include_router(themes_routes.router) +app.include_router(commanders_routes.router) # Warm validation cache early to reduce first-call latency in tests and dev try: @@ -2190,6 +2195,7 @@ async def http_exception_handler(request: Request, exc: HTTPException): "error": True, "status": exc.status_code, "detail": exc.detail, + "request_id": rid, "path": str(request.url.path), }, headers=headers) diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 64e5264..746b6a4 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -25,6 +25,8 @@ from deck_builder import builder_utils as bu from ..services.combo_utils import detect_all as _detect_all from path_util import csv_dir as _csv_dir from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached +from ..services.telemetry import log_commander_create_deck +from urllib.parse import urlparse # Cache for available card names used by validation endpoints _AVAILABLE_CARDS_CACHE: set[str] | None = None @@ -188,6 +190,39 @@ def _rebuild_ctx_with_multicopy(sess: dict) -> None: async def build_index(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + # Seed commander from query string when arriving from commander browser + q_commander = None + try: + q_commander = request.query_params.get("commander") + if q_commander: + # Persist a human-friendly commander name into session for the wizard + sess["commander"] = str(q_commander) + except Exception: + pass + return_url = None + try: + raw_return = request.query_params.get("return") + if raw_return: + parsed = urlparse(raw_return) + if not parsed.scheme and not parsed.netloc and parsed.path: + safe_path = parsed.path if parsed.path.startswith("/") else f"/{parsed.path}" + safe_return = safe_path + if parsed.query: + safe_return += f"?{parsed.query}" + if parsed.fragment: + safe_return += f"#{parsed.fragment}" + return_url = safe_return + except Exception: + return_url = None + if q_commander: + try: + log_commander_create_deck( + request, + commander=str(q_commander), + return_url=return_url, + ) + except Exception: + pass # Determine last step (fallback heuristics if not set) last_step = sess.get("last_step") if not last_step: @@ -210,6 +245,7 @@ async def build_index(request: Request) -> HTMLResponse: "tags": sess.get("tags", []), "name": sess.get("custom_export_base"), "last_step": last_step, + "return_url": return_url, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") diff --git a/code/web/routes/commanders.py b/code/web/routes/commanders.py new file mode 100644 index 0000000..eb298a7 --- /dev/null +++ b/code/web/routes/commanders.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import ceil +from typing import Iterable, Mapping, Sequence +from urllib.parse import urlencode + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse + +from ..app import templates +from ..services.commander_catalog_loader import CommanderRecord, load_commander_catalog +from ..services.theme_catalog_loader import load_index, slugify +from ..services.telemetry import log_commander_page_view + +router = APIRouter(prefix="/commanders", tags=["commanders"]) + +PAGE_SIZE = 20 + +_WUBRG_ORDER: tuple[str, ...] = ("W", "U", "B", "R", "G") +_COLOR_NAMES: dict[str, str] = { + "W": "White", + "U": "Blue", + "B": "Black", + "R": "Red", + "G": "Green", + "C": "Colorless", +} +_TWO_COLOR_LABELS: dict[str, str] = { + "WU": "Azorius", + "UB": "Dimir", + "BR": "Rakdos", + "RG": "Gruul", + "WG": "Selesnya", + "WB": "Orzhov", + "UR": "Izzet", + "BG": "Golgari", + "WR": "Boros", + "UG": "Simic", +} +_THREE_COLOR_LABELS: dict[str, str] = { + "WUB": "Esper", + "UBR": "Grixis", + "BRG": "Jund", + "WRG": "Naya", + "WUG": "Bant", + "WBR": "Mardu", + "WUR": "Jeskai", + "UBG": "Sultai", + "URG": "Temur", + "WBG": "Abzan", +} +_FOUR_COLOR_LABELS: dict[str, str] = { + "WUBR": "Yore-Tiller", + "WUBG": "Witch-Maw", + "WURG": "Ink-Treader", + "WBRG": "Dune-Brood", + "UBRG": "Glint-Eye", +} + + +@dataclass(frozen=True, slots=True) +class CommanderTheme: + name: str + slug: str + summary: str | None + + +@dataclass(slots=True) +class CommanderView: + record: CommanderRecord + color_code: str + color_label: str + color_aria_label: str + themes: tuple[CommanderTheme, ...] + partner_summary: tuple[str, ...] + + +def _is_htmx(request: Request) -> bool: + return request.headers.get("HX-Request", "").lower() == "true" + + +def _record_color_code(record: CommanderRecord) -> str: + code = record.color_identity_key or "" + if not code and record.is_colorless: + return "C" + return code + + +def _canon_color_code(raw: str | None) -> str: + if not raw: + return "" + text = raw.upper() + seen: set[str] = set() + ordered: list[str] = [] + for color in _WUBRG_ORDER: + if color in text: + seen.add(color) + ordered.append(color) + if not ordered and "C" in text: + return "C" + return "".join(ordered) + + +def _color_label_from_code(code: str) -> str: + if not code: + return "" + if code == "C": + return "Colorless (C)" + if len(code) == 1: + base = _COLOR_NAMES.get(code, code) + return f"{base} ({code})" + if len(code) == 2: + label = _TWO_COLOR_LABELS.get(code) + if label: + return f"{label} ({code})" + if len(code) == 3: + label = _THREE_COLOR_LABELS.get(code) + if label: + return f"{label} ({code})" + if len(code) == 4: + label = _FOUR_COLOR_LABELS.get(code) + if label: + return f"{label} ({code})" + if code == "WUBRG": + return "Five-Color (WUBRG)" + parts = [_COLOR_NAMES.get(ch, ch) for ch in code] + pretty = " / ".join(parts) + return f"{pretty} ({code})" + + +def _color_aria_label(record: CommanderRecord) -> str: + if record.color_identity: + names = [_COLOR_NAMES.get(ch, ch) for ch in record.color_identity] + return ", ".join(names) + return _COLOR_NAMES.get("C", "Colorless") + + +def _partner_summary(record: CommanderRecord) -> tuple[str, ...]: + parts: list[str] = [] + if record.partner_with: + parts.append("Partner with " + ", ".join(record.partner_with)) + elif record.is_partner: + parts.append("Partner available") + if record.supports_backgrounds: + parts.append("Choose a Background") + if record.is_background: + parts.append("Background commander") + return tuple(parts) + + +def _record_to_view(record: CommanderRecord, theme_info: Mapping[str, CommanderTheme]) -> CommanderView: + theme_objs: list[CommanderTheme] = [] + for theme_name in record.themes: + info = theme_info.get(theme_name) + if info is not None: + theme_objs.append(info) + else: + slug = slugify(theme_name) + theme_objs.append(CommanderTheme(name=theme_name, slug=slug, summary=None)) + color_code = _record_color_code(record) + return CommanderView( + record=record, + color_code=color_code, + color_label=_color_label_from_code(color_code), + color_aria_label=_color_aria_label(record), + themes=tuple(theme_objs), + partner_summary=_partner_summary(record), + ) + + +def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: 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 + return items + + +def _build_color_options(records: Sequence[CommanderRecord]) -> list[tuple[str, str]]: + present: set[str] = set() + for rec in records: + code = _record_color_code(rec) + if code: + present.add(code) + options: list[tuple[str, str]] = [] + for mono in ("W", "U", "B", "R", "G", "C"): + if mono in present: + options.append((mono, _color_label_from_code(mono))) + combos = sorted((code for code in present if len(code) >= 2), key=lambda c: (len(c), c)) + for code in combos: + options.append((code, _color_label_from_code(code))) + return options + + +def _build_theme_info(records: Sequence[CommanderRecord]) -> dict[str, CommanderTheme]: + unique_names: set[str] = set() + for rec in records: + unique_names.update(rec.themes) + if not unique_names: + return {} + try: + idx = load_index() + except FileNotFoundError: + return {} + except Exception: + return {} + info: dict[str, CommanderTheme] = {} + for name in unique_names: + try: + slug = slugify(name) + except Exception: + slug = name + summary: str | None = None + try: + data = idx.summary_by_slug.get(slug) + if data: + summary = data.get("short_description") or data.get("description") + except Exception: + summary = None + info[name] = CommanderTheme(name=name, slug=slug, summary=summary) + return info + + +@router.get("/", response_class=HTMLResponse) +async def commanders_index( + request: Request, + q: str | None = Query(default=None, alias="q"), + color: str | None = Query(default=None, alias="color"), + page: int = Query(default=1, ge=1), +) -> HTMLResponse: + entries: Sequence[CommanderRecord] = () + error: str | None = None + try: + catalog = load_commander_catalog() + entries = catalog.entries + except FileNotFoundError: + error = "Commander catalog is unavailable. Ensure csv_files/commander_cards.csv exists." + filtered = _filter_commanders(entries, q, color) + total_filtered = len(filtered) + page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1 + if page > page_count: + page = page_count + start_index = (page - 1) * PAGE_SIZE + end_index = start_index + PAGE_SIZE + page_records = filtered[start_index:end_index] + theme_info = _build_theme_info(page_records) + views = [_record_to_view(rec, theme_info) for rec in page_records] + color_options = _build_color_options(entries) if entries else [] + page_start = start_index + 1 if total_filtered else 0 + page_end = start_index + len(page_records) + has_prev = page > 1 + has_next = page < page_count + canon_color = _canon_color_code(color) + + def _page_url(page_value: int) -> str: + params: dict[str, str] = {} + if q: + params["q"] = q + if canon_color: + params["color"] = canon_color + params["page"] = str(page_value) + return f"/commanders?{urlencode(params)}" + + prev_page = page - 1 if has_prev else None + next_page = page + 1 if has_next else None + prev_url = _page_url(prev_page) if prev_page else None + next_url = _page_url(next_page) if next_page else None + + current_path = request.url.path or "/commanders" + current_query = request.url.query or "" + if current_query: + return_url = f"{current_path}?{current_query}" + else: + return_url = current_path + + context = { + "request": request, + "commanders": views, + "query": q or "", + "color": canon_color, + "color_options": color_options, + "total_count": len(entries), + "result_count": len(views), + "result_total": total_filtered, + "page": page, + "page_count": page_count, + "page_size": PAGE_SIZE, + "page_start": page_start, + "page_end": page_end, + "has_prev": has_prev, + "has_next": has_next, + "prev_page": prev_page, + "next_page": next_page, + "prev_url": prev_url, + "next_url": next_url, + "is_filtered": bool((q or "").strip() or (color or "").strip()), + "error": error, + "return_url": return_url, + } + template_name = "commanders/list_fragment.html" if _is_htmx(request) else "commanders/index.html" + try: + log_commander_page_view( + request, + page=page, + result_total=total_filtered, + result_count=len(views), + is_htmx=_is_htmx(request), + ) + except Exception: + pass + return templates.TemplateResponse(template_name, context) diff --git a/code/web/services/commander_catalog_loader.py b/code/web/services/commander_catalog_loader.py new file mode 100644 index 0000000..a05d78d --- /dev/null +++ b/code/web/services/commander_catalog_loader.py @@ -0,0 +1,423 @@ +"""Commander catalog loader and normalization helpers for the web UI. + +Responsibilities +================ +- Read and normalize `commander_cards.csv` (shared with the deck builder). +- Produce deterministic commander records with rich metadata (slug, colors, + partner/background flags, theme tags, Scryfall image URLs). +- Cache the parsed catalog and invalidate on file timestamp changes. + +The loader operates without pandas to keep the web layer light-weight and to +simplify unit testing. It honors the `CSV_FILES_DIR` environment variable via +`path_util.csv_dir()` just like the CLI builder. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Mapping, Optional, Tuple +import ast +import csv +import os +import re +from urllib.parse import quote + +from path_util import csv_dir + +__all__ = [ + "CommanderRecord", + "CommanderCatalog", + "load_commander_catalog", + "clear_commander_catalog_cache", +] + + +_COLOR_ALIAS = { + "W": "W", + "WHITE": "W", + "U": "U", + "BLUE": "U", + "B": "B", + "BLACK": "B", + "R": "R", + "RED": "R", + "G": "G", + "GREEN": "G", + "C": "C", + "COLORLESS": "C", +} +_WUBRG_ORDER: Tuple[str, ...] = ("W", "U", "B", "R", "G") +_SCYRFALL_BASE = "https://api.scryfall.com/cards/named?format=image" + + +@dataclass(frozen=True, slots=True) +class CommanderRecord: + """Normalized commander row.""" + + name: str + face_name: str + display_name: str + slug: str + color_identity: Tuple[str, ...] + color_identity_key: str + is_colorless: bool + colors: Tuple[str, ...] + mana_cost: str + mana_value: Optional[float] + type_line: str + creature_types: Tuple[str, ...] + oracle_text: str + power: Optional[str] + toughness: Optional[str] + keywords: Tuple[str, ...] + themes: Tuple[str, ...] + theme_tokens: Tuple[str, ...] + edhrec_rank: Optional[int] + layout: str + side: Optional[str] + image_small_url: str + image_normal_url: str + partner_with: Tuple[str, ...] + is_partner: bool + supports_backgrounds: bool + is_background: bool + search_haystack: str + + +@dataclass(frozen=True, slots=True) +class CommanderCatalog: + """Cached commander catalog with lookup helpers.""" + + source_path: Path + etag: str + mtime_ns: int + size: int + entries: Tuple[CommanderRecord, ...] + by_slug: Mapping[str, CommanderRecord] + + def get(self, slug: str) -> Optional[CommanderRecord]: + return self.by_slug.get(slug) + + +_CACHE: Dict[str, CommanderCatalog] = {} + + +def clear_commander_catalog_cache() -> None: + """Clear the in-memory commander catalog cache (testing/support).""" + + _CACHE.clear() + + +def load_commander_catalog( + source_path: str | os.PathLike[str] | None = None, + *, + force_reload: bool = False, +) -> CommanderCatalog: + """Load (and cache) the commander catalog. + + Args: + source_path: Optional path to override the default csv (mostly for tests). + force_reload: When True, bypass cache even if the file is unchanged. + """ + + csv_path = _resolve_commander_path(source_path) + key = str(csv_path) + + if not force_reload: + cached = _CACHE.get(key) + if cached and _is_cache_valid(csv_path, cached): + return cached + + catalog = _build_catalog(csv_path) + _CACHE[key] = catalog + return catalog + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +def _resolve_commander_path(source_path: str | os.PathLike[str] | None) -> Path: + if source_path is not None: + return Path(source_path).resolve() + return (Path(csv_dir()) / "commander_cards.csv").resolve() + + +def _is_cache_valid(path: Path, cached: CommanderCatalog) -> bool: + try: + stat_result = path.stat() + except FileNotFoundError: + return False + mtime_ns = getattr(stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000)) + if mtime_ns != cached.mtime_ns: + return False + return stat_result.st_size == cached.size + + +def _build_catalog(path: Path) -> CommanderCatalog: + if not path.exists(): + raise FileNotFoundError(f"Commander CSV not found at {path}") + + entries: List[CommanderRecord] = [] + used_slugs: set[str] = set() + + with path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + if reader.fieldnames is None: + raise ValueError("Commander CSV missing header row") + + for index, row in enumerate(reader): + try: + record = _row_to_record(row, used_slugs) + except Exception: + continue + entries.append(record) + used_slugs.add(record.slug) + + stat_result = path.stat() + mtime_ns = getattr(stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000)) + etag = f"{stat_result.st_size}-{mtime_ns}-{len(entries)}" + frozen_entries = tuple(entries) + by_slug = {record.slug: record for record in frozen_entries} + return CommanderCatalog( + source_path=path, + etag=etag, + mtime_ns=mtime_ns, + size=stat_result.st_size, + entries=frozen_entries, + by_slug=by_slug, + ) + + +def _row_to_record(row: Mapping[str, object], used_slugs: Iterable[str]) -> CommanderRecord: + name = _clean_str(row.get("name")) or "Unknown Commander" + face_name = _clean_str(row.get("faceName")) + display_name = face_name or name + + base_slug = _slugify(display_name) + side = _clean_str(row.get("side")) + if side and side.lower() not in {"", "a"}: + candidate = f"{base_slug}-{side.lower()}" + else: + candidate = base_slug + slug = _dedupe_slug(candidate, used_slugs) + + color_identity, is_colorless = _parse_color_identity(row.get("colorIdentity")) + colors, _ = _parse_color_identity(row.get("colors")) + mana_cost = _clean_str(row.get("manaCost")) + mana_value = _parse_float(row.get("manaValue")) + type_line = _clean_str(row.get("type")) + creature_types = tuple(_parse_literal_list(row.get("creatureTypes"))) + oracle_text = _clean_multiline(row.get("text")) + 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"))) + 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" + partner_with = tuple(_extract_partner_with(oracle_text)) + is_partner = bool( + partner_with + or _contains_keyword(oracle_text, "partner") + or _contains_keyword(oracle_text, "friends forever") + or _contains_keyword(oracle_text, "doctor's companion") + ) + supports_backgrounds = _contains_keyword(oracle_text, "choose a background") + is_background = "background" in (type_line.lower() if type_line else "") + + image_small_url = _build_scryfall_url(display_name, "small") + image_normal_url = _build_scryfall_url(display_name, "normal") + search_haystack = _build_haystack(display_name, type_line, themes, creature_types, keywords, oracle_text) + + color_identity_key = "".join(color_identity) if color_identity else "C" + + return CommanderRecord( + name=name, + face_name=face_name, + display_name=display_name, + slug=slug, + color_identity=color_identity, + color_identity_key=color_identity_key, + is_colorless=is_colorless, + colors=colors, + mana_cost=mana_cost, + mana_value=mana_value, + type_line=type_line, + creature_types=creature_types, + oracle_text=oracle_text, + power=power, + toughness=toughness, + keywords=keywords, + themes=themes, + theme_tokens=theme_tokens, + edhrec_rank=edhrec_rank, + layout=layout, + side=side or None, + image_small_url=image_small_url, + image_normal_url=image_normal_url, + partner_with=partner_with, + is_partner=is_partner, + supports_backgrounds=supports_backgrounds, + is_background=is_background, + search_haystack=search_haystack, + ) + + +def _clean_str(value: object) -> str: + if value is None: + return "" + return str(value).strip() + + +def _clean_multiline(value: object) -> str: + if value is None: + return "" + text = str(value).replace("\r\n", "\n").replace("\r", "\n") + return "\n".join(line.rstrip() for line in text.split("\n")) + + +def _parse_float(value: object) -> Optional[float]: + text = _clean_str(value) + if not text: + return None + try: + return float(text) + except ValueError: + return None + + +def _parse_int(value: object) -> Optional[int]: + text = _clean_str(value) + if not text: + return None + try: + return int(float(text)) + except ValueError: + return None + + +def _parse_literal_list(value: object) -> List[str]: + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + return [str(v).strip() for v in value if str(v).strip()] + text = str(value).strip() + if not text: + return [] + try: + parsed = ast.literal_eval(text) + if isinstance(parsed, (list, tuple, set)): + return [str(v).strip() for v in parsed if str(v).strip()] + except Exception: + pass + parts = [part.strip() for part in text.replace(";", ",").split(",")] + return [part for part in parts if part] + + +def _split_to_list(value: object) -> List[str]: + text = _clean_str(value) + if not text: + return [] + parts = [part.strip() for part in text.split(",")] + return [part for part in parts if part] + + +def _extract_partner_with(text: str) -> List[str]: + if not text: + return [] + out: List[str] = [] + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line: + continue + anchor = "Partner with " + if anchor not in line: + continue + after = line.split(anchor, 1)[1] + # Remove reminder text in parentheses and trailing punctuation. + target = after.split("(", 1)[0] + target = target.replace(" and ", ",") + for token in target.split(","): + cleaned = token.strip().strip(".") + if cleaned: + out.append(cleaned) + return out + + +def _contains_keyword(text: str, needle: str) -> bool: + if not text: + return False + return needle.lower() in text.lower() + + +def _parse_color_identity(value: object) -> Tuple[Tuple[str, ...], bool]: + text = _clean_str(value) + if not text: + return tuple(), True + tokens = re.split(r"[\s,&/]+", text) + colors: List[str] = [] + colorless_flag = False + for token in tokens: + if not token: + continue + mapped = _COLOR_ALIAS.get(token.upper()) + if mapped is None: + continue + if mapped == "C": + colorless_flag = True + else: + if mapped not in colors: + colors.append(mapped) + ordered = tuple(color for color in _WUBRG_ORDER if color in colors) + if ordered: + return ordered, False + return tuple(), True if colorless_flag or text.upper() in {"C", "COLORLESS"} else False + + +def _slugify(value: str) -> str: + normalized = value.lower().strip() + normalized = normalized.replace("+", " plus ") + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + normalized = re.sub(r"-+", "-", normalized).strip("-") + return normalized or "commander" + + +def _dedupe_slug(initial: str, existing: Iterable[str]) -> str: + base = initial or "commander" + if base not in existing: + return base + counter = 2 + while f"{base}-{counter}" in existing: + counter += 1 + return f"{base}-{counter}" + + +def _build_scryfall_url(name: str, version: str) -> str: + encoded = quote(name, safe="") + return f"{_SCYRFALL_BASE}&version={version}&exact={encoded}" + + +def _build_haystack( + display_name: str, + type_line: str, + themes: Tuple[str, ...], + creature_types: Tuple[str, ...], + keywords: Tuple[str, ...], + oracle_text: str, +) -> str: + tokens: List[str] = [] + tokens.append(display_name.lower()) + if type_line: + tokens.append(type_line.lower()) + if themes: + tokens.extend(theme.lower() for theme in themes) + if creature_types: + tokens.extend(t.lower() for t in creature_types) + if keywords: + tokens.extend(k.lower() for k in keywords) + if oracle_text: + tokens.append(oracle_text.lower()) + return "|".join(t for t in tokens if t) diff --git a/code/web/services/telemetry.py b/code/web/services/telemetry.py new file mode 100644 index 0000000..17ab01f --- /dev/null +++ b/code/web/services/telemetry.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +import logging +from typing import Any, Dict + +from fastapi import Request + +__all__ = [ + "log_commander_page_view", + "log_commander_create_deck", +] + +_LOGGER = logging.getLogger("web.commander_browser") + + +def _emit(logger: logging.Logger, payload: Dict[str, Any]) -> None: + try: + logger.info(json.dumps(payload, separators=(",", ":"), ensure_ascii=False)) + except Exception: + pass + + +def _request_id(request: Request) -> str | None: + try: + rid = getattr(request.state, "request_id", None) + if rid: + return str(rid) + except Exception: + return None + return None + + +def _client_ip(request: Request) -> str | None: + try: + client = getattr(request, "client", None) + if client and getattr(client, "host", None): + return str(client.host) + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + except Exception: + return None + return None + + +def _query_snapshot(request: Request) -> Dict[str, Any]: + snapshot: Dict[str, Any] = {} + try: + params = request.query_params + items = params.multi_items() if hasattr(params, "multi_items") else params.items() + for key, value in items: + key = str(key) + value = str(value) + if key in snapshot: + existing = snapshot[key] + if isinstance(existing, list): + existing.append(value) + else: + snapshot[key] = [existing, value] + else: + snapshot[key] = value + except Exception: + return {} + return snapshot + + +def log_commander_page_view( + request: Request, + *, + page: int, + result_total: int, + result_count: int, + is_htmx: bool, +) -> None: + payload: Dict[str, Any] = { + "event": "commander_browser.page_view", + "request_id": _request_id(request), + "path": str(request.url.path), + "query": _query_snapshot(request), + "page": int(page), + "result_total": int(result_total), + "result_count": int(result_count), + "is_htmx": bool(is_htmx), + "client_ip": _client_ip(request), + } + _emit(_LOGGER, payload) + + +def log_commander_create_deck( + request: Request, + *, + commander: str, + return_url: str | None, +) -> None: + payload: Dict[str, Any] = { + "event": "commander_browser.create_deck", + "request_id": _request_id(request), + "path": str(request.url.path), + "query": _query_snapshot(request), + "commander": commander, + "has_return": bool(return_url), + "return_url": return_url, + "client_ip": _client_ip(request), + } + _emit(_LOGGER, payload) diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 6278a5c..9112a17 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -65,7 +65,7 @@ --blue-main: #1565c0; /* balanced blue */ } *{box-sizing:border-box} -html,body{height:100%; overflow-x:hidden; max-width:100vw;} +html{height:100%; overflow-x:hidden; overflow-y:hidden; max-width:100vw;} body { font-family: system-ui, Arial, sans-serif; margin: 0; @@ -73,8 +73,10 @@ body { background: var(--bg); display: flex; flex-direction: column; - min-height: 100vh; + height: 100%; width: 100%; + overflow-x: hidden; + overflow-y: auto; } /* Honor HTML hidden attribute across the app */ [hidden] { display: none !important; } @@ -198,6 +200,15 @@ button:hover{ filter:brightness(1.05); } .btn:hover{ filter:brightness(1.05); text-decoration:none; } .btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; } label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; } +.color-identity{ display:inline-flex; align-items:center; gap:.35rem; } +.color-identity .mana + .mana{ margin-left:4px; } +.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; } +.mana-W{ background:#f9fafb; border-color:#d1d5db; } +.mana-U{ background:#3b82f6; border-color:#1d4ed8; } +.mana-B{ background:#111827; border-color:#1f2937; } +.mana-R{ background:#ef4444; border-color:#b91c1c; } +.mana-G{ background:#10b981; border-color:#047857; } +.mana-C{ background:#d3d3d3; border-color:#9ca3af; } select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; } fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; } small, .muted{ color: var(--muted); } diff --git a/code/web/templates/base.html b/code/web/templates/base.html index b3a7760..f4af5af 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -3,6 +3,7 @@ + MTG Deckbuilder +{% endif %} {% endblock %} diff --git a/code/web/templates/commanders/index.html b/code/web/templates/commanders/index.html new file mode 100644 index 0000000..5e62626 --- /dev/null +++ b/code/web/templates/commanders/index.html @@ -0,0 +1,201 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Commanders

+

Browse the catalog and jump straight into a build with your chosen leader.

+
+ +
+ + + + +
+ +
+ Loading commanders… + +
+ +
+ {% include "commanders/list_fragment.html" %} +
+
+ + + +{% endblock %} diff --git a/code/web/templates/commanders/list_fragment.html b/code/web/templates/commanders/list_fragment.html new file mode 100644 index 0000000..b84b1f4 --- /dev/null +++ b/code/web/templates/commanders/list_fragment.html @@ -0,0 +1,38 @@ +
+ {% if error %} + + {% else %} +
+ {% if total_count %} + {% if commanders %} + Showing {{ page_start }} – {{ page_end }} of {{ result_total }} commander{% if result_total != 1 %}s{% endif %}{% if is_filtered %} (filtered){% endif %}. + {% else %} + No commanders matched your filters. + {% endif %} + {% else %} + No commander data available. + {% endif %} +
+ {% if commanders %} + {% set pagination_position = 'top' %} + {% include "commanders/pagination_controls.html" %} +
+ {% for entry in commanders %} + {% include "commanders/row_wireframe.html" %} + {% endfor %} +
+ {% if page_count > 1 %} + {% set pagination_position = 'bottom' %} + {% include "commanders/pagination_controls.html" %} + {% endif %} + {% else %} +

+ {% if total_count %} + No commanders matched your filters. + {% else %} + Commander catalog is empty. + {% endif %} +

+ {% endif %} + {% endif %} +
diff --git a/code/web/templates/commanders/pagination_controls.html b/code/web/templates/commanders/pagination_controls.html new file mode 100644 index 0000000..f968746 --- /dev/null +++ b/code/web/templates/commanders/pagination_controls.html @@ -0,0 +1,37 @@ +
diff --git a/code/web/templates/commanders/row_wireframe.html b/code/web/templates/commanders/row_wireframe.html new file mode 100644 index 0000000..f2400b5 --- /dev/null +++ b/code/web/templates/commanders/row_wireframe.html @@ -0,0 +1,56 @@ +{# Commander row partial fed by CommanderView entries #} +{% from "partials/_macros.html" import color_identity %} +{% set record = entry.record %} +
+
+ {% set small = record.image_small_url or record.image_normal_url %} + {{ record.display_name }} card art +
+
+
+

{{ record.display_name }}

+ {{ color_identity(record.color_identity, record.is_colorless, entry.color_aria_label, entry.color_label) }} +
+

{{ record.type_line or 'Legendary Creature' }}

+ {% if entry.themes %} +
+ {% for theme in entry.themes %} + {% set summary = theme.summary or 'Summary unavailable' %} + + {% endfor %} +
+ {% else %} +
+ No themes linked yet. +
+ {% endif %} + {% if entry.partner_summary %} +
+ {% for note in entry.partner_summary %} + {{ note }}{% if not loop.last %}{% endif %} + {% endfor %} +
+ {% endif %} +
+
+ Build +
+
diff --git a/code/web/templates/diagnostics/index.html b/code/web/templates/diagnostics/index.html index c7a4b7f..c51a46c 100644 --- a/code/web/templates/diagnostics/index.html +++ b/code/web/templates/diagnostics/index.html @@ -66,6 +66,7 @@ + 'SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0') + ', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0') + ', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0') + + ', SHOW_COMMANDERS='+ (flags.SHOW_COMMANDERS? '1':'0') + ', RANDOM_MODES='+ (flags.RANDOM_MODES? '1':'0') + ', RANDOM_UI='+ (flags.RANDOM_UI? '1':'0') + ', RANDOM_MAX_ATTEMPTS='+ String(flags.RANDOM_MAX_ATTEMPTS ?? '') diff --git a/code/web/templates/home.html b/code/web/templates/home.html index 4596a16..b4434bd 100644 --- a/code/web/templates/home.html +++ b/code/web/templates/home.html @@ -4,12 +4,14 @@
Build a Deck Run a JSON Config - {% if show_setup %}Initial Setup{% endif %} + {% if show_setup %}Initial Setup{% endif %} Owned Library + {% if show_commanders %}Browse Commanders{% endif %} Finished Decks - Browse Themes - {% if random_ui %}Random Build{% endif %} - {% if show_logs %}View Logs{% endif %} + Browse Themes + {% if random_ui %}Random Build{% endif %} + {% if show_diagnostics %}Diagnostics{% endif %} + {% if show_logs %}View Logs{% endif %}
Themes: … diff --git a/code/web/templates/partials/_macros.html b/code/web/templates/partials/_macros.html index bc2382c..d1c343f 100644 --- a/code/web/templates/partials/_macros.html +++ b/code/web/templates/partials/_macros.html @@ -11,3 +11,19 @@ {{ '🔒 Unlock' if locked else '🔓 Lock' }} {%- endmacro %} + +{% macro color_identity(colors, is_colorless=False, aria_label='', title_text='') -%} + +{%- endmacro %} diff --git a/csv_files/testdata/commander_cards.csv b/csv_files/testdata/commander_cards.csv index d5da2a5..9cd952c 100644 --- a/csv_files/testdata/commander_cards.csv +++ b/csv_files/testdata/commander_cards.csv @@ -1,3 +1,5 @@ name,faceName,edhrecRank,colorIdentity,colors,manaCost,manaValue,type,creatureTypes,text,power,toughness,keywords,themeTags,layout,side -Krenko, Mob Boss,,1200,R,R,{2}{R}{R},4,Legendary Creature,['Goblin'],Tap: Create X 1/1 red Goblin tokens.,3,3,,['Goblin Kindred'],normal, -Isamaru, Hound of Konda,,2500,W,W,{W},1,Legendary Creature,['Hound'],Legendary creature.,2,2,,['Dog Kindred'],normal, +"Krenko, Mob Boss","Krenko, Mob Boss",1200,R,R,{2}{R}{R},4,"Legendary Creature — Goblin","['Goblin']","Tap: Create X 1/1 red Goblin tokens.",3,3,,"['Goblin Kindred']",normal, +"Isamaru, Hound of Konda","Isamaru, Hound of Konda",2500,W,W,{W},1,"Legendary Creature — Hound","['Hound']","Legendary creature.",2,2,,"['Dog Kindred']",normal, +"Traxos, Scourge of Kroog","Traxos, Scourge of Kroog",3500,Colorless,,{4},4,"Legendary Artifact Creature — Construct","['Construct']","Trample\nTraxos enters the battlefield tapped and doesn't untap during your untap step.\nWhenever you cast a historic spell, untap Traxos.",7,7,Trample,"['Artifacts Matter']",normal, +"Atraxa, Praetors' Voice","Atraxa, Praetors' Voice",150,"B, G, U, W","B, G, U, W",{1}{G}{W}{U}{B},4,"Legendary Creature — Phyrexian Angel Horror","['Angel', 'Horror', 'Phyrexian']","Flying, vigilance, deathtouch, lifelink\nAt the beginning of your end step, proliferate.",4,4,"Deathtouch, Flying, Lifelink, Vigilance","['Counters Matter', 'Proliferate']",normal, diff --git a/docs/commander_catalog.md b/docs/commander_catalog.md new file mode 100644 index 0000000..3ef5ad6 --- /dev/null +++ b/docs/commander_catalog.md @@ -0,0 +1,72 @@ +# Commander Catalog Onboarding + +The Commander Browser and deck builder both read from `csv_files/commander_cards.csv`. This file is generated during setup and must stay in sync with the fields the web UI expects. Use this guide whenever you need to add a new commander, refresh the dataset, or troubleshoot missing entries. + +## Where the file lives + +- Default path: `csv_files/commander_cards.csv` +- Override: set `CSV_FILES_DIR` (env var) before launching the app; the loader resolves `commander_cards.csv` inside that directory. +- Caching: the web layer caches the parsed file in process. Restart the app or call `clear_commander_catalog_cache()` in a shell if you edit the CSV while the server is running. + +## Required columns + +The loader normalizes these columns; keep the header names exact. Optional fields can be blank but should still be present. + +| Column | Notes | +| --- | --- | +| `name` | Printed front name. Used as the fallback display label. +| `faceName` | Front face name for MDFCs/split cards. Defaults to `name` when empty. +| `side` | Leave blank or `A` for the primary face. Secondary faces become distinct slugs. +| `colorIdentity` | WUBRG characters (any casing). `C` marks colorless identities. +| `colors` | Printed colors; mainly used for ordering badges. +| `manaCost` | Optional but keeps rows sortable in the UI. +| `manaValue` | Numeric converted mana cost. +| `type` | Full type line (e.g., `Legendary Creature — Phyrexian Angel`). +| `creatureTypes` | Python/JSON list or comma-separated string of creature subtypes. +| `text` | Oracle text. Enables partner/background detection and hover tooltips. +| `power` / `toughness` | Optional stats. Leave blank for non-creatures. +| `keywords` | Comma-separated keywords (Flying, Vigilance, …). +| `themeTags` | Python/JSON list of curated themes (e.g., `['Angels', 'Life Gain']`). +| `edhrecRank` | Optional EDHREC popularity rank (integer). +| `layout` | Layout string from MTGJSON (`normal`, `modal_dfc`, etc.). + +Additional columns are preserved but ignored by the browser; feel free to keep upstream metadata. + +## Recommended refresh workflow + +1. Ensure dependencies are installed: `pip install -r requirements.txt`. +2. Regenerate the commander CSV using the setup module: + ```powershell + python -c "from file_setup.setup import regenerate_csvs_all; regenerate_csvs_all()" + ``` + This downloads the latest MTGJSON card dump (if needed), reapplies commander eligibility rules, and rewrites `commander_cards.csv`. +3. (Optional) If you only need a fresh commander list and already have up-to-date `cards.csv`, run: + ```powershell + python -c "from file_setup.setup import determine_commanders; determine_commanders()" + ``` +4. Restart the web server (or your desktop app) so the cache reloads the new file. +5. Validate with the targeted test: + ```powershell + python -m pytest -q code/tests/test_commander_catalog_loader.py + ``` + The test confirms required columns exist, normalization still works, and caching invalidates correctly. + +## Manual edits (quick fixes) + +If you need to hotfix a single row before a full regeneration: + +1. Open the CSV in a UTF-8 aware editor (Excel can re-save with a UTF-8 BOM — prefer a text editor when possible). +2. Add or edit the row, ensuring the slug-worthy fields (`name`, `faceName`, `side`) are unique. +3. Keep the `themeTags` value as a Python/JSON list (e.g., `['Artifacts']`), or a comma-delimited list without stray quotes. +4. Save the file and restart the server so the cache refreshes. +5. Backfill the curated themes in `config/themes/` if the new commander should surface dedicated tags. + +> Manual edits are acceptable for emergency fixes but commit regenerated data as soon as possible so automation stays trustworthy. + +## Troubleshooting + +- **`Commander catalog is unavailable` error**: The app could not find the CSV. Verify the file exists under `CSV_FILES_DIR` and has a header row. +- **Row missing in the browser**: Ensure the commander passed eligibility (legendary rules) and the row’s `layout`/`side` data is correct. Slug collisions are auto-deduped (`-2`, `-3`, …) but rely on unique `name`+`side` combos. +- **Theme chips absent**: Confirm `themeTags` contains at least one value and that the theme slug exists in the theme catalog; otherwise the UI hides the chips. + +For deeper issues, enable verbose logs with `SHOW_LOGS=1` before restarting the web process.